diff --git a/.forbidden-paths.regex b/.forbidden-paths.regex index 0ff046b..d6ed625 100644 --- a/.forbidden-paths.regex +++ b/.forbidden-paths.regex @@ -6,6 +6,17 @@ (^|/)\.codex/ (^|/)\.claude/ (^|/)\.cursor/ +(^|/)\.astro/ +(^|/)\.cache/ +(^|/)\.vite/ +(^|/)\.turbo/ +(^|/)\.nyc_output/ +(^|/)(node_modules|target|dist|coverage|tmp)/ +(^|/)npm/native/ + +# Generated reports stay local. The compact public byte certificate is the +# single deliberate tracked exception; raw workdirs and host logs are blocked. +(^|/)reports/(?!release-ready-v[0-9]+\.[0-9]+\.[0-9]+/public-byte-certification\.json$) # Environment and credential files (^|/)\.env$ @@ -45,3 +56,13 @@ (^|/)\.idea/ (^|/)\.vscode/settings\.json$ (^|/)\.vscode/launch\.json$ + +# Runtime transcripts, databases, and diagnostics +\.log$ +\.jsonl$ +\.(stdout|stderr|trace|pid)$ +\.(sqlite|sqlite3|db)($|-) +\.(profraw|profdata)$ +\.dSYM/ +(\.tgz|\.crate)$ +(^|/)core($|\.) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 8295ef9..70d2ee6 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,3 +1,4 @@ #!/usr/bin/env sh set -eu node scripts/hooks/block-forbidden-staged-files.mjs +node scripts/hooks/block-sensitive-diff-content.mjs diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index c2ce6b2..ea71163 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -25,6 +25,25 @@ jobs: node-version: 22 - run: node scripts/hooks/block-forbidden-staged-files.mjs --tracked + sensitive-added-content: + name: Sensitive added content + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' || github.event_name == 'push' + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 22 + - name: Check pull request additions + if: github.event_name == 'pull_request' + run: node scripts/hooks/block-sensitive-diff-content.mjs --base "${{ github.event.pull_request.base.sha }}" + - name: Check pushed additions + if: github.event_name == 'push' && github.event.before != '0000000000000000000000000000000000000000' + run: node scripts/hooks/block-sensitive-diff-content.mjs --base "${{ github.event.before }}" + trufflehog: name: TruffleHog verified secrets runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 3402d3c..ddf7490 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,10 @@ id_dsa # Node and deployment tooling /node_modules/ /.astro/ +/.cache/ +/.vite/ +/.turbo/ +/.nyc_output/ /.npm-package/ /npm/native/ /.pnpm-store/ @@ -46,6 +50,23 @@ id_dsa /.wrangler/ pnpm-debug.log* *.log +*.jsonl +*.out +*.stdout +*.stderr +*.trace +*.pid +*.pid.lock +*.db +*.db-* +*.sqlite3 +*.sqlite3-* +*.profraw +*.profdata +*.dSYM/ +core +core.* +/*.tgz # Infrastructure state *.tfstate diff --git a/Cargo.toml b/Cargo.toml index 2907e4d..0ebba92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,6 @@ exclude = [ "xtask/**", "scripts/**", "dist/**", - "docs/prepublish-certification-*.md", "reports/**", "retained-evidence/**", "tmp/**", diff --git a/README.md b/README.md index cc276fd..1dbace6 100644 --- a/README.md +++ b/README.md @@ -36,30 +36,22 @@ verify it against `SHA256SUMS`, and place `model-routing` on your `PATH`. ## Setup from the website -The generator at [switchloom.ai](https://switchloom.ai) produces only the versioned -`SetupSpecV1` transport. It does not compile or write host files in the browser. Its primary -action copies a shell-safe command like: +The generator at [switchloom.ai](https://switchloom.ai) produces only the +versioned `SetupSpecV1` transport. It does not compile or write host files in +the browser. After choosing a provider and team, open the provider onboarding +flow, run the copied apply command from the target repository, then run doctor: ```sh -npx switchloom@0.3.1 preview --recipe 'sw1_...' -npx switchloom@0.3.1 apply --recipe 'sw1_...' +npx switchloom@0.3.1 apply --recipe 'sw1_...' --repository . +npx switchloom doctor codex ``` -The secondary action downloads the same setup as a readable `.switchloom/config.toml`: - -```sh -switchloom preview --config .switchloom/config.toml -switchloom apply --config .switchloom/config.toml -switchloom status -switchloom update -switchloom rollback -switchloom uninstall -``` - -Setup-backed apply previews the exact repository-local change set and asks for confirmation. -Use `--yes` only in an explicitly non-interactive workflow. Standalone mode never emits -`.planr`; optional Planr mode emits provider-neutral Planr declarations and thin native roles, -while Switchloom remains independent of Planr at build and runtime. +The onboarding steps include the provider version check and project-local +setup guidance. CLI apply previews the exact repository-local change set and +asks for confirmation before writing it. Use `--yes` only in an explicitly +non-interactive workflow. Standalone mode never emits `.planr`; optional Planr +mode emits provider-neutral Planr declarations and thin native roles, while +Switchloom remains independent of Planr at build and runtime. For direct CLI use, compile a bundle and run the same lifecycle against a repository: @@ -79,10 +71,9 @@ apply when setup depends on a locally installed host CLI. ## Current Status -The v0.3.1 public CLI compiles independently, preserves the frozen -Planr v1.5.0 routing inventory in [docs/migration-baseline.md](docs/migration-baseline.md), -and hard-cuts maintainer-only evaluation, catalog, registry, and live-verification -operations from the public command surface. +The v0.3.1 public CLI compiles independently and hard-cuts maintainer-only +evaluation, catalog, registry, and live-verification operations from the +public command surface. Current Planr handoff rules, runtime classes, and maintainer verification gates are in [docs/model-routing-policy.md](docs/model-routing-policy.md). Planr consumes @@ -101,9 +92,9 @@ cargo run -- policy list ## Website Generator -The static Astro website is an above-the-fold team generator built with React and shadcn. Users first choose standalone or optional Planr integration, then choose Codex, Cursor, Claude Code, OpenCode, or Pi, select up to four explicit roles, start from a Light, Balanced, or High team preset, and optionally override each role's model and reasoning effort. The primary result is a CLI recipe; the secondary result is a readable setup config. Only the Rust CLI compiles and applies project-native files. Codex is shown as an internal V2 thread-tree path; Cursor, Claude Code, and OpenCode are native subagent paths; Pi is an external runner path; separate app tasks are not treated as Codex V2 child threads. The host remains authoritative for model availability, execution, and billing. +The static Astro website is an above-the-fold team generator built with React and shadcn. Users first choose standalone or optional Planr integration, then choose Codex, Cursor, Claude Code, OpenCode, or Pi, select up to four explicit roles, start from a Light, Balanced, or High team preset, and optionally override each role's model and reasoning effort. The result is a provider-specific onboarding flow with a CLI apply recipe and doctor command. Only the Rust CLI previews, compiles, and applies project-native files. Codex is shown as an internal V2 thread-tree path; Cursor, Claude Code, and OpenCode are native subagent paths; Pi is an external runner path; separate app tasks are not treated as Codex V2 child threads. The host remains authoritative for model availability, execution, and billing. -Claude Code model and effort options are derived at build time from the canonical catalog produced by the Rust compiler. Codex mirrors its current desktop picker: `low`, `medium`, `high`, and `xhigh`, while Terra and Sol additionally expose `ultra` as a manual-only mode. Pure `max` is intentionally omitted because the desktop picker does not expose it separately; Ultra sends Max reasoning plus automatic multi-agent delegation. Light, Balanced, and High never select Ultra, and Codex defaults keep mechanical work on certified Terra rather than Luna. Luna remains selectable only as an explicit experimental/unverified choice until authentic Codex 0.145.0 V2 evidence is independently reviewed. Cursor uses a deliberately small, researched frontier allowlist because its full picker changes frequently; the website presents those models in a searchable selector. Generated custom setups are local and unverified until the user reviews them. +Claude Code model and effort options are derived at build time from the canonical catalog produced by the Rust compiler. Codex exposes `low`, `medium`, `high`, `xhigh`, and `max` for Luna, Terra, and Sol; Terra and Sol additionally expose `ultra`. Max is the largest single-agent reasoning budget and may need to be enabled in Codex app settings, while Ultra adds automatic multi-agent delegation. Light, Balanced, and High never select Max or Ultra, and Codex defaults keep mechanical work on certified Terra rather than Luna. Luna remains selectable only as an explicit experimental/unverified choice and stays out of V2 presets until authentic V2 support is independently reviewed. Cursor uses a deliberately small, researched frontier allowlist because its full picker changes frequently; the website presents those models in a searchable selector. Generated custom setups are local and unverified until the user reviews them. ```sh cargo run -p xtask -- release prepare --allow-dirty @@ -112,10 +103,10 @@ pnpm site:check pnpm site:dev ``` -The website setup contract is equivalent to the CLI lifecycle: the copied -`npx switchloom@0.3.1 apply --recipe 'sw1_...' --repository .` command and the -downloadable `.switchloom/config.toml` both replay through CLI preview/apply -before any repository-local artifact is written. +The website setup contract is the provider onboarding flow: the copied +`npx switchloom@0.3.1 apply --recipe 'sw1_...' --repository .` command runs the +CLI-owned preview and confirmation before writing repository-local artifacts, +and the provider-specific doctor command verifies the resulting host setup. The Cloudflare/Alchemy publication stack is repo-owned and requires Node.js 22 or newer. Test deployments are pinned to the `test` stage; production publishes the custom `switchloom.ai` domain only from the explicit `prod` stage: diff --git a/docs/model-routing-policy.md b/docs/model-routing-policy.md index 8848f70..644059a 100644 --- a/docs/model-routing-policy.md +++ b/docs/model-routing-policy.md @@ -51,7 +51,7 @@ For Codex, the runtime behavior contract freezes official Codex CLI `0.145.0` as both the minimum and maximum supported capability version for this contract slice. The digest-bound source artifact is -`docs/codex-v2-runtime-evidence.json#sha256:`. That artifact records +`evidence/codex/0.145.0/runtime-evidence.json#sha256:`. That artifact records the `codex --version` observation, Codex account/workspace state as the actual backend selection owner, a three-child limit derived from four active agents including the root session, shared repository filesystem behavior, explicit @@ -67,9 +67,10 @@ dispatch, Ultra behavior, and role precedence: Native Codex setup is repository-local only: it writes managed `.codex` project config and role files, including `[features.multi_agent_v2]` with -`enabled = true` and `hide_spawn_agent_metadata = false`. It preserves unrelated -project and global Codex configuration, and does not generate or instruct -nested `codex exec` dispatch. After apply/update/rollback, Codex may require +`enabled = true` and `hide_spawn_agent_metadata = true`. The latter preserves +Codex 0.145's backend-compatible reserved `collaboration.spawn_agent` schema. +It preserves unrelated project and global Codex configuration, and does not +generate or instruct nested `codex exec` dispatch. After apply/update/rollback, Codex may require trusting the project and reloading or restarting the host session before the generated role registrations are discoverable. diff --git a/docs/package-policy.md b/docs/package-policy.md index cfad5e6..fc25fb1 100644 --- a/docs/package-policy.md +++ b/docs/package-policy.md @@ -8,12 +8,25 @@ The repository must be safe to publish from a dirty local coordination environme - `.claude/`, `.codex/`, and `.cursor/` host-local state. - Credentials, private keys, `.env` files except `.env.example`, generated reports, and build output. - Regenerated website/package output such as `dist/`, `coverage/`, `tmp/`, and `.crate` files. +- Historical migration, handoff, and release records under `retained-evidence/`. The policy is enforced by `.gitignore`, `Cargo.toml` `exclude`, and the CI package-content audit. ## Publishable Inputs -Only source, fixtures, docs, CI metadata, and deterministic generator inputs should enter the package. Live verification receipts and authenticated-host evidence belong in release notes or reviewed handoff docs after secret scrubbing, not in the crate payload. +The npm tarball contains only package metadata, README, LICENSE, the launcher, +and supported native binaries. The Cargo source package additionally retains +the versioned Codex runtime evidence embedded by `src/evidence.rs`, plus source, +fixtures, current maintainer docs, CI metadata, and deterministic generator +inputs. Live verification receipts and authenticated-host evidence belong in +reviewed retained records after secret scrubbing, not in either payload. + +## Documentation Owners + +README and switchloom.ai own end-user setup and usage. Current maintainer +contracts remain in `docs/`; immutable runtime inputs live under `evidence/`; +historical migration, handoff, and release records live under +`retained-evidence/`. ## v0.3.1 Public Boundary diff --git a/docs/preset-registry.md b/docs/preset-registry.md index 3d6cf9d..a73d579 100644 --- a/docs/preset-registry.md +++ b/docs/preset-registry.md @@ -15,6 +15,6 @@ Unsigned catalog entries remain experimental. The public website uses this catalog at build time for Codex and Claude Code model, effort, and cost-tier options. Cursor is the narrow exception: its model picker changes frequently, so the generator exposes a reviewed frontier allowlist rather than every historical catalog profile. The current list was reviewed on 2026-07-17 against [CursorBench](https://cursor.com/de/cursorbench), Cursor's model documentation, and announcements for [Composer 2.5](https://cursor.com/changelog/composer-2-5) and [Grok 4.5](https://cursor.com/blog/grok-4-5). CursorBench is the source of truth for the exposed per-model reasoning levels; GPT-5.6 Luna, Terra, and Sol each expose Low, Medium, High, Extra High, and Max. -The generator lets a user assemble up to four explicit roles and download host-native project files. Light, Balanced, and High are transparent UI starting points that set every role at once; changing any model or effort switches the UI to Custom. Light maps to the hosts' actual `low` effort value—there is no host value named `light`. Codex Ultra remains a manual-only mode because it enables automatic multi-agent delegation; no preset selects it. Official and experimental catalog entries come from repository-owned inputs; signed and recommended states require trusted signatures and current evaluation evidence. Generated custom setups remain local/user-owned and unverified until reviewed. +The generator lets a user assemble up to four explicit roles, then opens the selected provider's onboarding flow with a CLI apply recipe and doctor command. Only the CLI previews and writes host-native project files. Light, Balanced, and High are transparent UI starting points that set every role at once; changing any model or effort switches the UI to Custom. Light maps to the hosts' actual `low` effort value—there is no host value named `light`. Codex Max remains a manual single-agent option, while Ultra remains a manual multi-agent delegation mode; no preset selects either. Official and experimental catalog entries come from repository-owned inputs; signed and recommended states require trusted signatures and current evaluation evidence. Generated custom setups remain local/user-owned and unverified until reviewed. Kimi K3 was reviewed on 2026-07-18 after its initial release. Moonshot currently exposes `kimi-k3` with Max thinking only and says Low and High will follow. It is not yet native in Cursor, Codex requires machine-local provider configuration that a repository ZIP cannot safely supply, and Claude Code routes its entire process through the Moonshot compatibility endpoint rather than selecting Kimi independently per role. Kimi K3 therefore remains pending instead of appearing as a selectable model that would generate incomplete host configuration. diff --git a/docs/codex-0.145-exact-version-capture.txt b/evidence/codex/0.145.0/exact-version-capture.txt similarity index 100% rename from docs/codex-0.145-exact-version-capture.txt rename to evidence/codex/0.145.0/exact-version-capture.txt diff --git a/docs/codex-v2-runtime-evidence.json b/evidence/codex/0.145.0/runtime-evidence.json similarity index 99% rename from docs/codex-v2-runtime-evidence.json rename to evidence/codex/0.145.0/runtime-evidence.json index 966c751..ec9e717 100644 --- a/docs/codex-v2-runtime-evidence.json +++ b/evidence/codex/0.145.0/runtime-evidence.json @@ -56,7 +56,7 @@ "required_raw_fragments": [ "codex-cli 0.145.0" ], - "source_path": "docs/codex-0.145-exact-version-capture.txt", + "source_path": "evidence/codex/0.145.0/exact-version-capture.txt", "raw_output": "codex-cli 0.145.0\n", "raw_output_sha256": "f9eb0c462cdded1fb971b33c647ff1b8b491dfe962a1506026e07a06f634f651" } diff --git a/fixtures/routing-bundle-v1/invalid-runtime-capability-mismatch.json b/fixtures/routing-bundle-v1/invalid-runtime-capability-mismatch.json index d557e8a..6bd027f 100644 --- a/fixtures/routing-bundle-v1/invalid-runtime-capability-mismatch.json +++ b/fixtures/routing-bundle-v1/invalid-runtime-capability-mismatch.json @@ -351,7 +351,7 @@ "automatic_delegation_requires_ultra": true }, "source_references": [ - "docs/codex-v2-runtime-evidence.json#sha256:1ca8b20e2e8b2c57f741d905bbfed6971a54e0520b3e9a2e9e8e5eefc0aa123d" + "evidence/codex/0.145.0/runtime-evidence.json#sha256:2c3b2fe9cb99a94d7a7c32f54dc5afd9f51449e9a0d56b711a8321bc55d7bd31" ] }, "discovery_artifacts": [ diff --git a/fixtures/routing-bundle-v1/invalid-runtime-slot-count.json b/fixtures/routing-bundle-v1/invalid-runtime-slot-count.json index 99771e5..168fa56 100644 --- a/fixtures/routing-bundle-v1/invalid-runtime-slot-count.json +++ b/fixtures/routing-bundle-v1/invalid-runtime-slot-count.json @@ -351,7 +351,7 @@ "automatic_delegation_requires_ultra": true }, "source_references": [ - "docs/codex-v2-runtime-evidence.json#sha256:1ca8b20e2e8b2c57f741d905bbfed6971a54e0520b3e9a2e9e8e5eefc0aa123d" + "evidence/codex/0.145.0/runtime-evidence.json#sha256:2c3b2fe9cb99a94d7a7c32f54dc5afd9f51449e9a0d56b711a8321bc55d7bd31" ] }, "discovery_artifacts": [ diff --git a/fixtures/routing-bundle-v1/invalid-runtime-ultra-delegation.json b/fixtures/routing-bundle-v1/invalid-runtime-ultra-delegation.json index b34b8d7..5d66ab4 100644 --- a/fixtures/routing-bundle-v1/invalid-runtime-ultra-delegation.json +++ b/fixtures/routing-bundle-v1/invalid-runtime-ultra-delegation.json @@ -351,7 +351,7 @@ "automatic_delegation_requires_ultra": true }, "source_references": [ - "docs/codex-v2-runtime-evidence.json#sha256:1ca8b20e2e8b2c57f741d905bbfed6971a54e0520b3e9a2e9e8e5eefc0aa123d" + "evidence/codex/0.145.0/runtime-evidence.json#sha256:2c3b2fe9cb99a94d7a7c32f54dc5afd9f51449e9a0d56b711a8321bc55d7bd31" ] }, "discovery_artifacts": [ diff --git a/fixtures/routing-bundle-v1/invalid-runtime-version-drift.json b/fixtures/routing-bundle-v1/invalid-runtime-version-drift.json index 14f8d12..a9ba579 100644 --- a/fixtures/routing-bundle-v1/invalid-runtime-version-drift.json +++ b/fixtures/routing-bundle-v1/invalid-runtime-version-drift.json @@ -351,7 +351,7 @@ "automatic_delegation_requires_ultra": true }, "source_references": [ - "docs/codex-v2-runtime-evidence.json#sha256:1ca8b20e2e8b2c57f741d905bbfed6971a54e0520b3e9a2e9e8e5eefc0aa123d" + "evidence/codex/0.145.0/runtime-evidence.json#sha256:2c3b2fe9cb99a94d7a7c32f54dc5afd9f51449e9a0d56b711a8321bc55d7bd31" ] }, "discovery_artifacts": [ diff --git a/fixtures/routing-bundle-v1/valid-balanced-codex.json b/fixtures/routing-bundle-v1/valid-balanced-codex.json index e995626..abcdafe 100644 --- a/fixtures/routing-bundle-v1/valid-balanced-codex.json +++ b/fixtures/routing-bundle-v1/valid-balanced-codex.json @@ -255,8 +255,8 @@ "path": ".codex/config.toml", "media_type": "application/toml", "mode": "replace", - "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_sol_medium]\nconfig_file = \"./agents/model-routing-sol-medium.toml\"\n\n[agents.model_routing_sol_ultra]\nconfig_file = \"./agents/model-routing-sol-ultra.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[agents.model_routing_terra_medium]\nconfig_file = \"./agents/model-routing-terra-medium.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = false\n", - "sha256": "5734ff3444010de65901ea3efda663f26e1c240718898b5d96736f9550a83d3e" + "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_sol_medium]\nconfig_file = \"./agents/model-routing-sol-medium.toml\"\n\n[agents.model_routing_sol_ultra]\nconfig_file = \"./agents/model-routing-sol-ultra.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[agents.model_routing_terra_medium]\nconfig_file = \"./agents/model-routing-terra-medium.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = true\n", + "sha256": "36150ae4d6e37a56c4911b5888fb1939446a9eab1c9cba173c79cc6e85f776eb" }, { "path": ".planr/agents.toml", @@ -371,7 +371,7 @@ "automatic_delegation_requires_ultra": true }, "source_references": [ - "docs/codex-v2-runtime-evidence.json#sha256:1ca8b20e2e8b2c57f741d905bbfed6971a54e0520b3e9a2e9e8e5eefc0aa123d" + "evidence/codex/0.145.0/runtime-evidence.json#sha256:2c3b2fe9cb99a94d7a7c32f54dc5afd9f51449e9a0d56b711a8321bc55d7bd31" ] }, "discovery_artifacts": [ diff --git a/fixtures/routing-bundle-v1/valid-balanced-mixed.json b/fixtures/routing-bundle-v1/valid-balanced-mixed.json index f829c36..165b817 100644 --- a/fixtures/routing-bundle-v1/valid-balanced-mixed.json +++ b/fixtures/routing-bundle-v1/valid-balanced-mixed.json @@ -195,8 +195,8 @@ "path": ".codex/config.toml", "media_type": "application/toml", "mode": "replace", - "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = false\n", - "sha256": "62c632cf3eb279cd1f82c71a527559b2a40c75f2646307d5a1df3523dd12d016" + "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = true\n", + "sha256": "1068d38f406cf4ca7160557d8a16cfbe6fc8684e89245ffba823cb510b47e594" }, { "path": ".cursor/agents/model-routing-fable-driver.md", diff --git a/npm/package-inventory.test.mjs b/npm/package-inventory.test.mjs new file mode 100644 index 0000000..41b735c --- /dev/null +++ b/npm/package-inventory.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const root = path.resolve("."); +const supportedTargets = [ + "darwin-arm64", + "darwin-x86_64", + "linux-arm64", + "linux-x86_64", +]; + +test("npm pack admits only the explicit supported native targets", async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), "switchloom-npm-inventory-")); + t.after(() => rm(directory, { force: true, recursive: true })); + + for (const file of ["package.json", "README.md", "LICENSE"]) { + await copyFile(path.join(root, file), path.join(directory, file)); + } + await mkdir(path.join(directory, "npm/bin"), { recursive: true }); + await copyFile( + path.join(root, "npm/bin/model-routing.js"), + path.join(directory, "npm/bin/model-routing.js"), + ); + + for (const target of [...supportedTargets, "unsupported"]) { + const targetDirectory = path.join(directory, "npm/native", target); + await mkdir(targetDirectory, { recursive: true }); + await writeFile(path.join(targetDirectory, "model-routing"), `${target}\n`); + } + await writeFile( + path.join(directory, "npm/native/provenance.json"), + '{"schema_version":"test"}\n', + ); + await mkdir(path.join(directory, "docs")); + await writeFile(path.join(directory, "docs/should-not-ship.md"), "private docs\n"); + await mkdir(path.join(directory, "reports")); + await writeFile(path.join(directory, "reports/should-not-ship.json"), "{}\n"); + + const packed = spawnSync("npm", ["pack", "--dry-run", "--json"], { + cwd: directory, + encoding: "utf8", + }); + assert.equal(packed.status, 0, packed.stderr); + const files = JSON.parse(packed.stdout)[0].files.map(({ path: file }) => file).sort(); + const expected = [ + "LICENSE", + "README.md", + "npm/bin/model-routing.js", + ...supportedTargets.map((target) => `npm/native/${target}/model-routing`), + "npm/native/provenance.json", + "package.json", + ].sort(); + + assert.deepEqual(files, expected); + assert.ok(!files.includes("npm/native/unsupported/model-routing")); + assert.ok(files.every((file) => !file.startsWith("docs/") && !file.startsWith("reports/"))); +}); diff --git a/package.json b/package.json index 477f927..2ac65e6 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,13 @@ }, "files": [ "npm/bin/model-routing.js", - "npm/native", + "npm/native/darwin-arm64/model-routing", + "npm/native/darwin-x86_64/model-routing", + "npm/native/linux-arm64/model-routing", + "npm/native/linux-x86_64/model-routing", + "npm/native/provenance.json", "README.md", - "LICENSE", - "docs", - "!docs/prepublish-certification-*.md" + "LICENSE" ], "publishConfig": { "access": "public", @@ -33,6 +35,7 @@ "distribution:test": "node --test npm/*.test.mjs && bash scripts/npm-pack-check.sh", "destroy:test": "node scripts/cloudflare-test.mjs destroy", "hooks:install": "git config core.hooksPath .githooks", + "hooks:check-sensitive": "node scripts/hooks/block-sensitive-diff-content.mjs --worktree", "pack:check": "bash scripts/npm-pack-check.sh", "security:check": "bash scripts/secleak-check.sh", "site:build": "astro build && node scripts/build-site.mjs", diff --git a/docs/planr-hard-cut-handoff.md b/retained-evidence/handoffs/v0.3.1/planr-hard-cut-handoff.md similarity index 98% rename from docs/planr-hard-cut-handoff.md rename to retained-evidence/handoffs/v0.3.1/planr-hard-cut-handoff.md index 5b7bf2c..a4a3cbf 100644 --- a/docs/planr-hard-cut-handoff.md +++ b/retained-evidence/handoffs/v0.3.1/planr-hard-cut-handoff.md @@ -3,7 +3,7 @@ Date: 2026-07-22 This is the durable Goal A gate for the Planr Goal B hard cut. It does not -modify `/Users/kregenrek/projects/planr`; it records the independently reviewed +modify `${HOME}/projects/planr`; it records the independently reviewed and published Switchloom owner plus the exact Planr responsibilities to keep, replace, or delete. @@ -152,7 +152,7 @@ wrapper, or second source of truth for removed routing bundle commands. ## Goal B Deletion Oracle -Goal B may start in `/Users/kregenrek/projects/planr` only after certification +Goal B may start in `${HOME}/projects/planr` only after certification item `i-certify-public-bytes-and-finaliz-ad43` and its independent review close, the approval list is empty, and `planr plan audit pln-45ebe887 --json` passes. diff --git a/retained-evidence/live-web/generator/desktop-generator-hierarchy.png b/retained-evidence/live-web/generator/desktop-generator-hierarchy.png new file mode 100644 index 0000000..cc2927c Binary files /dev/null and b/retained-evidence/live-web/generator/desktop-generator-hierarchy.png differ diff --git a/retained-evidence/live-web/generator/desktop-generator.png b/retained-evidence/live-web/generator/desktop-generator.png new file mode 100644 index 0000000..3ff58ec Binary files /dev/null and b/retained-evidence/live-web/generator/desktop-generator.png differ diff --git a/retained-evidence/live-web/generator/desktop-reset-confirmation.png b/retained-evidence/live-web/generator/desktop-reset-confirmation.png new file mode 100644 index 0000000..1185bc8 Binary files /dev/null and b/retained-evidence/live-web/generator/desktop-reset-confirmation.png differ diff --git a/retained-evidence/live-web/generator/mobile-generator.png b/retained-evidence/live-web/generator/mobile-generator.png new file mode 100644 index 0000000..bc7085e Binary files /dev/null and b/retained-evidence/live-web/generator/mobile-generator.png differ diff --git a/retained-evidence/live-web/generator/mobile-reset-confirmation.png b/retained-evidence/live-web/generator/mobile-reset-confirmation.png new file mode 100644 index 0000000..b2ab482 Binary files /dev/null and b/retained-evidence/live-web/generator/mobile-reset-confirmation.png differ diff --git a/docs/migration-baseline.md b/retained-evidence/migrations/v0.3.0/migration-baseline.md similarity index 90% rename from docs/migration-baseline.md rename to retained-evidence/migrations/v0.3.0/migration-baseline.md index 90adc34..eac1bae 100644 --- a/docs/migration-baseline.md +++ b/retained-evidence/migrations/v0.3.0/migration-baseline.md @@ -4,11 +4,11 @@ This document records the immutable extraction baseline for the standalone Switc ## Frozen Source -- Source repository: `/Users/kregenrek/projects/planr` +- Source repository: `${HOME}/projects/planr` - Frozen tag: `v1.5.0` - Frozen commit: `7a01ad54cb41fd755f368a79339a96a997f693d0` -- Baseline command: `git -C /Users/kregenrek/projects/planr rev-list -n 1 v1.5.0` -- Current standalone repository rule: do not edit `/Users/kregenrek/projects/planr` product code during Goal A. +- Baseline command: `git -C "${HOME}/projects/planr" rev-list -n 1 v1.5.0` +- Current standalone repository rule: do not edit `${HOME}/projects/planr` product code during Goal A. ## Ownership Disposition Manifest diff --git a/docs/migration-manifest.tsv b/retained-evidence/migrations/v0.3.0/migration-manifest.tsv similarity index 100% rename from docs/migration-manifest.tsv rename to retained-evidence/migrations/v0.3.0/migration-manifest.tsv diff --git a/docs/v0.3.0-migration-characterization.md b/retained-evidence/migrations/v0.3.0/v0.3.0-migration-characterization.md similarity index 96% rename from docs/v0.3.0-migration-characterization.md rename to retained-evidence/migrations/v0.3.0/v0.3.0-migration-characterization.md index ca5d8a3..8d0cb10 100644 --- a/docs/v0.3.0-migration-characterization.md +++ b/retained-evidence/migrations/v0.3.0/v0.3.0-migration-characterization.md @@ -45,9 +45,9 @@ git rev-parse HEAD git status --porcelain=v1 -uall git status --porcelain=v1 -uall | shasum -a 256 git describe --tags --always --dirty -git -C /Users/kregenrek/projects/planr rev-parse HEAD -git -C /Users/kregenrek/projects/planr status --porcelain=v1 -uall | shasum -a 256 -git -C /Users/kregenrek/projects/planr status --short --branch +git -C "${HOME}/projects/planr" rev-parse HEAD +git -C "${HOME}/projects/planr" status --porcelain=v1 -uall | shasum -a 256 +git -C "${HOME}/projects/planr" status --short --branch ``` - Switchloom HEAD: `5c68fca30266b71a7694461c293d9565ea280cb2`. diff --git a/docs/prepublish-certification-0.2.2.md b/retained-evidence/releases/v0.2.2/prepublish-certification-0.2.2.md similarity index 98% rename from docs/prepublish-certification-0.2.2.md rename to retained-evidence/releases/v0.2.2/prepublish-certification-0.2.2.md index be87f52..e7a141f 100644 --- a/docs/prepublish-certification-0.2.2.md +++ b/retained-evidence/releases/v0.2.2/prepublish-certification-0.2.2.md @@ -48,7 +48,7 @@ Publish Planr item: `i-publish-and-post-publish-verify-de77` - `bash -n scripts/release.sh scripts/build-release.sh scripts/npm-pack-check.sh scripts/secleak-check.sh scripts/codex-standalone-oracle.sh scripts/native-host-certification-oracle.sh` - `git diff --check` - `git check-ignore -v reports/native-host-certification/current/certification-summary.json dist/website/index.html target/release/model-routing npm/native/darwin-arm64/model-routing .planr` -- `/Users/kregenrek/.agents/skills/secleak-check/scripts/secleak-check.sh` passed after removing generated `node_modules`; BetterLeaks found no leaks and Trivy reported zero Cargo vulnerabilities. +- `${HOME}/.agents/skills/secleak-check/scripts/secleak-check.sh` passed after removing generated `node_modules`; BetterLeaks found no leaks and Trivy reported zero Cargo vulnerabilities. - `scripts/build-release.sh` - `target/release/model-routing --version` - `SWITCHLOOM_NATIVE_BIN="$PWD/target/release/model-routing" node npm/bin/model-routing.js --version` diff --git a/docs/prepublish-certification-0.3.0.md b/retained-evidence/releases/v0.3.0/prepublish-certification-0.3.0.md similarity index 99% rename from docs/prepublish-certification-0.3.0.md rename to retained-evidence/releases/v0.3.0/prepublish-certification-0.3.0.md index 8b4fa1f..d1fb42e 100644 --- a/docs/prepublish-certification-0.3.0.md +++ b/retained-evidence/releases/v0.3.0/prepublish-certification-0.3.0.md @@ -15,7 +15,7 @@ Final handoff Planr item: `i-finalize-durable-release-and-pla-2dac` - Tag object: `8306c2533be0cbeb9c6801651cb08d0d244d7678` - Tag target: `45c15fed09786c9dee1167744b1c43b87b47d505` - Protected Planr repository: - `/Users/kregenrek/projects/planr` + `${HOME}/projects/planr` - Protected Planr HEAD: `bbc877d40191b2cbb289ed26df5e6fee25e4326d` - Protected Planr dirty-state hash: diff --git a/docs/prepublish-certification-0.3.1.md b/retained-evidence/releases/v0.3.1/prepublish-certification-0.3.1.md similarity index 98% rename from docs/prepublish-certification-0.3.1.md rename to retained-evidence/releases/v0.3.1/prepublish-certification-0.3.1.md index ba3037e..30b5b86 100644 --- a/docs/prepublish-certification-0.3.1.md +++ b/retained-evidence/releases/v0.3.1/prepublish-certification-0.3.1.md @@ -128,7 +128,7 @@ failing closed with `parent must contain exactly 2 V2 spawn_agent calls`. `f9920edf6c71288ee3e266c3e884647bc7df17c512a5038b0f9eb7c5d7881ede`. The protected global-config snapshots match before and after both runs. The -capability boundary remains frozen in `docs/codex-v2-runtime-evidence.json`: +capability boundary remains frozen in `evidence/codex/0.145.0/runtime-evidence.json`: Codex owns effective backend selection and orchestration; Switchloom owns the repository-local role declarations and requested-versus-effective contract. @@ -149,7 +149,7 @@ unavailable/unverified wording. ## Protection And Limits -- `/Users/kregenrek/projects/planr` remained at +- `${HOME}/projects/planr` remained at `bbc877d40191b2cbb289ed26df5e6fee25e4326d` with `-uall` status SHA-256 `d6c56495c7e2a78aed2e641b0e928bc8a579bf31335db36456a9f05726827927`. - The global Codex config remained SHA-256 diff --git a/scripts/check-migration-manifest.sh b/scripts/check-migration-manifest.sh index 336b9cf..0bf50bc 100644 --- a/scripts/check-migration-manifest.sh +++ b/scripts/check-migration-manifest.sh @@ -1,8 +1,8 @@ #!/bin/sh set -eu -manifest="${1:-docs/migration-manifest.tsv}" -planr_root="${PLANR_ROOT:-/Users/kregenrek/projects/planr}" +manifest="${1:-retained-evidence/migrations/v0.3.0/migration-manifest.tsv}" +planr_root="${PLANR_ROOT:-${HOME}/projects/planr}" baseline_tag="${PLANR_BASELINE_TAG:-v1.5.0}" baseline_commit="${PLANR_BASELINE_COMMIT:-7a01ad54cb41fd755f368a79339a96a997f693d0}" baseline_source_hash="${PLANR_BASELINE_SOURCE_HASH:-d32166f38448fcc2cb5632b24214625b45ae6326}" diff --git a/scripts/hooks/block-sensitive-diff-content.mjs b/scripts/hooks/block-sensitive-diff-content.mjs new file mode 100644 index 0000000..f72f010 --- /dev/null +++ b/scripts/hooks/block-sensitive-diff-content.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +const projectRoot = resolve(dirname(scriptPath), "..", ".."); +const maximumTextFileBytes = 2 * 1024 * 1024; + +const safeHomeNames = new Set(["", "example", "runner", "test", "tester", "user"]); +const safeEmailDomains = new Set(["example.com", "example.org", "users.noreply.github.com"]); + +function git(args) { + return execFileSync("git", args, { + cwd: projectRoot, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + }); +} + +function isSafeHomePath(value) { + const match = value.match(/^\/(?:Users|home)\/([^/]+)/); + return match ? safeHomeNames.has(match[1]) : true; +} + +function isSafeEmail(value) { + const domain = value.split("@").at(-1)?.toLowerCase(); + return domain ? safeEmailDomains.has(domain) : false; +} + +export function scanLine(content, file, line) { + const findings = []; + const homePattern = /\/(?:Users|home)\/[A-Za-z0-9._<>-]+/g; + const emailPattern = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; + const callbackPattern = /https?:\/\/[^\s"'<>]*(?:deviceauth|oauth[^\s"'<>]*callback)[^\s"'<>]*(?:code|state|token)=[A-Za-z0-9._~-]{8,}/gi; + + for (const match of content.matchAll(homePattern)) { + if (!isSafeHomePath(match[0])) findings.push({ category: "personal-home-path", file, line }); + } + for (const match of content.matchAll(emailPattern)) { + if (!isSafeEmail(match[0])) findings.push({ category: "personal-email", file, line }); + } + if (callbackPattern.test(content)) { + findings.push({ category: "credential-bearing-auth-url", file, line }); + } + + return findings; +} + +export function scanUnifiedDiff(diff) { + const findings = []; + let file = null; + let newLine = 0; + + for (const rawLine of diff.split("\n")) { + if (rawLine.startsWith("+++ b/")) { + file = rawLine.slice(6); + continue; + } + const hunk = rawLine.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (hunk) { + newLine = Number.parseInt(hunk[1], 10); + continue; + } + if (!file || rawLine.startsWith("--- ") || rawLine.startsWith("diff --git ")) continue; + if (rawLine.startsWith("+")) { + findings.push(...scanLine(rawLine.slice(1), file, newLine)); + newLine += 1; + } else if (!rawLine.startsWith("-")) { + newLine += 1; + } + } + + return findings; +} + +function scanUntrackedFiles() { + const output = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { + cwd: projectRoot, + encoding: "buffer", + }); + const findings = []; + for (const file of output.toString("utf8").split("\0").filter(Boolean)) { + const absolutePath = resolve(projectRoot, file); + if (!existsSync(absolutePath) || statSync(absolutePath).size > maximumTextFileBytes) continue; + const content = readFileSync(absolutePath); + if (content.includes(0)) continue; + content + .toString("utf8") + .split("\n") + .forEach((lineContent, index) => findings.push(...scanLine(lineContent, file, index + 1))); + } + return findings; +} + +function uniqueFindings(findings) { + return [...new Map(findings.map((finding) => [ + `${finding.category}:${finding.file}:${finding.line}`, + finding, + ])).values()]; +} + +function collectFindings(args) { + const baseIndex = args.indexOf("--base"); + if (baseIndex !== -1) { + const base = args[baseIndex + 1]; + if (!base) throw new Error("--base requires a Git revision"); + return scanUnifiedDiff(git(["diff", "--no-ext-diff", "--no-color", "--unified=0", base, "--"])); + } + if (args.includes("--worktree")) { + return uniqueFindings([ + ...scanUnifiedDiff(git(["diff", "--no-ext-diff", "--no-color", "--unified=0", "--"])), + ...scanUnifiedDiff(git(["diff", "--cached", "--no-ext-diff", "--no-color", "--unified=0", "--"])), + ...scanUntrackedFiles(), + ]); + } + return scanUnifiedDiff(git(["diff", "--cached", "--no-ext-diff", "--no-color", "--unified=0", "--"])); +} + +function main() { + const findings = uniqueFindings(collectFindings(process.argv.slice(2))); + if (findings.length === 0) { + console.log("No sensitive personal content detected in added lines"); + return; + } + + console.error("Sensitive personal content detected in added lines:"); + for (const finding of findings) { + console.error(`- ${finding.file}:${finding.line} (${finding.category})`); + } + console.error("Replace personal values with placeholders or keep the generated file outside Git."); + process.exitCode = 1; +} + +if (resolve(process.argv[1] ?? "") === scriptPath) main(); diff --git a/scripts/hooks/block-sensitive-diff-content.test.mjs b/scripts/hooks/block-sensitive-diff-content.test.mjs new file mode 100644 index 0000000..ae35a82 --- /dev/null +++ b/scripts/hooks/block-sensitive-diff-content.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { scanLine, scanUnifiedDiff } from "./block-sensitive-diff-content.mjs"; + +test("reports private values by category and location without returning the value", () => { + const privateHome = ["", "Users", "private-name", "project"].join("/"); + const privateEmail = ["person", "private.invalid"].join("@"); + const findings = scanLine( + `owner=${privateHome} contact=${privateEmail}`, + "receipt.json", + 7, + ); + + assert.deepEqual(findings, [ + { category: "personal-home-path", file: "receipt.json", line: 7 }, + { category: "personal-email", file: "receipt.json", line: 7 }, + ]); + assert.equal(JSON.stringify(findings).includes("private-name"), false); + assert.equal(JSON.stringify(findings).includes("person@"), false); +}); + +test("allows generic paths and non-personal bot or example email addresses", () => { + assert.deepEqual(scanLine("/Users//repo", "docs.md", 1), []); + assert.deepEqual(scanLine("/home/runner/work", "workflow.yml", 2), []); + assert.deepEqual(scanLine("release-bot@users.noreply.github.com", "workflow.yml", 3), []); + assert.deepEqual(scanLine("user@example.com", "fixture.txt", 4), []); +}); + +test("detects credential-bearing authentication callback URLs", () => { + const callback = [ + "https://auth.example.invalid/deviceauth/callback", + "code=abcdefgh1234", + ].join("?"); + assert.deepEqual( + scanLine(callback, "session.log", 9), + [{ category: "credential-bearing-auth-url", file: "session.log", line: 9 }], + ); +}); + +test("maps added diff lines to their destination line numbers", () => { + const privateHome = ["", "Users", "private-name", "work"].join("/"); + const findings = scanUnifiedDiff(`diff --git a/new.md b/new.md +--- a/new.md ++++ b/new.md +@@ -2,0 +3,2 @@ ++safe ++path=${privateHome} +`); + + assert.deepEqual(findings, [ + { category: "personal-home-path", file: "new.md", line: 4 }, + ]); +}); diff --git a/scripts/secleak-check.sh b/scripts/secleak-check.sh index 8628c85..f3c7029 100755 --- a/scripts/secleak-check.sh +++ b/scripts/secleak-check.sh @@ -5,6 +5,7 @@ repo_root="$(git rev-parse --show-toplevel)" cd "$repo_root" node scripts/hooks/block-forbidden-staged-files.mjs --tracked +node scripts/hooks/block-sensitive-diff-content.mjs --worktree betterleaks_args=(git --no-banner --redact=100) if [[ -f .betterleaks.toml ]]; then @@ -18,5 +19,8 @@ trivy fs \ --skip-dirs node_modules \ --skip-dirs target \ --skip-dirs dist \ + --skip-dirs .git \ + --skip-dirs .planr \ + --skip-dirs reports \ --exit-code 1 \ . diff --git a/scripts/verify-cloudflare-website.mjs b/scripts/verify-cloudflare-website.mjs index 82868ac..a093879 100644 --- a/scripts/verify-cloudflare-website.mjs +++ b/scripts/verify-cloudflare-website.mjs @@ -65,15 +65,22 @@ const scriptMatches = [...html.matchAll(/(?:src|component-url)="([^"]*Generator[ if (scriptMatches.length === 0) throw new Error("deployed homepage missing generator client script"); const generatorScript = new TextDecoder().decode(await fetchOk(scriptMatches.at(-1)[1])); for (const required of [ - "Copy npx recipe command", - "Download .switchloom/config.toml", + "Get your agent team", + "Check your Codex version", + "Check Cursor Agent", "Standalone or With Planr", "switchloom preview --recipe", "switchloom rollback --repository .", ]) { if (!generatorScript.includes(required)) throw new Error(`deployed generator missing ${required}`); } -for (const removed of ["jszip", "Download setup (.zip)", "switchloom.config.json"]) { +for (const removed of [ + "jszip", + "Download setup (.zip)", + "switchloom.config.json", + "Copy npx recipe command", + "Download .switchloom/config.toml", +]) { if (generatorScript.toLowerCase().includes(removed.toLowerCase())) { throw new Error(`deployed generator still contains removed browser artifact path: ${removed}`); } diff --git a/scripts/verify-generator-live.mjs b/scripts/verify-generator-live.mjs new file mode 100644 index 0000000..e26bc8e --- /dev/null +++ b/scripts/verify-generator-live.mjs @@ -0,0 +1,440 @@ +#!/usr/bin/env node +import { createServer } from "node:net"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn } from "node:child_process"; + +const args = new Map(); +for (let index = 2; index < process.argv.length; index += 2) { + args.set(process.argv[index], process.argv[index + 1]); +} + +const baseUrl = args.get("--base-url") ?? "http://127.0.0.1:4173"; +const outDir = args.get("--out-dir") ?? "retained-evidence/live-web/generator"; +const chromePath = args.get("--chrome") ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function freePort() { + return await new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => resolve(address.port)); + }); + }); +} + +async function sleep(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchJson(url, options) { + const response = await fetch(url, options); + if (!response.ok) throw new Error(`${options?.method ?? "GET"} ${url} failed with ${response.status}`); + return await response.json(); +} + +async function waitForJson(url, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + let lastError; + while (Date.now() < deadline) { + try { + return await fetchJson(url); + } catch (error) { + lastError = error; + await sleep(100); + } + } + throw lastError ?? new Error(`timed out waiting for ${url}`); +} + +function createCdpClient(webSocketUrl) { + const socket = new WebSocket(webSocketUrl); + let nextId = 1; + const pending = new Map(); + const eventWaiters = new Map(); + + socket.addEventListener("message", (message) => { + const packet = JSON.parse(message.data); + if (packet.id && pending.has(packet.id)) { + const { resolve, reject } = pending.get(packet.id); + pending.delete(packet.id); + if (packet.error) reject(new Error(packet.error.message)); + else resolve(packet.result); + return; + } + const waiters = eventWaiters.get(packet.method); + if (waiters?.length) { + waiters.splice(0).forEach((resolve) => resolve(packet.params)); + } + }); + + const opened = new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); + + return { + async send(method, params = {}) { + await opened; + const id = nextId; + nextId += 1; + const done = new Promise((resolve, reject) => pending.set(id, { resolve, reject })); + socket.send(JSON.stringify({ id, method, params })); + return await done; + }, + async waitFor(method) { + await opened; + return await new Promise((resolve) => { + const waiters = eventWaiters.get(method) ?? []; + waiters.push(resolve); + eventWaiters.set(method, waiters); + }); + }, + async close() { + await opened; + socket.close(); + }, + }; +} + +async function evaluate(client, expression) { + const result = await client.send("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + }); + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.exception?.description ?? "Runtime.evaluate failed"); + } + return result.result.value; +} + +const helpers = String.raw` +(() => { + const text = (node = document.body) => (node?.innerText || node?.textContent || "").replace(/\s+/g, " ").trim(); + const visible = (element) => !!element && element.offsetParent !== null; + const byLabel = (label) => [...document.querySelectorAll("button, input, [aria-label]")] + .find((element) => element.getAttribute("aria-label") === label); + const byText = (wanted) => [...document.querySelectorAll("button, [role=button]")] + .find((element) => visible(element) && text(element) === wanted); + const clickLabel = (label) => { + const element = byLabel(label); + if (!element) throw new Error("missing labelled control: " + label); + element.click(); + }; + const clickText = (wanted) => { + const element = byText(wanted); + if (!element) throw new Error("missing text control: " + wanted); + element.click(); + }; + const focusLabel = (label) => { + const element = byLabel(label); + if (!element) throw new Error("missing focus target: " + label); + element.focus(); + return document.activeElement === element; + }; + const spec = () => { + const tab = byText("Spec"); + if (tab) tab.click(); + return text(document.querySelector("pre")); + }; + const has = (value) => text().includes(value); + const not = (value) => !text().includes(value); + return { text: text(), has, not, clickLabel, clickText, focusLabel, activeLabel: document.activeElement?.getAttribute("aria-label") || "", spec }; +})() +`; + +async function pageState(client) { + return await evaluate(client, "document.body.innerText"); +} + +async function waitFor(client, predicateSource, message, timeoutMs = 8000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await evaluate(client, `Boolean((${predicateSource})())`)) return; + await sleep(100); + } + throw new Error(message); +} + +async function clickLabel(client, label) { + await evaluate(client, `${helpers}.clickLabel(${JSON.stringify(label)})`); + await sleep(150); +} + +async function clickText(client, text) { + await evaluate(client, `${helpers}.clickText(${JSON.stringify(text)})`); + await sleep(150); +} + +async function focusLabel(client, label) { + const focused = await evaluate(client, `${helpers}.focusLabel(${JSON.stringify(label)})`); + assert(focused, `failed to focus ${label}`); +} + +async function scrollTeamHierarchyIntoView(client) { + await evaluate(client, String.raw` +(() => { + const legends = [...document.querySelectorAll("legend")]; + const legend = legends.find((element) => element.textContent?.includes("3. Tune each role")); + if (!legend) throw new Error("team hierarchy legend missing"); + legend.scrollIntoView({ block: "start", inline: "nearest" }); +})() +`); + await sleep(150); +} + +async function assertNoHorizontalOverflow(client, label) { + const overflow = JSON.parse(await evaluate(client, String.raw` +JSON.stringify((() => ({ + viewportWidth: window.innerWidth, + documentWidth: document.documentElement.scrollWidth, + bodyWidth: document.body.scrollWidth, +}))()) +`)); + assert( + overflow.documentWidth <= overflow.viewportWidth + 1 && overflow.bodyWidth <= overflow.viewportWidth + 1, + `${label}: horizontal overflow detected: viewport ${overflow.viewportWidth}, document ${overflow.documentWidth}, body ${overflow.bodyWidth}`, + ); +} + +async function assertHierarchyGeometry(client, label) { + const geometry = JSON.parse(await evaluate(client, String.raw` +JSON.stringify((() => { + const rect = (element) => { + const box = element.getBoundingClientRect(); + return { top: box.top, right: box.right, bottom: box.bottom, left: box.left, width: box.width, height: box.height }; + }; + const intersects = (a, b) => !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top); + const wrappers = [...document.querySelectorAll("div")] + .filter((element) => element.classList.contains("relative") && element.classList.contains("pl-6")); + const rows = wrappers.map((wrapper, index) => { + const card = wrapper.querySelector('[data-slot="card"]'); + const connector = wrapper.querySelector('span[aria-hidden="true"]'); + const header = card?.querySelector('[data-slot="card-header"]'); + const titleColumn = header?.firstElementChild; + const action = header?.querySelector('[data-slot="card-action"]'); + const wrapperRect = rect(wrapper); + const cardRect = card ? rect(card) : null; + const connectorRect = connector ? rect(connector) : null; + const headerRect = header ? rect(header) : null; + const titleRect = titleColumn ? rect(titleColumn) : null; + const actionRect = action ? rect(action) : null; + const before = getComputedStyle(wrapper, "::before"); + return { + index, + wrapperRect, + cardRect, + connectorRect, + headerRect, + titleRect, + actionRect, + beforeWidth: Number.parseFloat(before.width), + beforeHeight: Number.parseFloat(before.height), + beforeDisplay: before.display, + actionOverlapsText: Boolean(titleRect && actionRect && intersects(titleRect, actionRect)), + }; + }); + return { + viewportWidth: window.innerWidth, + rows, + }; +})()) +`)); + + assert(geometry.rows.length === 3, `${label}: expected 3 connected child cards, found ${geometry.rows.length}`); + for (const row of geometry.rows) { + assert(row.cardRect, `${label}: child ${row.index + 1} card missing`); + assert(row.connectorRect, `${label}: child ${row.index + 1} connector missing`); + assert(row.beforeDisplay !== "none" && row.beforeWidth >= 1 && row.beforeHeight >= 20, `${label}: child ${row.index + 1} vertical connector is not visible`); + assert(row.connectorRect.width >= 12 && row.connectorRect.height >= 1, `${label}: child ${row.index + 1} horizontal connector is not visible`); + assert(row.connectorRect.left >= row.wrapperRect.left, `${label}: child ${row.index + 1} connector escapes wrapper`); + assert(row.connectorRect.right <= row.cardRect.left + 1, `${label}: child ${row.index + 1} connector is not aligned to card edge`); + assert(row.connectorRect.top >= row.cardRect.top && row.connectorRect.top <= row.cardRect.bottom, `${label}: child ${row.index + 1} connector misses card height`); + assert(row.cardRect.right <= geometry.viewportWidth + 1, `${label}: child ${row.index + 1} card overflows viewport`); + assert(row.actionRect, `${label}: child ${row.index + 1} action controls missing`); + assert(row.headerRect && row.actionRect.right <= row.headerRect.right + 1, `${label}: child ${row.index + 1} action controls overflow header`); + assert(!row.actionOverlapsText, `${label}: child ${row.index + 1} action controls overlap title text`); + } +} + +async function assertResetDialogGeometry(client, label) { + const geometry = JSON.parse(await evaluate(client, String.raw` +JSON.stringify((() => { + const rect = (element) => { + const box = element.getBoundingClientRect(); + return { top: box.top, right: box.right, bottom: box.bottom, left: box.left, width: box.width, height: box.height }; + }; + const dialog = document.querySelector('[data-slot="dialog-content"]'); + const footer = dialog?.querySelector('[data-slot="dialog-footer"]'); + const buttons = footer ? [...footer.querySelectorAll("button")].map(rect) : []; + return { + viewportWidth: window.innerWidth, + dialog: dialog ? rect(dialog) : null, + buttons, + text: dialog?.innerText || "", + }; +})()) +`)); + assert(geometry.dialog, `${label}: reset dialog is not visible`); + assert(geometry.text.includes("Reset roles?"), `${label}: reset dialog title missing`); + assert(geometry.dialog.left >= 0 && geometry.dialog.right <= geometry.viewportWidth + 1, `${label}: reset dialog overflows viewport`); + for (const [index, button] of geometry.buttons.entries()) { + assert(button.left >= geometry.dialog.left && button.right <= geometry.dialog.right + 1, `${label}: dialog button ${index + 1} overflows dialog`); + } +} + +async function capture(client, fileName) { + const screenshot = await client.send("Page.captureScreenshot", { format: "png", fromSurface: true }); + const path = join(outDir, fileName); + await writeFile(path, Buffer.from(screenshot.data, "base64")); + return path; +} + +async function setViewport(client, width, height, scale = 1) { + await client.send("Emulation.setDeviceMetricsOverride", { + width, + height, + deviceScaleFactor: scale, + mobile: width < 600, + }); +} + +async function main() { + await mkdir(outDir, { recursive: true }); + const port = await freePort(); + const profile = join(tmpdir(), `switchloom-live-web-${process.pid}`); + const chrome = spawn(chromePath, [ + "--headless=new", + "--disable-gpu", + "--no-first-run", + "--no-default-browser-check", + `--remote-debugging-port=${port}`, + `--user-data-dir=${profile}`, + "about:blank", + ], { stdio: ["ignore", "ignore", "pipe"] }); + + let stderr = ""; + chrome.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + try { + await waitForJson(`http://127.0.0.1:${port}/json/version`); + const target = await fetchJson(`http://127.0.0.1:${port}/json/new?${encodeURIComponent(baseUrl)}`, { method: "PUT" }); + const client = createCdpClient(target.webSocketDebuggerUrl); + await client.send("Runtime.enable"); + await client.send("Page.enable"); + + await setViewport(client, 1440, 1000); + await client.send("Page.navigate", { url: baseUrl }); + await client.waitFor("Page.loadEventFired"); + await waitFor(client, "() => document.body.innerText.includes('Build your coding-agent team.')", "generator did not render"); + + let state = await pageState(client); + assert(state.includes("Host managed"), "desktop: host-managed parent copy missing"); + assert(state.includes("3 generated child roles"), "desktop: generated child count missing"); + assert(state.includes("Set Codex reasoning to Medium."), "desktop: balanced parent effort copy missing"); + assert(!state.includes("Not included"), "desktop: obsolete parent copy is still visible"); + const desktopShot = await capture(client, "desktop-generator.png"); + await scrollTeamHierarchyIntoView(client); + await assertNoHorizontalOverflow(client, "desktop hierarchy"); + await assertHierarchyGeometry(client, "desktop hierarchy"); + const desktopHierarchyShot = await capture(client, "desktop-generator-hierarchy.png"); + + await focusLabel(client, "Edit Implementer"); + await client.send("Input.dispatchKeyEvent", { type: "rawKeyDown", key: " ", code: "Space", windowsVirtualKeyCode: 32 }); + await client.send("Input.dispatchKeyEvent", { type: "keyUp", key: " ", code: "Space", windowsVirtualKeyCode: 32 }); + await waitFor(client, "() => document.body.innerText.includes('Model') && document.querySelector('[aria-label=\"Implementer model\"]')", "keyboard edit did not expose Implementer controls"); + + await clickLabel(client, "Remove Reviewer"); + state = await pageState(client); + assert(state.includes("Custom"), "removal: custom preset badge missing"); + assert(state.includes("2 generated child roles"), "removal: child count did not update"); + assert(!state.includes("Reviewer Finds defects independently."), "removal: reviewer card still visible"); + + await clickLabel(client, "Reset roles"); + await waitFor(client, "() => document.body.innerText.includes('Reset roles?')", "reset dialog did not open"); + await assertResetDialogGeometry(client, "desktop reset dialog"); + const desktopResetShot = await capture(client, "desktop-reset-confirmation.png"); + await clickText(client, "Cancel"); + state = await pageState(client); + assert(state.includes("Custom"), "cancel reset: custom state was lost"); + assert(state.includes("2 generated child roles"), "cancel reset: removed role came back"); + + await clickLabel(client, "Reset roles"); + await clickLabel(client, "Confirm reset roles"); + await waitFor(client, "() => document.body.innerText.includes('3 generated child roles') && !document.body.innerText.includes('Custom')", "confirm reset did not restore preset"); + + await clickText(client, "Pi"); + await waitFor(client, "() => document.body.innerText.includes('Pi team')", "host switch to Pi failed"); + state = await pageState(client); + assert(state.includes("4 focused roles"), "host switch: Pi should render all focused roles"); + assert(!state.includes("host-managed parent"), "host switch: Pi should not show native parent summary"); + await clickText(client, "Spec"); + await waitFor(client, "() => Boolean(document.querySelector('pre')?.textContent)", "spec tab did not render JSON"); + const piSpec = await evaluate(client, "document.querySelector('pre')?.textContent || ''"); + assert(piSpec.includes('"orchestrator"'), "host switch: Pi spec should include orchestrator"); + + await clickText(client, "Codex"); + await clickText(client, "Light"); + await waitFor(client, "() => document.body.innerText.includes('Set Codex reasoning to Low.')", "light preset effort copy missing"); + await clickText(client, "High"); + await waitFor(client, "() => document.body.innerText.includes('Set Codex reasoning to Medium.')", "high preset effort copy missing"); + + await setViewport(client, 390, 844, 2); + await client.send("Page.reload", { ignoreCache: true }); + await client.waitFor("Page.loadEventFired"); + await waitFor(client, "() => document.body.innerText.includes('Build your coding-agent team.') && document.body.innerText.includes('Host managed')", "mobile: generator did not render"); + state = await pageState(client); + assert(state.includes("3 generated child roles"), "mobile: generated child count missing"); + assert(state.includes("Reset roles"), "mobile: reset control missing"); + await scrollTeamHierarchyIntoView(client); + await assertNoHorizontalOverflow(client, "mobile hierarchy"); + await assertHierarchyGeometry(client, "mobile hierarchy"); + const mobileShot = await capture(client, "mobile-generator.png"); + await clickLabel(client, "Remove Reviewer"); + await clickLabel(client, "Reset roles"); + await waitFor(client, "() => document.body.innerText.includes('Reset roles?')", "mobile: reset dialog did not open"); + await assertResetDialogGeometry(client, "mobile reset dialog"); + const mobileResetShot = await capture(client, "mobile-reset-confirmation.png"); + + await client.close(); + console.log(JSON.stringify({ + ok: true, + baseUrl, + screenshots: [desktopShot, desktopHierarchyShot, desktopResetShot, mobileShot, mobileResetShot], + checks: [ + "desktop host-managed parent and generated child count", + "desktop hierarchy connectors and action-control geometry", + "desktop reset confirmation capture", + "keyboard activation of Edit Implementer", + "remove Reviewer", + "cancel reset preserves custom edits", + "confirm reset restores selected preset", + "host switch to Pi includes orchestrator in spec", + "preset switching updates Codex parent effort copy", + "narrow mobile render at 390x844", + "narrow mobile hierarchy connectors, action-control geometry, and horizontal overflow", + "narrow mobile reset confirmation capture", + ], + }, null, 2)); + } finally { + chrome.kill("SIGTERM"); + await rm(profile, { recursive: true, force: true }); + if (chrome.exitCode && chrome.exitCode !== 0) { + process.stderr.write(stderr); + } + } +} + +main().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/src/evidence.rs b/src/evidence.rs index 9c78a8b..9b4eda0 100644 --- a/src/evidence.rs +++ b/src/evidence.rs @@ -6,10 +6,14 @@ use serde_json::{Value, json}; use std::collections::BTreeSet; use std::fs; +pub(crate) const CODEX_V2_RUNTIME_EVIDENCE_PATH: &str = + "evidence/codex/0.145.0/runtime-evidence.json"; +const CODEX_0145_EXACT_VERSION_CAPTURE_PATH: &str = + "evidence/codex/0.145.0/exact-version-capture.txt"; pub(crate) const CODEX_V2_RUNTIME_EVIDENCE_JSON: &str = - include_str!("../docs/codex-v2-runtime-evidence.json"); + include_str!("../evidence/codex/0.145.0/runtime-evidence.json"); const CODEX_0145_EXACT_VERSION_CAPTURE: &str = - include_str!("../docs/codex-0.145-exact-version-capture.txt"); + include_str!("../evidence/codex/0.145.0/exact-version-capture.txt"); pub(crate) fn codex_v2_runtime_evidence() -> Result { let evidence: CodexV2RuntimeEvidence = serde_json::from_str(CODEX_V2_RUNTIME_EVIDENCE_JSON) @@ -216,7 +220,7 @@ pub(crate) fn validate_codex_claim_source_identity( && record.source.contains("--version") && source_path.is_some_and(|path| { path == format!("local-shell:{}", record.source) - || path == "docs/codex-0.145-exact-version-capture.txt" + || path == CODEX_0145_EXACT_VERSION_CAPTURE_PATH }) && source_url.is_none() } @@ -267,9 +271,7 @@ pub(crate) fn validate_codex_retained_source_contains_raw_output( return Ok(()); }; let source_content = match source_path { - "docs/codex-0.145-exact-version-capture.txt" => { - CODEX_0145_EXACT_VERSION_CAPTURE.to_string() - } + CODEX_0145_EXACT_VERSION_CAPTURE_PATH => CODEX_0145_EXACT_VERSION_CAPTURE.to_string(), path if path.starts_with("fixtures/codex-v2-runtime-evidence/") => fs::read_to_string(path) .with_context(|| { format!("failed to read Codex V2 evidence fixture source `{source_path}`") @@ -723,7 +725,7 @@ pub(crate) fn validate_runtime_behavior(capability: &HostCapabilityV1) -> Result pub(crate) fn codex_v2_runtime_evidence_reference() -> String { format!( - "docs/codex-v2-runtime-evidence.json#sha256:{}", + "{CODEX_V2_RUNTIME_EVIDENCE_PATH}#sha256:{}", sha256(CODEX_V2_RUNTIME_EVIDENCE_JSON.as_bytes()) ) } diff --git a/src/hosts.rs b/src/hosts.rs index df67cb9..a6b4239 100644 --- a/src/hosts.rs +++ b/src/hosts.rs @@ -190,18 +190,18 @@ pub(crate) fn codex_repository_diagnostics(repository: &Path) -> Result {} - Some(true) => diagnostics.push(probe_diagnostic( + Some(true) => {} + Some(false) => diagnostics.push(probe_diagnostic( "codex_v2_metadata_conflict", "error", - "Repository-local features.multi_agent_v2.hide_spawn_agent_metadata is true; certified Switchloom evidence expects visible spawn metadata.", - "Set hide_spawn_agent_metadata = false for certified evidence capture, then reload or restart Codex.", + "Repository-local features.multi_agent_v2.hide_spawn_agent_metadata is false; Codex 0.145 rejects the resulting reserved collaboration.spawn_agent schema.", + "Set hide_spawn_agent_metadata = true for the backend-compatible Codex V2 spawn schema, then reload or restart Codex.", )), None => diagnostics.push(probe_diagnostic( "codex_v2_metadata_missing", "warning", "Repository-local features.multi_agent_v2.hide_spawn_agent_metadata is missing.", - "Run switchloom update/apply for a Codex 0.145 bundle to add hide_spawn_agent_metadata = false.", + "Run switchloom update/apply for a Codex 0.145 bundle to add hide_spawn_agent_metadata = true.", )), } @@ -687,17 +687,17 @@ pub(crate) fn setup_model_catalog(host: &str) -> Vec { "codex" => vec![ SetupModelOption { id: "gpt-5.6-sol", - efforts: &["low", "medium", "high", "xhigh", "ultra"], + efforts: &["low", "medium", "high", "xhigh", "max", "ultra"], tier: "premium", }, SetupModelOption { id: "gpt-5.6-terra", - efforts: &["low", "medium", "high", "xhigh", "ultra"], + efforts: &["low", "medium", "high", "xhigh", "max", "ultra"], tier: "standard", }, SetupModelOption { id: "gpt-5.6-luna", - efforts: &["low", "medium", "high", "xhigh"], + efforts: &["low", "medium", "high", "xhigh", "max"], tier: "standard", }, ], @@ -1187,7 +1187,7 @@ pub(crate) fn render_codex_agent_registration_artifact( features: CodexFeaturesConfig { multi_agent_v2: CodexMultiAgentV2Config { enabled: true, - hide_spawn_agent_metadata: false, + hide_spawn_agent_metadata: true, }, }, })?; diff --git a/src/tests/evidence.rs b/src/tests/evidence.rs index f5d94e8..a60588c 100644 --- a/src/tests/evidence.rs +++ b/src/tests/evidence.rs @@ -1,5 +1,25 @@ use crate::*; +#[test] +fn codex_runtime_evidence_uses_only_the_versioned_evidence_owner() { + let evidence = codex_v2_runtime_evidence().unwrap(); + let installed_version = evidence + .claim_provenance + .get("installed_version") + .unwrap() + .first() + .unwrap(); + + assert_eq!( + installed_version.source_path.as_deref(), + Some("evidence/codex/0.145.0/exact-version-capture.txt") + ); + assert!( + codex_v2_runtime_evidence_reference() + .starts_with("evidence/codex/0.145.0/runtime-evidence.json#sha256:") + ); +} + #[test] fn codex_runtime_evidence_fixtures_fail_for_named_provenance_reasons() { for (fixture, expected) in [ diff --git a/src/tests/hosts.rs b/src/tests/hosts.rs index 31064e2..cc766be 100644 --- a/src/tests/hosts.rs +++ b/src/tests/hosts.rs @@ -109,6 +109,14 @@ hide_spawn_agent_metadata = false diagnostic.code == "codex_v2_activation_conflict" && diagnostic.repair.contains("enabled = true") })); + assert!(exact_report.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "codex_v2_metadata_conflict" + && diagnostic.message.contains("Codex 0.145") + && diagnostic.message.contains("collaboration.spawn_agent") + && diagnostic + .repair + .contains("hide_spawn_agent_metadata = true") + })); assert!(exact_report.diagnostics.iter().any(|diagnostic| { diagnostic.code == "codex_trust_reload_required" && diagnostic.repair.contains("reload or restart") @@ -139,7 +147,7 @@ config_file = "./agents/model-routing-sol-high.toml" [features.multi_agent_v2] enabled = true -hide_spawn_agent_metadata = false +hide_spawn_agent_metadata = true "#, ) .unwrap(); @@ -152,6 +160,12 @@ hide_spawn_agent_metadata = false .iter() .any(|diagnostic| diagnostic.severity == "error") ); + assert!( + !ready_report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "codex_v2_metadata_conflict") + ); } #[test] diff --git a/src/tests/routing.rs b/src/tests/routing.rs index fa5f119..d569b8e 100644 --- a/src/tests/routing.rs +++ b/src/tests/routing.rs @@ -316,6 +316,99 @@ fn fully_custom_setup_compiles_as_unverified_host_native_bundle() { validate_bundle(&bundle).unwrap(); } +#[test] +fn child_only_setup_does_not_reintroduce_parent_profiles_routes_or_artifacts() { + let spec = SetupSpecV1 { + schema_version: 1, + host: "codex-openai".to_string(), + integration: Integration::Planr, + usage_policy: "balanced".to_string(), + selected_roles: BTreeMap::from([ + ( + "implementer".to_string(), + SetupRoleSelection { + model: "gpt-5.6-terra".to_string(), + effort: Some("high".to_string()), + spawn: Some(SetupSpawnPolicy { + agent_type: "switchloom_implementer".to_string(), + task_name: "implementer".to_string(), + fork_turns: ForkPolicy { + mode: "none".to_string(), + turns: None, + }, + }), + }, + ), + ( + "verifier".to_string(), + SetupRoleSelection { + model: "gpt-5.6-terra".to_string(), + effort: Some("medium".to_string()), + spawn: Some(SetupSpawnPolicy { + agent_type: "switchloom_verifier".to_string(), + task_name: "verifier".to_string(), + fork_turns: ForkPolicy { + mode: "none".to_string(), + turns: None, + }, + }), + }, + ), + ]), + routes: vec![ + SetupRouteMapping { + work_type: "code".to_string(), + role: "implementer".to_string(), + fallbacks: Vec::new(), + }, + SetupRouteMapping { + work_type: "review".to_string(), + role: "verifier".to_string(), + fallbacks: Vec::new(), + }, + SetupRouteMapping { + work_type: "verification".to_string(), + role: "verifier".to_string(), + fallbacks: Vec::new(), + }, + ], + route_default: None, + }; + + let bundle = compile_setup_spec(&spec).unwrap(); + validate_bundle(&bundle).unwrap(); + assert!(!bundle.profiles.contains_key("orchestrator")); + assert!(bundle.route_default.is_none()); + assert!( + bundle + .routes + .iter() + .all(|route| route.profile != "orchestrator") + ); + assert!( + bundle + .artifacts + .iter() + .all(|artifact| !artifact.path.contains("orchestrator") + && !artifact.content.contains("switchloom_orchestrator")) + ); + + let contract = bundle.adapter_contract.as_ref().unwrap(); + assert!( + !contract + .routing_intent + .semantic_roles + .contains(&"orchestrator".to_string()) + ); + assert!( + contract + .routing_intent + .role_requests + .iter() + .all(|request| request.semantic_role != "orchestrator") + ); +} + #[test] fn successful_custom_setups_validate_final_bundles_for_each_host_family() { for (host, role, model, effort) in [ diff --git a/tests/lifecycle_contract.rs b/tests/lifecycle_contract.rs index 9940354..1f4be01 100644 --- a/tests/lifecycle_contract.rs +++ b/tests/lifecycle_contract.rs @@ -72,7 +72,7 @@ fn assert_codex_v2_activation(content: &str) { ); assert_eq!( parsed["features"]["multi_agent_v2"]["hide_spawn_agent_metadata"].as_bool(), - Some(false) + Some(true) ); } @@ -84,7 +84,7 @@ fn assert_codex_v2_disabled(content: &str) { ); assert_eq!( parsed["features"]["multi_agent_v2"]["hide_spawn_agent_metadata"].as_bool(), - Some(false) + Some(true) ); } @@ -431,7 +431,7 @@ fn lifecycle_preserves_compatible_user_owned_codex_v2_activation_and_rejects_fal fs::create_dir_all(repository.join(".codex")).unwrap(); fs::write( repository.join(".codex/config.toml"), - "[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = false\n", + "[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = true\n", ) .unwrap(); @@ -474,7 +474,7 @@ fn lifecycle_preserves_compatible_user_owned_codex_v2_activation_and_rejects_fal fs::create_dir_all(conflict_repo.join(".codex")).unwrap(); fs::write( conflict_repo.join(".codex/config.toml"), - "[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = false\n", + "[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = true\n", ) .unwrap(); apply_bundle_file_with_bundle(&conflict_repo, &codex).unwrap(); @@ -524,7 +524,7 @@ fn lifecycle_preserves_compatible_user_owned_codex_v2_activation_and_rejects_fal fs::create_dir_all(cross_host_repo.join(".codex")).unwrap(); fs::write( cross_host_repo.join(".codex/config.toml"), - "[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = false\n", + "[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = true\n", ) .unwrap(); apply_bundle_file_with_bundle(&cross_host_repo, &codex).unwrap(); diff --git a/vitest.config.ts b/vitest.config.ts index 6d8b16e..d6cee72 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./website/src", import.meta.url)), + }, + }, test: { include: ["website/src/**/*.test.ts"], }, diff --git a/website/data/bundles/balanced-codex-openai.json b/website/data/bundles/balanced-codex-openai.json index 8334076..3a535b4 100644 --- a/website/data/bundles/balanced-codex-openai.json +++ b/website/data/bundles/balanced-codex-openai.json @@ -255,8 +255,8 @@ "path": ".codex/config.toml", "media_type": "application/toml", "mode": "replace", - "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_sol_medium]\nconfig_file = \"./agents/model-routing-sol-medium.toml\"\n\n[agents.model_routing_sol_ultra]\nconfig_file = \"./agents/model-routing-sol-ultra.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[agents.model_routing_terra_medium]\nconfig_file = \"./agents/model-routing-terra-medium.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = false\n", - "sha256": "5734ff3444010de65901ea3efda663f26e1c240718898b5d96736f9550a83d3e" + "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_sol_medium]\nconfig_file = \"./agents/model-routing-sol-medium.toml\"\n\n[agents.model_routing_sol_ultra]\nconfig_file = \"./agents/model-routing-sol-ultra.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[agents.model_routing_terra_medium]\nconfig_file = \"./agents/model-routing-terra-medium.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = true\n", + "sha256": "36150ae4d6e37a56c4911b5888fb1939446a9eab1c9cba173c79cc6e85f776eb" } ], "evidence": { @@ -357,7 +357,7 @@ "automatic_delegation_requires_ultra": true }, "source_references": [ - "docs/codex-v2-runtime-evidence.json#sha256:1ca8b20e2e8b2c57f741d905bbfed6971a54e0520b3e9a2e9e8e5eefc0aa123d" + "evidence/codex/0.145.0/runtime-evidence.json#sha256:2c3b2fe9cb99a94d7a7c32f54dc5afd9f51449e9a0d56b711a8321bc55d7bd31" ] }, "discovery_artifacts": [ diff --git a/website/data/bundles/balanced-mixed-host.json b/website/data/bundles/balanced-mixed-host.json index 2993428..f016f62 100644 --- a/website/data/bundles/balanced-mixed-host.json +++ b/website/data/bundles/balanced-mixed-host.json @@ -195,8 +195,8 @@ "path": ".codex/config.toml", "media_type": "application/toml", "mode": "replace", - "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = false\n", - "sha256": "62c632cf3eb279cd1f82c71a527559b2a40c75f2646307d5a1df3523dd12d016" + "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = true\n", + "sha256": "1068d38f406cf4ca7160557d8a16cfbe6fc8684e89245ffba823cb510b47e594" }, { "path": ".cursor/agents/model-routing-fable-driver.md", diff --git a/website/data/bundles/low-usage-codex-openai.json b/website/data/bundles/low-usage-codex-openai.json index 08bb56c..d7892e9 100644 --- a/website/data/bundles/low-usage-codex-openai.json +++ b/website/data/bundles/low-usage-codex-openai.json @@ -255,8 +255,8 @@ "path": ".codex/config.toml", "media_type": "application/toml", "mode": "replace", - "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_sol_medium]\nconfig_file = \"./agents/model-routing-sol-medium.toml\"\n\n[agents.model_routing_sol_ultra]\nconfig_file = \"./agents/model-routing-sol-ultra.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[agents.model_routing_terra_medium]\nconfig_file = \"./agents/model-routing-terra-medium.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = false\n", - "sha256": "5734ff3444010de65901ea3efda663f26e1c240718898b5d96736f9550a83d3e" + "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_sol_medium]\nconfig_file = \"./agents/model-routing-sol-medium.toml\"\n\n[agents.model_routing_sol_ultra]\nconfig_file = \"./agents/model-routing-sol-ultra.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[agents.model_routing_terra_medium]\nconfig_file = \"./agents/model-routing-terra-medium.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = true\n", + "sha256": "36150ae4d6e37a56c4911b5888fb1939446a9eab1c9cba173c79cc6e85f776eb" } ], "evidence": { @@ -357,7 +357,7 @@ "automatic_delegation_requires_ultra": true }, "source_references": [ - "docs/codex-v2-runtime-evidence.json#sha256:1ca8b20e2e8b2c57f741d905bbfed6971a54e0520b3e9a2e9e8e5eefc0aa123d" + "evidence/codex/0.145.0/runtime-evidence.json#sha256:2c3b2fe9cb99a94d7a7c32f54dc5afd9f51449e9a0d56b711a8321bc55d7bd31" ] }, "discovery_artifacts": [ diff --git a/website/data/bundles/low-usage-mixed-host.json b/website/data/bundles/low-usage-mixed-host.json index e3c8bb2..ccaa536 100644 --- a/website/data/bundles/low-usage-mixed-host.json +++ b/website/data/bundles/low-usage-mixed-host.json @@ -195,8 +195,8 @@ "path": ".codex/config.toml", "media_type": "application/toml", "mode": "replace", - "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = false\n", - "sha256": "62c632cf3eb279cd1f82c71a527559b2a40c75f2646307d5a1df3523dd12d016" + "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = true\n", + "sha256": "1068d38f406cf4ca7160557d8a16cfbe6fc8684e89245ffba823cb510b47e594" }, { "path": ".cursor/agents/model-routing-fable-driver.md", diff --git a/website/data/bundles/max-quality-codex-openai.json b/website/data/bundles/max-quality-codex-openai.json index 4ccc4cb..fc3d37a 100644 --- a/website/data/bundles/max-quality-codex-openai.json +++ b/website/data/bundles/max-quality-codex-openai.json @@ -255,8 +255,8 @@ "path": ".codex/config.toml", "media_type": "application/toml", "mode": "replace", - "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_sol_medium]\nconfig_file = \"./agents/model-routing-sol-medium.toml\"\n\n[agents.model_routing_sol_ultra]\nconfig_file = \"./agents/model-routing-sol-ultra.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[agents.model_routing_terra_medium]\nconfig_file = \"./agents/model-routing-terra-medium.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = false\n", - "sha256": "5734ff3444010de65901ea3efda663f26e1c240718898b5d96736f9550a83d3e" + "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_sol_medium]\nconfig_file = \"./agents/model-routing-sol-medium.toml\"\n\n[agents.model_routing_sol_ultra]\nconfig_file = \"./agents/model-routing-sol-ultra.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[agents.model_routing_terra_medium]\nconfig_file = \"./agents/model-routing-terra-medium.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = true\n", + "sha256": "36150ae4d6e37a56c4911b5888fb1939446a9eab1c9cba173c79cc6e85f776eb" } ], "evidence": { @@ -357,7 +357,7 @@ "automatic_delegation_requires_ultra": true }, "source_references": [ - "docs/codex-v2-runtime-evidence.json#sha256:1ca8b20e2e8b2c57f741d905bbfed6971a54e0520b3e9a2e9e8e5eefc0aa123d" + "evidence/codex/0.145.0/runtime-evidence.json#sha256:2c3b2fe9cb99a94d7a7c32f54dc5afd9f51449e9a0d56b711a8321bc55d7bd31" ] }, "discovery_artifacts": [ diff --git a/website/data/bundles/max-quality-mixed-host.json b/website/data/bundles/max-quality-mixed-host.json index 25eccd3..1f18943 100644 --- a/website/data/bundles/max-quality-mixed-host.json +++ b/website/data/bundles/max-quality-mixed-host.json @@ -195,8 +195,8 @@ "path": ".codex/config.toml", "media_type": "application/toml", "mode": "replace", - "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = false\n", - "sha256": "62c632cf3eb279cd1f82c71a527559b2a40c75f2646307d5a1df3523dd12d016" + "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = true\n", + "sha256": "1068d38f406cf4ca7160557d8a16cfbe6fc8684e89245ffba823cb510b47e594" }, { "path": ".cursor/agents/model-routing-fable-driver.md", diff --git a/website/data/bundles/read-only-audit-codex-openai.json b/website/data/bundles/read-only-audit-codex-openai.json index bb344f6..c04b340 100644 --- a/website/data/bundles/read-only-audit-codex-openai.json +++ b/website/data/bundles/read-only-audit-codex-openai.json @@ -250,8 +250,8 @@ "path": ".codex/config.toml", "media_type": "application/toml", "mode": "replace", - "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_sol_medium]\nconfig_file = \"./agents/model-routing-sol-medium.toml\"\n\n[agents.model_routing_sol_ultra]\nconfig_file = \"./agents/model-routing-sol-ultra.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[agents.model_routing_terra_medium]\nconfig_file = \"./agents/model-routing-terra-medium.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = false\n", - "sha256": "5734ff3444010de65901ea3efda663f26e1c240718898b5d96736f9550a83d3e" + "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_sol_medium]\nconfig_file = \"./agents/model-routing-sol-medium.toml\"\n\n[agents.model_routing_sol_ultra]\nconfig_file = \"./agents/model-routing-sol-ultra.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[agents.model_routing_terra_medium]\nconfig_file = \"./agents/model-routing-terra-medium.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = true\n", + "sha256": "36150ae4d6e37a56c4911b5888fb1939446a9eab1c9cba173c79cc6e85f776eb" } ], "evidence": { @@ -352,7 +352,7 @@ "automatic_delegation_requires_ultra": true }, "source_references": [ - "docs/codex-v2-runtime-evidence.json#sha256:1ca8b20e2e8b2c57f741d905bbfed6971a54e0520b3e9a2e9e8e5eefc0aa123d" + "evidence/codex/0.145.0/runtime-evidence.json#sha256:2c3b2fe9cb99a94d7a7c32f54dc5afd9f51449e9a0d56b711a8321bc55d7bd31" ] }, "discovery_artifacts": [ diff --git a/website/data/bundles/read-only-audit-mixed-host.json b/website/data/bundles/read-only-audit-mixed-host.json index e97be8a..eda880a 100644 --- a/website/data/bundles/read-only-audit-mixed-host.json +++ b/website/data/bundles/read-only-audit-mixed-host.json @@ -190,8 +190,8 @@ "path": ".codex/config.toml", "media_type": "application/toml", "mode": "replace", - "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = false\n", - "sha256": "62c632cf3eb279cd1f82c71a527559b2a40c75f2646307d5a1df3523dd12d016" + "content": "[agents.model_routing_luna_xhigh]\nconfig_file = \"./agents/model-routing-luna-xhigh.toml\"\n\n[agents.model_routing_sol_high]\nconfig_file = \"./agents/model-routing-sol-high.toml\"\n\n[agents.model_routing_terra_high]\nconfig_file = \"./agents/model-routing-terra-high.toml\"\n\n[agents.model_routing_terra_mechanical]\nconfig_file = \"./agents/model-routing-terra-mechanical.toml\"\n\n[features.multi_agent_v2]\nenabled = true\nhide_spawn_agent_metadata = true\n", + "sha256": "1068d38f406cf4ca7160557d8a16cfbe6fc8684e89245ffba823cb510b47e594" }, { "path": ".cursor/agents/model-routing-fable-driver.md", diff --git a/website/data/catalog.json b/website/data/catalog.json index 521af2d..77ec05a 100644 --- a/website/data/catalog.json +++ b/website/data/catalog.json @@ -261,11 +261,11 @@ }, { "path": ".codex/config.toml", - "sha256": "5734ff3444010de65901ea3efda663f26e1c240718898b5d96736f9550a83d3e" + "sha256": "36150ae4d6e37a56c4911b5888fb1939446a9eab1c9cba173c79cc6e85f776eb" } ], "id": "model-routing-official", - "manifestSha256": "da329ed10bbf9be4cc136173eb2702237662d6c13ad9e0f995e531535a0bc603", + "manifestSha256": "723133c65d9687f46ccfe3af8c1c846d7c49e466926e3aebddfbe4ab67a986e9", "signatureVerified": false, "signer": null, "trustedMaintainer": false, @@ -1317,7 +1317,7 @@ }, { "path": ".codex/config.toml", - "sha256": "62c632cf3eb279cd1f82c71a527559b2a40c75f2646307d5a1df3523dd12d016" + "sha256": "1068d38f406cf4ca7160557d8a16cfbe6fc8684e89245ffba823cb510b47e594" }, { "path": ".cursor/agents/model-routing-fable-driver.md", @@ -1325,7 +1325,7 @@ } ], "id": "model-routing-official", - "manifestSha256": "6516747d43ceebe030e139b68771b67066de7a0c95e0ed6e4ae7cbbe9dce714c", + "manifestSha256": "9f3e19c9e241aee058cc76ad812539cf6dd660a0b36f0dd01f23ec744985e2f2", "signatureVerified": false, "signer": null, "trustedMaintainer": false, @@ -1596,11 +1596,11 @@ }, { "path": ".codex/config.toml", - "sha256": "5734ff3444010de65901ea3efda663f26e1c240718898b5d96736f9550a83d3e" + "sha256": "36150ae4d6e37a56c4911b5888fb1939446a9eab1c9cba173c79cc6e85f776eb" } ], "id": "model-routing-official", - "manifestSha256": "efa00a0b8992660fa77d57ace0221f43893893089a8864e4105ae1b218b46904", + "manifestSha256": "a5b7d8e85e3bedbb057899c5bbdf56a694c0deb5478aa00e3dc80b52395582ea", "signatureVerified": false, "signer": null, "trustedMaintainer": false, @@ -2652,7 +2652,7 @@ }, { "path": ".codex/config.toml", - "sha256": "62c632cf3eb279cd1f82c71a527559b2a40c75f2646307d5a1df3523dd12d016" + "sha256": "1068d38f406cf4ca7160557d8a16cfbe6fc8684e89245ffba823cb510b47e594" }, { "path": ".cursor/agents/model-routing-fable-driver.md", @@ -2660,7 +2660,7 @@ } ], "id": "model-routing-official", - "manifestSha256": "12a776041f4e7dd723029c5ebb8a38317952d12b1ad30b0e3e9d88f7a7fe727e", + "manifestSha256": "c55098b862389b5edb4f41ac620a3d4817af2633d95f290cb401e55978ed921e", "signatureVerified": false, "signer": null, "trustedMaintainer": false, @@ -2931,11 +2931,11 @@ }, { "path": ".codex/config.toml", - "sha256": "5734ff3444010de65901ea3efda663f26e1c240718898b5d96736f9550a83d3e" + "sha256": "36150ae4d6e37a56c4911b5888fb1939446a9eab1c9cba173c79cc6e85f776eb" } ], "id": "model-routing-official", - "manifestSha256": "9886185283a2c96c661375d6b784673c2cb6a62bd1978a087c84de1dab214be6", + "manifestSha256": "23c135e044113ce8a6f8b048fbf96710fa16d465940bc89154bd291f38912d2a", "signatureVerified": false, "signer": null, "trustedMaintainer": false, @@ -3987,7 +3987,7 @@ }, { "path": ".codex/config.toml", - "sha256": "62c632cf3eb279cd1f82c71a527559b2a40c75f2646307d5a1df3523dd12d016" + "sha256": "1068d38f406cf4ca7160557d8a16cfbe6fc8684e89245ffba823cb510b47e594" }, { "path": ".cursor/agents/model-routing-fable-driver.md", @@ -3995,7 +3995,7 @@ } ], "id": "model-routing-official", - "manifestSha256": "67b12f790864b4018428ef357c99c7512022cd5bc0d0a33a047ad256122b1c45", + "manifestSha256": "0290603ce5c40440533235f99b021abfd807269c07285996a033198d3460354c", "signatureVerified": false, "signer": null, "trustedMaintainer": false, @@ -4261,11 +4261,11 @@ }, { "path": ".codex/config.toml", - "sha256": "5734ff3444010de65901ea3efda663f26e1c240718898b5d96736f9550a83d3e" + "sha256": "36150ae4d6e37a56c4911b5888fb1939446a9eab1c9cba173c79cc6e85f776eb" } ], "id": "model-routing-official", - "manifestSha256": "4fae4ee77b4408b96da09d2e07aebaf29d4357d18fab26022ab8587a16202b32", + "manifestSha256": "73176db7f008ea637b9959e9261f7d38abde11a30defdc52ace3c9e33ed8cd83", "signatureVerified": false, "signer": null, "trustedMaintainer": false, @@ -5287,7 +5287,7 @@ }, { "path": ".codex/config.toml", - "sha256": "62c632cf3eb279cd1f82c71a527559b2a40c75f2646307d5a1df3523dd12d016" + "sha256": "1068d38f406cf4ca7160557d8a16cfbe6fc8684e89245ffba823cb510b47e594" }, { "path": ".cursor/agents/model-routing-fable-driver.md", @@ -5295,7 +5295,7 @@ } ], "id": "model-routing-official", - "manifestSha256": "7be221d1d4262414ad9067671ad1dd535b2a4fb45925f67e59f2160633d32f97", + "manifestSha256": "179e020fe418ffd91f2c033d1f591c70a342103555f4128cde8ff1e08aeee535", "signatureVerified": false, "signer": null, "trustedMaintainer": false, @@ -5437,6 +5437,7 @@ "medium", "high", "xhigh", + "max", "ultra" ], "id": "gpt-5.6-sol", @@ -5448,6 +5449,7 @@ "medium", "high", "xhigh", + "max", "ultra" ], "id": "gpt-5.6-terra", @@ -5458,7 +5460,8 @@ "low", "medium", "high", - "xhigh" + "xhigh", + "max" ], "id": "gpt-5.6-luna", "tier": "standard" diff --git a/website/public/_headers b/website/public/_headers index 3487836..32165a4 100644 --- a/website/public/_headers +++ b/website/public/_headers @@ -2,4 +2,4 @@ X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() - Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none' + Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://analytics.int.macherjek.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://analytics.int.macherjek.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none' diff --git a/website/public/brand/pi.svg b/website/public/brand/pi.svg index 1d6ea61..385ad2c 100644 --- a/website/public/brand/pi.svg +++ b/website/public/brand/pi.svg @@ -3,9 +3,6 @@ Pi + + + diff --git a/website/src/components/AgentTeamDialog.tsx b/website/src/components/AgentTeamDialog.tsx new file mode 100644 index 0000000..90e7921 --- /dev/null +++ b/website/src/components/AgentTeamDialog.tsx @@ -0,0 +1,150 @@ +import { useState } from "react"; +import { ArrowRightIcon, CheckIcon, CopyIcon, TerminalWindowIcon, UsersThreeIcon } from "@phosphor-icons/react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import type { ResolvedProviderOnboarding } from "@/lib/onboarding"; + +export function AgentTeamDialog({ onboarding }: { onboarding: ResolvedProviderOnboarding }) { + const [copiedStep, setCopiedStep] = useState(null); + const [copyFailedStep, setCopyFailedStep] = useState(null); + const [showInstallCommand, setShowInstallCommand] = useState(false); + + async function copyCommand(stepId: string, command: string) { + try { + await navigator.clipboard.writeText(command); + setCopyFailedStep(null); + setCopiedStep(stepId); + window.setTimeout(() => setCopiedStep((current) => current === stepId ? null : current), 1400); + } catch { + setCopiedStep(null); + setCopyFailedStep(stepId); + } + } + + return ( + + }> + + + +
+ + + + + {onboarding.status} + +
+ {onboarding.title} + + {onboarding.description} + +
+ +
    + {onboarding.steps.map((step, index) => ( +
  1. + + {index + 1} + +
    +

    {step.title}

    +

    + {step.description} +

    + {step.command && ( + step.id === "install" ? ( +
    +
    + + +
    + {showInstallCommand && ( +
    + + {step.command} + +
    + )} + {copyFailedStep === step.id && ( +

    + Clipboard access failed. Try again or copy the command from the Commands tab. +

    + )} +
    + ) : ( +
    +
    +
    + {copyFailedStep === step.id && ( +

    + Clipboard access failed. Select the command above and copy it manually. +

    + )} +
    + ) + )} +
    +
  2. + ))} +
+
+
+ ); +} diff --git a/website/src/components/EffortStrengthPicker.tsx b/website/src/components/EffortStrengthPicker.tsx new file mode 100644 index 0000000..a3c6601 --- /dev/null +++ b/website/src/components/EffortStrengthPicker.tsx @@ -0,0 +1,173 @@ +import { useId, useRef } from "react"; +import { CaretDownIcon, InfoIcon } from "@phosphor-icons/react"; + +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { effortHint, formatEffortLabel } from "@/lib/model-family"; + +export function EffortStrengthPicker({ + label, + efforts, + value, + onValueChange, + valueNote, + "aria-label": ariaLabel, + className, +}: { + label: string; + efforts: readonly string[]; + value: string; + onValueChange: (effort: string) => void; + valueNote?: string; + "aria-label": string; + className?: string; +}) { + const labelId = useId(); + const trackRef = useRef(null); + const index = Math.max(0, efforts.indexOf(value)); + const max = Math.max(efforts.length - 1, 1); + const percent = efforts.length <= 1 ? 0 : (index / max) * 100; + const hint = effortHint(value); + const longestLabel = efforts.reduce((longest, effort) => { + const next = formatEffortLabel(effort); + return next.length > longest.length ? next : longest; + }, formatEffortLabel(value)); + + function setIndex(next: number) { + const clamped = Math.min(Math.max(next, 0), efforts.length - 1); + const effort = efforts[clamped]; + if (effort) onValueChange(effort); + } + + function onTrackPointer(clientX: number) { + const track = trackRef.current; + if (!track || efforts.length <= 1) return; + const rect = track.getBoundingClientRect(); + if (rect.width <= 0) return; + const ratio = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1); + setIndex(Math.round(ratio * max)); + } + + return ( +
+ + + } + > + + + + {formatEffortLabel(value)} + {valueNote ? ( + + }> + + + {valueNote} + + ) : null} + + + + + + {label} + + {formatEffortLabel(value)} + {hint ? ` — ${hint}` : null} + + + +
{ + if (event.key === "ArrowRight" || event.key === "ArrowUp") { + event.preventDefault(); + setIndex(index + 1); + } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") { + event.preventDefault(); + setIndex(index - 1); + } else if (event.key === "Home") { + event.preventDefault(); + setIndex(0); + } else if (event.key === "End") { + event.preventDefault(); + setIndex(efforts.length - 1); + } + }} + onPointerDown={(event) => { + event.currentTarget.setPointerCapture(event.pointerId); + onTrackPointer(event.clientX); + }} + onPointerMove={(event) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return; + onTrackPointer(event.clientX); + }} + > +
+
+ {efforts.map((effort, effortIndex) => { + const left = efforts.length <= 1 ? 0 : (effortIndex / max) * 100; + return ( +
+
+ +
+ {formatEffortLabel(efforts[0] ?? value)} + {formatEffortLabel(efforts[efforts.length - 1] ?? value)} +
+ + +
+ ); +} diff --git a/website/src/components/Generator.test.ts b/website/src/components/Generator.test.ts new file mode 100644 index 0000000..92f7ce0 --- /dev/null +++ b/website/src/components/Generator.test.ts @@ -0,0 +1,457 @@ +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import catalog from "../../data/catalog.json"; +import Generator from "./Generator"; +import { hostCatalogFrom, setupTransportFrom } from "../lib/generator"; + +const reactState = vi.hoisted(() => ({ + cursor: 0, + values: [] as unknown[], + reset() { + this.cursor = 0; + this.values = []; + }, + rewind() { + this.cursor = 0; + }, + useState(initial: T | (() => T)) { + const index = this.cursor; + this.cursor += 1; + if (!(index in this.values)) { + this.values[index] = typeof initial === "function" ? (initial as () => T)() : initial; + } + const setValue = (next: T | ((current: T) => T)) => { + const current = this.values[index] as T; + this.values[index] = typeof next === "function" ? (next as (current: T) => T)(current) : next; + }; + return [this.values[index] as T, setValue] as const; + }, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useMemo: (factory: () => T) => factory(), + useState: (initial: T | (() => T)) => reactState.useState(initial), + }; +}); + +const hostCatalog = hostCatalogFrom(catalog); +const setupTransport = setupTransportFrom(catalog); + +type TestRenderableComponent = (props: Record) => ReactNode; + +function renderGenerator() { + reactState.rewind(); + return Generator({ hostCatalog, setupTransport }); +} + +function elementProps(node: ReactNode) { + return isValidElement(node) ? (node as ReactElement>).props : undefined; +} + +function isTestRenderableComponent(type: unknown): type is TestRenderableComponent { + return typeof type === "function" && [ + "CommandSummary", + "ConnectedChildCard", + "GeneratorConfigPanel", + "GeneratorSummaryPanel", + "IntegrationFieldSet", + "ParentRecommendationCard", + "PresetField", + "ResetRolesDialog", + "RoleCard", + "RoleTuningFieldSet", + "RuntimeFieldSet", + "TeamSummary", + ].includes(type.name); +} + +function visibleText(node: ReactNode): string { + if (node === null || node === undefined || typeof node === "boolean") { + return ""; + } + if (typeof node === "string" || typeof node === "number") { + return String(node); + } + if (Array.isArray(node)) { + return node.map(visibleText).join(""); + } + if (!isValidElement(node)) { + return ""; + } + + const element = node as ReactElement>; + const type = element.type; + if (isTestRenderableComponent(type)) { + return visibleText(type(element.props)); + } + + return visibleText(element.props.children as ReactNode); +} + +function findElement(node: ReactNode, predicate: (element: ReactElement>) => boolean): ReactElement> | null { + if (node === null || node === undefined || typeof node === "boolean" || typeof node === "string" || typeof node === "number") { + return null; + } + if (Array.isArray(node)) { + for (const child of node) { + const found = findElement(child, predicate); + if (found) { + return found; + } + } + return null; + } + if (!isValidElement(node)) { + return null; + } + + const element = node as ReactElement>; + if (predicate(element)) { + return element; + } + + const foundInRenderedControl = findElement(element.props.render as ReactNode, predicate); + if (foundInRenderedControl) { + return foundInRenderedControl; + } + if (isTestRenderableComponent(element.type)) { + const foundInRenderedComponent = findElement(element.type(element.props), predicate); + if (foundInRenderedComponent) { + return foundInRenderedComponent; + } + } + return findElement(element.props.children as ReactNode, predicate); +} + +function findByAriaLabel(node: ReactNode, label: string): ReactElement> { + if (Array.isArray(node)) { + for (const child of node) { + const found = findByAriaLabelOrNull(child, label); + if (found) { + return found; + } + } + } + + const found = findByAriaLabelOrNull(node, label); + if (!found) { + throw new Error(`Could not find element with aria-label ${label}`); + } + return found; +} + +function findByAriaLabelOrNull(node: ReactNode, label: string): ReactElement> | null { + if (node === null || node === undefined || typeof node === "boolean" || typeof node === "string" || typeof node === "number") { + return null; + } + if (Array.isArray(node)) { + for (const child of node) { + const found = findByAriaLabelOrNull(child, label); + if (found) { + return found; + } + } + return null; + } + if (!isValidElement(node)) { + return null; + } + + const props = elementProps(node)!; + if (props["aria-label"] === label) { + return node as ReactElement>; + } + const renderedControl = props.render as ReactNode; + const foundInRenderedControl = findByAriaLabelOrNull(renderedControl, label); + if (foundInRenderedControl) { + return foundInRenderedControl; + } + if (isTestRenderableComponent(node.type)) { + return findByAriaLabelOrNull(node.type(props), label); + } + return findByAriaLabelOrNull(props.children as ReactNode, label); +} + +function selectToggle(label: string, value: string) { + const control = findByAriaLabel(renderGenerator(), label); + const onValueChange = control.props.onValueChange; + if (typeof onValueChange !== "function") { + throw new Error(`${label} is not selectable`); + } + onValueChange([value]); +} + +describe("Generator parent recommendation card", () => { + beforeEach(() => { + reactState.reset(); + }); + + it("renders the non-editable parent card as host managed with Balanced effort copy", () => { + const text = visibleText(renderGenerator()); + + expect(text).toContain("Orchestrator"); + expect(text).toContain("Host managed"); + expect(text).toContain("Set Codex reasoning to Medium."); + expect(text).not.toContain("Not included"); + expect(text).not.toContain("Balanced recommends Sol"); + }); + + it("counts generated children separately from the host-managed parent in the summary", () => { + const text = visibleText(renderGenerator()); + + expect(text).toContain("3 generated child roles"); + expect(text).toContain("host-managed parent"); + expect(text).toContain("not written to Spec"); + expect(text).not.toContain("4 focused roles"); + }); + + it("shows host-managed parent effort without rendering the parent model in the Team summary", () => { + selectToggle("AI agent", "cursor"); + const text = visibleText(renderGenerator()); + + expect(text).toContain("Orchestrator"); + expect(text).toContain("Host managed"); + expect(text).toContain("high · not written to Spec"); + expect(text).not.toContain("fable-5"); + }); + + it("updates the parent card effort copy when Light is selected", () => { + selectToggle("Team preset", "light"); + const text = visibleText(renderGenerator()); + + expect(text).toContain("Orchestrator"); + expect(text).toContain("Host managed"); + expect(text).toContain("Set Codex reasoning to Low."); + expect(text).not.toContain("Not included"); + expect(text).not.toContain("Set Codex reasoning to Medium."); + }); + + it("updates the parent card effort copy when High is selected after Light", () => { + selectToggle("Team preset", "light"); + expect(visibleText(renderGenerator())).toContain("Set Codex reasoning to Low."); + + selectToggle("Team preset", "high"); + const text = visibleText(renderGenerator()); + + expect(text).toContain("Orchestrator"); + expect(text).toContain("Host managed"); + expect(text).toContain("Set Codex reasoning to Medium."); + expect(text).not.toContain("Not included"); + expect(text).not.toContain("Set Codex reasoning to Low."); + }); + + it("does not render the parent card for Pi", () => { + selectToggle("AI agent", "pi"); + const text = visibleText(renderGenerator()); + + expect(text).toContain("4 focused roles"); + expect(text).toContain("Orchestrator"); + expect(text).toContain("openai/gpt-4o-mini · medium"); + expect(text).not.toContain("host-managed parent"); + expect(text).not.toContain("Host managed"); + expect(text).not.toContain("Not included"); + expect(text).not.toContain("Set Codex reasoning"); + }); +}); + +describe("Generator connected child cards", () => { + beforeEach(() => { + reactState.reset(); + }); + + it("renders every selected child with visible model and reasoning controls", () => { + const tree = renderGenerator(); + const text = visibleText(tree); + + expect(text).toContain("Implementer"); + expect(text).toContain("Writes code and runs focused tests."); + expect(text).toContain("Reviewer"); + expect(text).toContain("Finds defects independently."); + expect(text).toContain("Verifier"); + expect(text).toContain("Proves the result actually works."); + expect(findByAriaLabelOrNull(tree, "Edit Implementer")).toBeNull(); + expect(findByAriaLabel(tree, "Remove Reviewer").props.disabled).toBe(false); + expect(findByAriaLabel(tree, "Implementer model")).toBeDefined(); + expect(findByAriaLabel(tree, "Implementer Reasoning")).toBeDefined(); + expect(findByAriaLabel(tree, "Reviewer model")).toBeDefined(); + expect(findByAriaLabel(tree, "Reviewer Reasoning")).toBeDefined(); + expect(findByAriaLabel(tree, "Verifier model")).toBeDefined(); + expect(findByAriaLabel(tree, "Verifier Reasoning")).toBeDefined(); + }); + + it("removes a child card from its trash action and marks the preset custom", () => { + const removeReviewer = findByAriaLabel(renderGenerator(), "Remove Reviewer").props.onClick; + if (typeof removeReviewer !== "function") { + throw new Error("Reviewer remove action is not clickable"); + } + removeReviewer(); + + const text = visibleText(renderGenerator()); + expect(text).not.toContain("Reviewer"); + expect(text).toContain("Implementer"); + expect(text).toContain("Verifier"); + expect(text).toContain("Custom"); + }); + + it("keeps all child controls visible without pencil actions", () => { + const tree = renderGenerator(); + expect(findByAriaLabelOrNull(tree, "Edit Implementer")).toBeNull(); + expect(findByAriaLabelOrNull(tree, "Edit Reviewer")).toBeNull(); + expect(findByAriaLabelOrNull(tree, "Edit Verifier")).toBeNull(); + expect(findByAriaLabel(tree, "Implementer model")).toBeDefined(); + expect(findByAriaLabel(tree, "Implementer Reasoning")).toBeDefined(); + expect(findByAriaLabel(tree, "Reviewer model")).toBeDefined(); + expect(findByAriaLabel(tree, "Reviewer Reasoning")).toBeDefined(); + expect(findByAriaLabel(tree, "Verifier model")).toBeDefined(); + expect(findByAriaLabel(tree, "Verifier Reasoning")).toBeDefined(); + }); + + it("explains Codex Max as a manual single-agent option", () => { + const reasoning = findByAriaLabel(renderGenerator(), "Reviewer Reasoning"); + const onValueChange = reasoning.props.onValueChange; + if (typeof onValueChange !== "function") { + throw new Error("Reviewer reasoning is not selectable"); + } + onValueChange("max"); + + expect(findByAriaLabel(renderGenerator(), "Reviewer Reasoning").props.valueNote) + .toMatch(/single-agent|one Codex agent/i); + expect(findByAriaLabel(renderGenerator(), "Reviewer Reasoning").props.valueNote) + .toMatch(/app settings/i); + }); + + it("disables reset roles until the selected roles differ from the preset baseline", () => { + expect(findByAriaLabel(renderGenerator(), "Reset roles").props.disabled).toBe(true); + + const removeReviewer = findByAriaLabel(renderGenerator(), "Remove Reviewer").props.onClick; + if (typeof removeReviewer !== "function") { + throw new Error("Reviewer remove action is not clickable"); + } + removeReviewer(); + + expect(findByAriaLabel(renderGenerator(), "Reset roles").props.disabled).toBe(false); + expect(visibleText(renderGenerator())).toContain("Custom"); + }); + + it("restores the last selected preset from the reset confirmation", () => { + selectToggle("Team preset", "high"); + const removeReviewer = findByAriaLabel(renderGenerator(), "Remove Reviewer").props.onClick; + if (typeof removeReviewer !== "function") { + throw new Error("Reviewer remove action is not clickable"); + } + removeReviewer(); + expect(visibleText(renderGenerator())).toContain("Custom"); + expect(visibleText(renderGenerator())).not.toContain("Reviewer"); + + const confirmReset = findByAriaLabel(renderGenerator(), "Confirm reset roles").props.onClick; + if (typeof confirmReset !== "function") { + throw new Error("Reset confirmation is not clickable"); + } + confirmReset(); + + const text = visibleText(renderGenerator()); + expect(text).toContain("Reviewer"); + expect(text).toContain("High"); + expect(text).not.toContain("Custom"); + expect(findByAriaLabel(renderGenerator(), "Reset roles").props.disabled).toBe(true); + }); + + it("keeps removed roles custom after host switching until reset restores the new host preset", () => { + const removeReviewer = findByAriaLabel(renderGenerator(), "Remove Reviewer").props.onClick; + if (typeof removeReviewer !== "function") { + throw new Error("Reviewer remove action is not clickable"); + } + removeReviewer(); + + selectToggle("AI agent", "pi"); + + const switchedText = visibleText(renderGenerator()); + expect(switchedText).toContain("Custom"); + expect(switchedText).not.toContain("Reviewer"); + expect(switchedText).toContain("openai/gpt-4o-mini · medium"); + expect(findByAriaLabel(renderGenerator(), "Reset roles").props.disabled).toBe(false); + + const confirmReset = findByAriaLabel(renderGenerator(), "Confirm reset roles").props.onClick; + if (typeof confirmReset !== "function") { + throw new Error("Reset confirmation is not clickable"); + } + confirmReset(); + + const resetText = visibleText(renderGenerator()); + expect(resetText).toContain("Reviewer"); + expect(resetText).not.toContain("Custom"); + expect(findByAriaLabel(renderGenerator(), "Reset roles").props.disabled).toBe(true); + }); + + it("renders reset confirmation with the project dialog treatment", () => { + const removeReviewer = findByAriaLabel(renderGenerator(), "Remove Reviewer").props.onClick; + if (typeof removeReviewer !== "function") { + throw new Error("Reviewer remove action is not clickable"); + } + removeReviewer(); + + const resetDialogContent = findElement(renderGenerator(), (element) => { + return typeof element.type === "function" + && element.type.name === "DialogContent" + && visibleText(element.props.children as ReactNode).includes("Reset roles?"); + }); + expect(resetDialogContent?.props.className).toContain("p-0"); + expect(resetDialogContent?.props.className).toContain("sm:max-w-lg"); + + const resetDialogHeader = findElement(resetDialogContent, (element) => { + return typeof element.type === "function" + && element.type.name === "DialogHeader" + && visibleText(element.props.children as ReactNode).includes("Destructive action"); + }); + expect(resetDialogHeader?.props.className).toContain("border-b"); + expect(resetDialogHeader?.props.className).toContain("px-5"); + expect(visibleText(resetDialogContent)).toContain("Cancel keeps your current role edits."); + }); + + it("keeps custom edits when reset confirmation is dismissed", () => { + const removeReviewer = findByAriaLabel(renderGenerator(), "Remove Reviewer").props.onClick; + if (typeof removeReviewer !== "function") { + throw new Error("Reviewer remove action is not clickable"); + } + removeReviewer(); + + const resetDialog = findElement(renderGenerator(), (element) => { + return typeof element.type === "function" + && element.type.name === "Dialog" + && element.props.open === false + && typeof element.props.onOpenChange === "function"; + }); + const onOpenChange = resetDialog?.props.onOpenChange; + if (typeof onOpenChange !== "function") { + throw new Error("Reset confirmation dialog is not dismissible"); + } + onOpenChange(false); + + const text = visibleText(renderGenerator()); + expect(text).toContain("Custom"); + expect(text).not.toContain("Reviewer"); + expect(findByAriaLabel(renderGenerator(), "Reset roles").props.disabled).toBe(false); + }); + + it("guards the final child role with a disabled remove affordance", () => { + const removeImplementer = findByAriaLabel(renderGenerator(), "Remove Implementer").props.onClick; + if (typeof removeImplementer !== "function") { + throw new Error("Implementer remove action is not clickable"); + } + removeImplementer(); + + const removeReviewer = findByAriaLabel(renderGenerator(), "Remove Reviewer").props.onClick; + if (typeof removeReviewer !== "function") { + throw new Error("Reviewer remove action is not clickable"); + } + removeReviewer(); + + const tree = renderGenerator(); + expect(visibleText(tree)).toContain("At least one child role is required."); + expect(findByAriaLabel(tree, "Cannot remove Verifier; at least one child role is required").props.disabled).toBe(true); + }); +}); diff --git a/website/src/components/Generator.tsx b/website/src/components/Generator.tsx index 553c35b..4afa0cf 100644 --- a/website/src/components/Generator.tsx +++ b/website/src/components/Generator.tsx @@ -1,18 +1,34 @@ import { useMemo, useState } from "react"; import { + ArrowCounterClockwiseIcon, CheckIcon, CopyIcon, - DownloadSimpleIcon, GithubLogoIcon, + QuestionIcon, RobotIcon, ShieldCheckIcon, + TrashIcon, + WarningCircleIcon, XLogoIcon, } from "@phosphor-icons/react"; +import { AgentTeamDialog } from "@/components/AgentTeamDialog"; +import { EffortStrengthPicker } from "@/components/EffortStrengthPicker"; +import { ModelStrengthKugeln } from "@/components/ModelStrengthKugeln"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { Combobox, ComboboxContent, @@ -24,31 +40,42 @@ import { import { Field, FieldDescription, FieldGroup, FieldSet, FieldLegend, FieldTitle } from "@/components/ui/field"; import { Separator } from "@/components/ui/separator"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { cn } from "@/lib/utils"; +import { modelFamilyInfo } from "@/lib/model-family"; import { applyPreset, + canRenderParentRecommendation, changeHost, + choosePreset, createConfig, + createPresetSelection, HOST_IDS, HOSTS, + isPresetDirty, lifecycleCommands, + markPresetCustom, PRESET_IDS, PRESETS, + parentRecommendationEffortCopy, + primaryRecommendation, recipeApplyCommand, + removeChildRole, + resetRolesToPreset, + type ChildRoleId, type HostCatalog, type HostId, type PresetId, - ROLE_IDS, ROLES, type RoleId, setEffort, setIntegration, setModel, - setRoles, - setupConfigToml, setupSpec, + setupSpecRoleIds, } from "@/lib/generator"; +import { providerOnboarding } from "@/lib/onboarding"; function HostIcon({ host }: { host: HostId }) { return ( @@ -56,48 +83,79 @@ function HostIcon({ host }: { host: HostId }) { src={`/brand/${host === "claude-code" ? "claude" : host}.svg`} alt="" aria-hidden="true" - className="size-4 shrink-0 object-contain" + className={cn( + "size-4 shrink-0 object-contain", + (host === "cursor" || host === "opencode" || host === "pi") && "group-aria-[pressed=true]/toggle:invert", + )} /> ); } -export default function Generator({ hostCatalog, setupTransport }: { hostCatalog: HostCatalog; setupTransport: { recipePrefix: string; configPath: string } }) { - const [config, setConfig] = useState(createConfig()); - const [preset, setPreset] = useState("balanced"); - const [copyState, setCopyState] = useState<"idle" | "copied">("idle"); +function truncateCommandPreview(command: string) { + return command.replace(/--recipe '[^']+'/, "--recipe 'sw1_…'"); +} + +type GeneratorProps = { + hostCatalog: HostCatalog; + setupTransport: { recipePrefix: string; configPath: string }; +}; + +type GeneratorConfigState = ReturnType; +type PresetSelectionState = ReturnType; +type OnboardingState = ReturnType; + +export default function Generator({ hostCatalog, setupTransport }: GeneratorProps) { + const [config, setConfig] = useState(() => createConfig()); + const [preset, setPreset] = useState(() => createPresetSelection()); + const [copiedCommandId, setCopiedCommandId] = useState(null); + const [resetDialogOpen, setResetDialogOpen] = useState(false); const setup = useMemo(() => setupSpec(config, hostCatalog), [config, hostCatalog]); - const configToml = useMemo(() => setupConfigToml(config, hostCatalog), [config, hostCatalog]); const copyCommand = useMemo(() => recipeApplyCommand(config, hostCatalog, setupTransport.recipePrefix), [config, hostCatalog, setupTransport.recipePrefix]); const commands = useMemo(() => lifecycleCommands(config, hostCatalog, setupTransport.recipePrefix), [config, hostCatalog, setupTransport.recipePrefix]); + const onboarding = useMemo(() => providerOnboarding(config.host, copyCommand), [config.host, copyCommand]); const host = HOSTS[config.host]; + const rolesDirty = useMemo(() => isPresetDirty(config, preset.lastSelected, hostCatalog), [config, preset.lastSelected, hostCatalog]); + const hasHostManagedParent = canRenderParentRecommendation(config); + const childRoleCount = config.roles.length; + const outputRoleIds = useMemo(() => setupSpecRoleIds(config), [config]); - function downloadConfig() { - const blob = new Blob([configToml], { type: "application/toml" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = "config.toml"; - link.click(); - URL.revokeObjectURL(url); - } - - async function copy() { - await navigator.clipboard.writeText(copyCommand); - setCopyState("copied"); - window.setTimeout(() => setCopyState("idle"), 1400); + async function copyLifecycleCommand(id: string, command: string) { + try { + await navigator.clipboard.writeText(command); + setCopiedCommandId(id); + window.setTimeout(() => setCopiedCommandId((current) => current === id ? null : current), 1400); + } catch { + setCopiedCommandId(null); + } } function selectHost(hostId: HostId) { - setConfig(changeHost(config, hostId)); - setPreset("balanced"); + const nextConfig = changeHost(config, hostId); + const nextPreset = choosePreset(preset, "balanced"); + setConfig(nextConfig); + setPreset(isPresetDirty(nextConfig, "balanced", hostCatalog) ? markPresetCustom(nextPreset) : nextPreset); } function selectPreset(value: PresetId) { setConfig(applyPreset(config, value, hostCatalog)); - setPreset(value); + setPreset(choosePreset(preset, value)); + } + + function removeRole(role: ChildRoleId) { + const nextConfig = removeChildRole(config, role); + if (nextConfig === config) return; + setConfig(nextConfig); + setPreset(markPresetCustom(preset)); + } + + function confirmResetRoles() { + setConfig(resetRolesToPreset(config, preset.lastSelected, hostCatalog)); + setPreset(choosePreset(preset, preset.lastSelected)); + setResetDialogOpen(false); } return ( +
@@ -106,6 +164,7 @@ export default function Generator({ hostCatalog, setupTransport }: { hostCatalog
+
+ ); +} + +function GeneratorConfigPanel({ config, hasHostManagedParent, host, hostCatalog, preset, resetDialogOpen, rolesDirty, onConfirmResetRoles, onEffort, onIntegration, onModel, onPreset, onRemoveRole, onResetDialogOpenChange, onSelectHost }: { + config: GeneratorConfigState; + hasHostManagedParent: boolean; + host: (typeof HOSTS)[HostId]; + hostCatalog: HostCatalog; + preset: PresetSelectionState; + resetDialogOpen: boolean; + rolesDirty: boolean; + onConfirmResetRoles: () => void; + onEffort: (role: ChildRoleId, effort: string) => void; + onIntegration: (integration: "standalone" | "planr") => void; + onModel: (role: ChildRoleId, model: string) => void; + onPreset: (preset: PresetId) => void; + onRemoveRole: (role: ChildRoleId) => void; + onResetDialogOpenChange: (open: boolean) => void; + onSelectHost: (host: HostId) => void; +}) { + return ( + + + Configure your team + + + + + + + + + + + + ); +} + +function RuntimeFieldSet({ config, host, onSelectHost }: { + config: GeneratorConfigState; + host: (typeof HOSTS)[HostId]; + onSelectHost: (host: HostId) => void; +}) { + return ( +
+
+ 1. Which runtime are you using? + + + } + > + + +

{host.label}: {host.runtime}

+

{host.note}

+
+
+
+ The generated files match that host's project conventions and evidence boundary. + values[0] && onSelectHost(values[0] as HostId)} + variant="outline" + spacing={0} + className="grid w-full grid-cols-1 sm:grid-cols-5" + > + {HOST_IDS.map((id) => ( + + + {HOSTS[id].label} + + ))} + + {config.host !== "codex" && ( + +
+ instructa/switchloom + + . + + + )} +
); } -function RoleCard({ role, config, hostCatalog, onModel, onEffort }: { - role: RoleId; +function IntegrationFieldSet({ config, onIntegration }: { + config: GeneratorConfigState; + onIntegration: (integration: "standalone" | "planr") => void; +}) { + return ( +
+ 2. Standalone or With Planr? + + Standalone sets up host-native agents in your project. With Planr also adds Planr policy files when the repository already uses Planr. + + values[0] && onIntegration(values[0] as "standalone" | "planr")} + variant="outline" + spacing={0} + className="grid w-full grid-cols-2" + > + Standalone + + + With Planr + + + {config.integration === "planr" && ( + + + )} +
+ ); +} + +function RoleTuningFieldSet({ config, hasHostManagedParent, host, hostCatalog, preset, resetDialogOpen, rolesDirty, onConfirmResetRoles, onEffort, onModel, onPreset, onRemoveRole, onResetDialogOpenChange }: { + config: GeneratorConfigState; + hasHostManagedParent: boolean; + host: (typeof HOSTS)[HostId]; + hostCatalog: HostCatalog; + preset: PresetSelectionState; + resetDialogOpen: boolean; + rolesDirty: boolean; + onConfirmResetRoles: () => void; + onEffort: (role: ChildRoleId, effort: string) => void; + onModel: (role: ChildRoleId, model: string) => void; + onPreset: (preset: PresetId) => void; + onRemoveRole: (role: ChildRoleId) => void; + onResetDialogOpenChange: (open: boolean) => void; +}) { + return ( +
+
+
+ 3. Tune each role + Start with a team-wide preset, then override any role below. +
+ +
+ + + {hasHostManagedParent && ( + + )} +
+ {config.roles.map((role, index) => ( + + 1} + onRemove={() => onRemoveRole(role)} + onModel={(model) => onModel(role, model)} + onEffort={(effort) => onEffort(role, effort)} + /> + + ))} +
+
+
+ ); +} + +function ResetRolesDialog({ host, open, preset, rolesDirty, onConfirm, onOpenChange }: { + host: (typeof HOSTS)[HostId]; + open: boolean; + preset: PresetSelectionState; + rolesDirty: boolean; + onConfirm: () => void; + onOpenChange: (open: boolean) => void; +}) { + return ( + + }> + + + +
+ + + Destructive action +
+ Reset roles? + + Restore the {PRESETS[preset.lastSelected].label} preset roles, models, effort, and usage policy for {host.label}. + +
+
+ Cancel keeps your current role edits. Reset restores the selected preset. +
+ + }> + Cancel + + + +
+
+ ); +} + +function PresetField({ preset, onPreset }: { + preset: PresetSelectionState; + onPreset: (preset: PresetId) => void; +}) { + return ( + +
+ Team preset + {preset.selected === "custom" && Custom} +
+ values[0] && onPreset(values[0] as PresetId)} + variant="outline" + spacing={0} + className="grid w-full grid-cols-3" + > + {PRESET_IDS.map((id) => ( + + {PRESETS[id].label} + + ))} + + + {preset.selected === "custom" ? "Per-role choices override the preset." : PRESETS[preset.selected].short} + +
+ ); +} + +function GeneratorSummaryPanel({ childRoleCount, commands, config, copiedCommandId, hasHostManagedParent, host, onboarding, outputRoleIds, setup, onCopyCommand }: { + childRoleCount: number; + commands: ReturnType; + config: GeneratorConfigState; + copiedCommandId: string | null; + hasHostManagedParent: boolean; + host: (typeof HOSTS)[HostId]; + onboarding: OnboardingState; + outputRoleIds: RoleId[]; + setup: ReturnType; + onCopyCommand: (id: string, command: string) => void; +}) { + return ( + + + Your {host.label} team + + {hasHostManagedParent + ? `${childRoleCount} generated child ${childRoleCount === 1 ? "role" : "roles"} · host-managed parent` + : `${outputRoleIds.length} focused ${outputRoleIds.length === 1 ? "role" : "roles"}`} + {" · "} + {config.integration === "planr" ? "Planr setup spec" : "standalone setup spec"} + + + + + + Team + Commands + Spec + + + + + + + + +
+              {JSON.stringify(setup, null, 2)}
+            
+
+
+
+ + +

+ Preview before apply, run doctor to check the host, and review every repository-local change before confirming setup. +

+
+
+ ); +} + +function TeamSummary({ config, hasHostManagedParent }: { + config: GeneratorConfigState; + hasHostManagedParent: boolean; +}) { + return ( +
+ {hasHostManagedParent && ( +
+ P +
+
+

{ROLES.orchestrator.label}

+ Host managed +
+

+ {config.assignments.orchestrator.effort ? `${config.assignments.orchestrator.effort} · ` : ""}not written to Spec +

+
+
+ )} + {config.roles.map((role, index) => { + const assignment = config.assignments[role]; + return ( +
+ {index + 1} +
+

{ROLES[role].label}

+

+ {assignment.model}{assignment.effort ? ` · ${assignment.effort}` : ""} +

+
+
+ ); + })} + {!hasHostManagedParent && ( +
+ {config.roles.length + 1} +
+

{ROLES.orchestrator.label}

+

+ {config.assignments.orchestrator.model}{config.assignments.orchestrator.effort ? ` · ${config.assignments.orchestrator.effort}` : ""} +

+
+
+ )} +
+ ); +} + +function CommandSummary({ commands, copiedCommandId, onCopyCommand }: { + commands: ReturnType; + copiedCommandId: string | null; + onCopyCommand: (id: string, command: string) => void; +}) { + return ( +
    + {commands.map((entry) => ( +
  1. +
    +
    +

    {entry.title}

    +

    {entry.description}

    +
    + +
    + + {truncateCommandPreview(entry.command)} + +
  2. + ))} +
+ ); +} + +function ConnectedChildCard({ children, isLast }: { children: React.ReactNode; isLast: boolean }) { + return ( +
+
+ ); +} + +function ParentRecommendationCard({ config, preset }: { + config: ReturnType; + preset: PresetId; +}) { + const recommendation = primaryRecommendation(config); + const effortCopy = parentRecommendationEffortCopy(config, preset); + + return ( + + +
+
+ {ROLES[recommendation.id].label} + {ROLES[recommendation.id].short} +
+ Host managed +
+
+ +

{effortCopy}

+
+
+ ); +} + +function RoleCard({ role, config, hostCatalog, canRemove, onRemove, onModel, onEffort }: { + role: ChildRoleId; config: ReturnType; hostCatalog: HostCatalog; + canRemove: boolean; + onRemove: () => void; onModel: (model: string) => void; onEffort: (effort: string) => void; }) { @@ -362,78 +729,125 @@ function RoleCard({ role, config, hostCatalog, onModel, onEffort }: { const models = hostCatalog[config.host].models; const assignment = config.assignments[role]; const model = models.find((candidate) => candidate.id === assignment.model)!; + const family = modelFamilyInfo(model.id); + const removeLabel = canRemove + ? `Remove ${ROLES[role].label}` + : `Cannot remove ${ROLES[role].label}; at least one child role is required`; return ( - {ROLES[role].label} - {ROLES[role].short} - {model.tier} +
+ {ROLES[role].label} + {ROLES[role].short} +

+ {model.label}{assignment.effort ? ` · ${assignment.effort}` : ""} +

+
+ + +
+ {!canRemove && ( + +

At least one child role is required.

+
+ )} - - Model - {config.host === "cursor" ? ( - value && onModel(value.id)} - itemToStringValue={(option) => option.label} - autoHighlight - > - - - No current model found. - - {(option) => ( - - {option.label} - {option.provider} - - )} - - - - ) : ( - values[0] && onModel(values[0])} - variant="outline" - spacing={1} - className="flex w-full flex-wrap" - > - {models.map((option) => {option.label})} - - )} - - {host.effortLabel && model.efforts.length > 0 && ( - - {host.effortLabel} - values[0] && onEffort(values[0])} - variant="outline" - spacing={1} - > - {model.efforts.map((effort) => ( - - {effort} - - ))} - - {model.efforts.includes("ultra") && ( - - Ultra enables automatic multi-agent delegation. It is manual-only and never selected by a preset. - +
+ + Model + {config.host === "cursor" ? ( + value && onModel(value.id)} + itemToStringValue={(option) => option.label} + autoHighlight + > + + + No current model found. + + {(option) => ( + + {option.label} + {option.provider} + + )} + + + + ) : ( + values[0] && onModel(values[0])} + variant="outline" + spacing={1} + className="flex w-full flex-wrap" + > + {models.map((option) => { + const optionFamily = modelFamilyInfo(option.id); + if (option.disabledReason) { + return ( + + }> + + {optionFamily && } + {option.label} + + + +

{option.disabledReason}

+
+
+ ); + } + return ( + + {optionFamily && } + {option.label} + + ); + })} +
+ )} + {family && ( + {family.short} + )} +
+ {host.effortLabel && model.efforts.length > 0 && assignment.effort && ( + + {host.effortLabel} + + )} - - )} +
diff --git a/website/src/components/ModelStrengthKugeln.tsx b/website/src/components/ModelStrengthKugeln.tsx new file mode 100644 index 0000000..3ac9fb2 --- /dev/null +++ b/website/src/components/ModelStrengthKugeln.tsx @@ -0,0 +1,101 @@ +import { useId } from "react"; + +import { cn } from "@/lib/utils"; +import type { ModelFamilyId } from "@/lib/model-family"; + +const FAMILY_LOOK: Record< + ModelFamilyId, + { + strength: 1 | 2 | 3; + highlight: string; + mid: string; + core: string; + rim: string; + } +> = { + luna: { + strength: 1, + highlight: "#f4f4f5", + mid: "#c8cdd4", + core: "#6b7280", + rim: "#9ca3af", + }, + terra: { + strength: 2, + highlight: "#7ef0ff", + mid: "#2f7dff", + core: "#0b3d8c", + rim: "#3aa0ff", + }, + sol: { + strength: 3, + highlight: "#ffc078", + mid: "#e07020", + core: "#6a2a0c", + rim: "#f08a3a", + }, +}; + +/** One celestial mass mark with GPT-5.6 family color + digit texture. */ +export function ModelStrengthKugeln({ + family, + className, + title, +}: { + family: ModelFamilyId; + className?: string; + title?: string; +}) { + const uid = useId().replaceAll(":", ""); + const look = FAMILY_LOOK[family]; + const size = 12 + look.strength * 2; + const radius = 2.6 + look.strength * 1.05; + const gradientId = `${uid}-grad`; + const textureId = `${uid}-tex`; + + return ( + + {title ? {title} : null} + + + + + + + + + 5 + + + 6 + + + + + + + + + ); +} diff --git a/website/src/components/ui/dialog.tsx b/website/src/components/ui/dialog.tsx new file mode 100644 index 0000000..cbe7c9c --- /dev/null +++ b/website/src/components/ui/dialog.tsx @@ -0,0 +1,155 @@ +import * as React from "react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "@phosphor-icons/react" + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogOverlay({ + className, + ...props +}: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + }> + Close + + )} +
+ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/website/src/components/ui/popover.tsx b/website/src/components/ui/popover.tsx new file mode 100644 index 0000000..17f69e4 --- /dev/null +++ b/website/src/components/ui/popover.tsx @@ -0,0 +1,88 @@ +import * as React from "react" +import { Popover as PopoverPrimitive } from "@base-ui/react/popover" + +import { cn } from "@/lib/utils" + +function Popover({ ...props }: PopoverPrimitive.Root.Props) { + return +} + +function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { + return +} + +function PopoverContent({ + className, + align = "center", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + ...props +}: PopoverPrimitive.Popup.Props & + Pick< + PopoverPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) { + return ( + + ) +} + +function PopoverDescription({ + className, + ...props +}: PopoverPrimitive.Description.Props) { + return ( + + ) +} + +export { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} diff --git a/website/src/components/ui/toggle.tsx b/website/src/components/ui/toggle.tsx index 6eb627e..d3cb655 100644 --- a/website/src/components/ui/toggle.tsx +++ b/website/src/components/ui/toggle.tsx @@ -6,12 +6,12 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const toggleVariants = cva( - "group/toggle inline-flex items-center justify-center gap-1 rounded-none text-xs font-medium whitespace-nowrap transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:disabled:opacity-100 data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:disabled:opacity-100 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/toggle inline-flex items-center justify-center gap-1 rounded-none text-xs font-medium whitespace-nowrap transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-35 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:disabled:opacity-100 data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:disabled:opacity-100 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-transparent", - outline: "border border-input bg-transparent hover:bg-muted", + outline: "border border-input bg-transparent hover:bg-muted disabled:border-transparent", }, size: { default: diff --git a/website/src/components/ui/tooltip.tsx b/website/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..7988b8d --- /dev/null +++ b/website/src/components/ui/tooltip.tsx @@ -0,0 +1,64 @@ +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delay = 0, + ...props +}: TooltipPrimitive.Provider.Props) { + return ( + + ) +} + +function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { + return +} + +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + return +} + +function TooltipContent({ + className, + side = "top", + sideOffset = 4, + align = "center", + alignOffset = 0, + children, + ...props +}: TooltipPrimitive.Popup.Props & + Pick< + TooltipPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + {children} + + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/website/src/lib/generator.test.ts b/website/src/lib/generator.test.ts index a80f254..b4e6a49 100644 --- a/website/src/lib/generator.test.ts +++ b/website/src/lib/generator.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { applyPreset, changeHost, createConfig, HOST_IDS, hostCatalogFrom, lifecycleCommands, recipeApplyCommand, setEffort, setIntegration, setModel, setRoles, setupConfigToml, setupRecipe, setupSpec, setupTransportFrom } from "./generator"; +import { applyPreset, canRenderParentRecommendation, changeHost, CHILD_ROLE_IDS, choosePreset, createConfig, createPresetSelection, HOST_IDS, hostCatalogFrom, isChildRoleId, isPresetDirty, isPrimaryRecommendationId, lifecycleCommands, markPresetCustom, parentRecommendationEffortCopy, PRESETS, primaryRecommendation, PRIMARY_RECOMMENDATION_ID, recipeApplyCommand, removeChildRole, resetRolesToPreset, ROLE_IDS, selectedChildRoleIds, setEffort, setIntegration, setModel, setRoles, setupConfigToml, setupRecipe, setupSpec, setupSummary, setupTransportFrom } from "./generator"; const generatedCatalog = { setupContract: { @@ -11,9 +11,9 @@ const generatedCatalog = { id: "codex", binding: "codex-openai", models: [ - { id: "gpt-5.6-sol", efforts: ["low", "medium", "high", "xhigh", "ultra"], tier: "premium" as const }, - { id: "gpt-5.6-terra", efforts: ["low", "medium", "high", "xhigh", "ultra"], tier: "standard" as const }, - { id: "gpt-5.6-luna", efforts: ["low", "medium", "high", "xhigh"], tier: "standard" as const }, + { id: "gpt-5.6-sol", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"], tier: "premium" as const }, + { id: "gpt-5.6-terra", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"], tier: "standard" as const }, + { id: "gpt-5.6-luna", efforts: ["low", "medium", "high", "xhigh", "max"], tier: "standard" as const }, ], }, { @@ -63,15 +63,81 @@ const generatedCatalog = { const hostCatalog = hostCatalogFrom(generatedCatalog); describe("Switchloom generator", () => { - it("always keeps the orchestrator and caps the team at four roles", () => { + it("splits the primary recommendation id from generated child role ids", () => { + expect(PRIMARY_RECOMMENDATION_ID).toBe("orchestrator"); + expect(CHILD_ROLE_IDS).toEqual(["implementer", "reviewer", "verifier"]); + expect(ROLE_IDS).toEqual([PRIMARY_RECOMMENDATION_ID, ...CHILD_ROLE_IDS]); + expect(isPrimaryRecommendationId("orchestrator")).toBe(true); + expect(isChildRoleId("orchestrator")).toBe(false); + expect(isChildRoleId("reviewer")).toBe(true); + }); + + it("keeps generated child roles separate from the parent recommendation", () => { const config = setRoles(createConfig(), ["implementer", "reviewer", "verifier", "not-a-role"]); - expect(config.roles).toEqual(["orchestrator", "implementer", "reviewer", "verifier"]); + expect(config.roles).toEqual(["implementer", "reviewer", "verifier"]); + }); + + it("tracks the parent recommendation separately from selected children", () => { + const config = setRoles(createConfig(), ["reviewer", "not-a-role"]); + expect(primaryRecommendation(config)).toEqual({ + id: "orchestrator", + assignment: { model: "gpt-5.6-sol", effort: "medium" }, + }); + expect(selectedChildRoleIds(config)).toEqual(["reviewer"]); + expect(selectedChildRoleIds(config)).not.toContain("orchestrator"); + }); + + it("remembers the last selected preset through custom edits", () => { + const balanced = createPresetSelection(); + expect(balanced).toEqual({ selected: "balanced", lastSelected: "balanced" }); + + const high = choosePreset(balanced, "high"); + expect(high).toEqual({ selected: "high", lastSelected: "high" }); + expect(markPresetCustom(high)).toEqual({ selected: "custom", lastSelected: "high" }); + }); + + it("detects dirty team state against a preset baseline", () => { + const balanced = applyPreset(createConfig("cursor"), "balanced", hostCatalog); + expect(isPresetDirty(balanced, "balanced", hostCatalog)).toBe(false); + expect(isPresetDirty(setEffort(balanced, "reviewer", "max", hostCatalog), "balanced", hostCatalog)).toBe(true); + expect(isPresetDirty(setRoles(balanced, ["implementer", "reviewer"]), "balanced", hostCatalog)).toBe(true); + }); + + it("removes selected children without dropping the final child", () => { + const config = createConfig(); + const withoutReviewer = removeChildRole(config, "reviewer"); + expect(withoutReviewer.roles).toEqual(["implementer", "verifier"]); + expect(removeChildRole(setRoles(config, ["verifier"]), "verifier").roles).toEqual(["verifier"]); + }); + + it("resets roles and assignments to the last selected preset", () => { + const custom = setRoles( + setEffort(applyPreset(createConfig("codex"), "high", hostCatalog), "reviewer", "ultra", hostCatalog), + ["reviewer"], + ); + + const reset = resetRolesToPreset(custom, "high", hostCatalog); + expect(reset.roles).toEqual(["implementer", "reviewer", "verifier"]); + expect(reset.usagePolicy).toBe("max-quality"); + expect(reset.assignments.reviewer).toEqual({ model: "gpt-5.6-sol", effort: "medium" }); + expect(isPresetDirty(reset, "high", hostCatalog)).toBe(false); }); it("resets incompatible assignments when the host changes", () => { - const changed = changeHost(createConfig("codex"), "claude-code"); + const changed = changeHost(applyPreset(createConfig("codex"), "high", hostCatalog), "claude-code"); expect(changed.assignments.orchestrator).toEqual({ model: "opus", effort: "high" }); - expect(changed.roles).toHaveLength(4); + expect(changed.roles).toHaveLength(3); + expect(changed.usagePolicy).toBe("balanced"); + expect(isPresetDirty(changed, "balanced", hostCatalog)).toBe(false); + }); + + it("keeps the selected child roster while applying Balanced host assignments", () => { + const high = setRoles(applyPreset(createConfig("codex"), "high", hostCatalog), ["reviewer", "verifier"]); + const changed = changeHost(high, "cursor"); + expect(changed.roles).toEqual(["reviewer", "verifier"]); + expect(changed.usagePolicy).toBe("balanced"); + expect(changed.assignments.reviewer).toEqual({ model: "gpt-5.6-sol", effort: "high" }); + expect(isPresetDirty(changed, "balanced", hostCatalog)).toBe(true); }); it("derives host models from the generated setup contract", () => { @@ -102,14 +168,22 @@ describe("Switchloom generator", () => { }); it("uses the complete current Codex GPT-5.6 effort manifest", () => { - expect(hostCatalog.codex.models.find((model) => model.id === "gpt-5.6-sol")?.label).toBe("Sol (certified)"); - expect(hostCatalog.codex.models.find((model) => model.id === "gpt-5.6-terra")?.label).toBe("Terra (certified)"); - expect(hostCatalog.codex.models.find((model) => model.id === "gpt-5.6-luna")?.label).toBe("Luna (experimental)"); + expect(hostCatalog.codex.models.find((model) => model.id === "gpt-5.6-sol")?.label).toBe("Sol"); + expect(hostCatalog.codex.models.find((model) => model.id === "gpt-5.6-terra")?.label).toBe("Terra"); + expect(hostCatalog.codex.models.find((model) => model.id === "gpt-5.6-luna")?.label).toBe("Luna"); + expect(hostCatalog.codex.models.find((model) => model.id === "gpt-5.6-luna")?.disabledReason) + .toBe("not supported yet in v2"); expect(hostCatalog.cursor.models.find((model) => model.id === "gpt-5.6-luna")?.label).toBe("Luna"); + expect(hostCatalog.cursor.models.find((model) => model.id === "gpt-5.6-luna")?.disabledReason).toBeUndefined(); expect(hostCatalog.codex.models.find((model) => model.id === "gpt-5.6-sol")?.efforts).toEqual([ - "low", "medium", "high", "xhigh", "ultra", + "low", "medium", "high", "xhigh", "max", "ultra", + ]); + expect(hostCatalog.codex.models.find((model) => model.id === "gpt-5.6-terra")?.efforts).toEqual([ + "low", "medium", "high", "xhigh", "max", "ultra", + ]); + expect(hostCatalog.codex.models.find((model) => model.id === "gpt-5.6-luna")?.efforts).toEqual([ + "low", "medium", "high", "xhigh", "max", ]); - expect(hostCatalog.codex.models.flatMap((model) => model.efforts)).not.toContain("max"); expect(hostCatalog.codex.models.find((model) => model.id === "gpt-5.6-luna")?.efforts).not.toContain("ultra"); }); @@ -141,7 +215,7 @@ describe("Switchloom generator", () => { }); }); - it("keeps Light genuinely low and Ultra out of every preset", () => { + it("keeps Codex Light genuinely low and Max or Ultra out of Codex presets", () => { const codexLight = applyPreset(createConfig("codex"), "light", hostCatalog); expect(Object.values(codexLight.assignments).map((assignment) => assignment.effort)).toEqual([ "low", "low", "low", "low", @@ -155,6 +229,10 @@ describe("Switchloom generator", () => { .not.toContainEqual(expect.objectContaining({ effort: "ultra" })); } } + for (const preset of ["light", "balanced", "high"] as const) { + expect(Object.values(applyPreset(createConfig("codex"), preset, hostCatalog).assignments)) + .not.toContainEqual(expect.objectContaining({ effort: "max" })); + } }); it("keeps Luna out of every certified Codex preset", () => { @@ -172,22 +250,59 @@ describe("Switchloom generator", () => { { model: "gpt-5.6-sol", effort: "medium" }, { model: "gpt-5.6-sol", effort: "medium" }, ]); + expect(PRESETS.high.short).toBe("Prioritizes the strongest model and a larger execution budget."); }); - it("allows Ultra only as an explicit Codex role choice", () => { + it("keeps explicit Codex Orchestrator choices parent-only", () => { const configured = setEffort(createConfig("codex"), "orchestrator", "ultra", hostCatalog); expect(configured.assignments.orchestrator.effort).toBe("ultra"); - expect(setupSpec(configured, hostCatalog).selected_roles.orchestrator.effort).toBe("ultra"); + expect(setupSpec(configured, hostCatalog).selected_roles.orchestrator).toBeUndefined(); }); - it("writes a selected Cursor frontier model into the generated agent", () => { + it("derives parent-card effort copy from each Codex preset without recommending a parent model", () => { + expect(parentRecommendationEffortCopy(applyPreset(createConfig("codex"), "light", hostCatalog), "light")) + .toBe("Set Codex reasoning to Low."); + expect(parentRecommendationEffortCopy(applyPreset(createConfig("codex"), "balanced", hostCatalog), "balanced")) + .toBe("Set Codex reasoning to Medium."); + expect(parentRecommendationEffortCopy(applyPreset(createConfig("codex"), "high", hostCatalog), "high")) + .toBe("Set Codex reasoning to Medium."); + }); + + it("summarizes native parents separately from generated child roles", () => { + const nativeSummary = setupSummary(setRoles(createConfig("codex"), ["reviewer"])); + expect(nativeSummary).toContain("1 generated child role"); + expect(nativeSummary).toContain("Host-managed parent: Orchestrator"); + expect(nativeSummary).toContain("Reviewer:"); + expect(nativeSummary).not.toContain("\nOrchestrator:"); + + const externalSummary = setupSummary(setRoles(createConfig("pi"), ["reviewer"])); + expect(externalSummary).toContain("2 focused roles"); + expect(externalSummary).not.toContain("Host-managed parent"); + expect(externalSummary).toContain("\nOrchestrator: openai/gpt-4o-mini · medium"); + }); + + it("summarizes native parent ownership without recommending a parent model", () => { + const summary = setupSummary(setRoles(createConfig("cursor"), ["implementer"])); + expect(summary).toContain("Host-managed parent: Orchestrator (high)"); + expect(summary).toContain("Implementer: composer-2.5"); + expect(summary).not.toContain("fable-5"); + }); + + it("gates the parent-card ownership treatment away from Pi external-runner configs", () => { + expect(canRenderParentRecommendation(createConfig("codex"))).toBe(true); + expect(canRenderParentRecommendation(createConfig("pi"))).toBe(false); + expect(parentRecommendationEffortCopy(applyPreset(createConfig("pi"), "balanced", hostCatalog), "balanced")) + .toBe("Set Pi thinking to Medium."); + }); + + it("does not write a selected Cursor parent model into generated agents", () => { const config = setModel(createConfig("cursor"), "orchestrator", "grok-4.5", hostCatalog); - expect(setupSpec(setRoles(config, ["orchestrator"]), hostCatalog).selected_roles.orchestrator.model).toBe("grok-4.5"); + expect(setupSpec(setRoles(config, ["reviewer"]), hostCatalog).selected_roles).not.toHaveProperty("orchestrator"); }); it("writes selected Cursor reasoning effort into the generated agent", () => { const config = setEffort(createConfig("cursor"), "reviewer", "max", hostCatalog); - expect(setupSpec(setRoles(config, ["orchestrator", "reviewer"]), hostCatalog).selected_roles.reviewer.effort).toBe("max"); + expect(setupSpec(setRoles(config, ["reviewer"]), hostCatalog).selected_roles.reviewer.effort).toBe("max"); }); it("moves effort to a supported value when the model changes", () => { @@ -202,8 +317,8 @@ describe("Switchloom generator", () => { expect(applyPreset(createConfig("codex"), "high", hostCatalog).usagePolicy).toBe("max-quality"); }); - it("serializes only SetupSpecV1 with Codex spawn identities", () => { - const config = setRoles(createConfig("codex"), ["orchestrator", "reviewer"]); + it("serializes only child SetupSpecV1 roles with Codex spawn identities", () => { + const config = setRoles(createConfig("codex"), ["reviewer"]); const spec = setupSpec(config, hostCatalog); expect(spec).toEqual({ schema_version: 1, @@ -211,20 +326,40 @@ describe("Switchloom generator", () => { integration: "standalone", usage_policy: "balanced", selected_roles: { - orchestrator: { + reviewer: { model: "gpt-5.6-sol", + effort: "high", + spawn: { agent_type: "switchloom_reviewer", task_name: "reviewer", fork_turns: { mode: "none" } }, + }, + }, + routes: [ + { work_type: "code", role: "reviewer", fallbacks: [] }, + { work_type: "review", role: "reviewer", fallbacks: [] }, + { work_type: "verification", role: "reviewer", fallbacks: [] }, + ], + }); + }); + + it("preserves Pi external-runner orchestrator routing in SetupSpecV1", () => { + const spec = setupSpec(setRoles(createConfig("pi"), ["reviewer"]), hostCatalog); + expect(spec).toEqual({ + schema_version: 1, + host: "pi-external", + integration: "standalone", + usage_policy: "balanced", + selected_roles: { + orchestrator: { + model: "openai/gpt-4o-mini", effort: "medium", - spawn: { agent_type: "switchloom_orchestrator", task_name: "orchestrator", fork_turns: { mode: "none" } }, }, reviewer: { - model: "gpt-5.6-sol", + model: "anthropic/claude-sonnet-4-5", effort: "high", - spawn: { agent_type: "switchloom_reviewer", task_name: "reviewer", fork_turns: { mode: "none" } }, }, }, routes: [ { work_type: "planning", role: "orchestrator", fallbacks: [] }, - { work_type: "code", role: "orchestrator", fallbacks: [] }, + { work_type: "code", role: "reviewer", fallbacks: [] }, { work_type: "review", role: "reviewer", fallbacks: [] }, { work_type: "verification", role: "reviewer", fallbacks: [] }, ], @@ -232,16 +367,49 @@ describe("Switchloom generator", () => { }); }); + it("writes Pi external-runner orchestrator routing to setup TOML", () => { + const toml = setupConfigToml(setRoles(createConfig("pi"), ["reviewer"]), hostCatalog); + expect(toml).toContain("[route_default]"); + expect(toml).toContain('role = "orchestrator"'); + expect(toml).toContain("[selected_roles.orchestrator]"); + expect(toml).toContain('model = "openai/gpt-4o-mini"'); + expect(toml).toContain("[selected_roles.reviewer]"); + }); + + it("keeps deterministic review fallback on verifier when reviewer is removed", () => { + const spec = setupSpec(setRoles(createConfig("codex"), ["implementer", "verifier"]), hostCatalog); + expect(spec.selected_roles).toHaveProperty("implementer"); + expect(spec.selected_roles).toHaveProperty("verifier"); + expect(spec.selected_roles).not.toHaveProperty("reviewer"); + expect(spec.routes).toEqual([ + { work_type: "code", role: "implementer", fallbacks: [] }, + { work_type: "review", role: "verifier", fallbacks: [] }, + { work_type: "verification", role: "verifier", fallbacks: [] }, + ]); + }); + + it("keeps deterministic verification fallback on reviewer when verifier is removed", () => { + const spec = setupSpec(setRoles(createConfig("codex"), ["implementer", "reviewer"]), hostCatalog); + expect(spec.selected_roles).toHaveProperty("implementer"); + expect(spec.selected_roles).toHaveProperty("reviewer"); + expect(spec.selected_roles).not.toHaveProperty("verifier"); + expect(spec.routes).toEqual([ + { work_type: "code", role: "implementer", fallbacks: [] }, + { work_type: "review", role: "reviewer", fallbacks: [] }, + { work_type: "verification", role: "reviewer", fallbacks: [] }, + ]); + }); + it("serializes Planr as an explicit setup mode before role tuning", () => { const spec = setupSpec(setIntegration(createConfig("claude-code"), "planr"), hostCatalog); expect(spec.integration).toBe("planr"); expect(spec.host).toBe("claude-native"); - expect(spec.selected_roles.orchestrator).not.toHaveProperty("spawn"); + expect(spec.selected_roles.orchestrator).toBeUndefined(); }); it("creates a shell-safe npx recipe command and readable setup TOML", () => { const transport = setupTransportFrom(generatedCatalog); - const config = setRoles(createConfig("codex"), ["orchestrator", "implementer"]); + const config = setRoles(createConfig("codex"), ["implementer"]); const recipe = setupRecipe(config, hostCatalog, transport.recipePrefix); expect(recipe).toMatch(/^sw1_[A-Za-z0-9_-]+$/); const command = recipeApplyCommand(config, hostCatalog, transport.recipePrefix); @@ -250,13 +418,16 @@ describe("Switchloom generator", () => { expect(toml).toContain('host = "codex-openai"'); expect(toml).toContain('integration = "standalone"'); expect(toml).toContain("[selected_roles.implementer.spawn]"); + expect(toml).not.toContain("[route_default]"); + expect(toml).not.toContain("[selected_roles.orchestrator]"); + expect(toml).not.toContain("switchloom_orchestrator"); expect(toml).not.toContain(".codex/agents"); expect(toml).not.toContain("switchloom.config.json"); }); it("shows the full CLI lifecycle without claiming custom setup verification", () => { const commands = lifecycleCommands(createConfig("cursor"), hostCatalog); - expect(commands).toEqual([ + expect(commands.map((entry) => entry.command)).toEqual([ "npm install -g switchloom@0.3.1", expect.stringMatching(/^switchloom preview --recipe 'sw1_/), expect.stringMatching(/^switchloom apply --recipe 'sw1_/), @@ -266,6 +437,7 @@ describe("Switchloom generator", () => { "switchloom rollback --repository .", "switchloom uninstall --repository .", ]); - expect(commands.join("\n")).not.toMatch(/recommend/i); + expect(commands.every((entry) => entry.title && entry.description)).toBe(true); + expect(commands.map((entry) => entry.command).join("\n")).not.toMatch(/recommend/i); }); }); diff --git a/website/src/lib/generator.ts b/website/src/lib/generator.ts index 5ff76b4..c738bd7 100644 --- a/website/src/lib/generator.ts +++ b/website/src/lib/generator.ts @@ -1,8 +1,12 @@ -export const ROLE_IDS = ["orchestrator", "implementer", "reviewer", "verifier"] as const; +export const PRIMARY_RECOMMENDATION_ID = "orchestrator" as const; +export const CHILD_ROLE_IDS = ["implementer", "reviewer", "verifier"] as const; +export const ROLE_IDS = [PRIMARY_RECOMMENDATION_ID, ...CHILD_ROLE_IDS] as const; export const HOST_IDS = ["codex", "cursor", "claude-code", "opencode", "pi"] as const; export const PRESET_IDS = ["light", "balanced", "high"] as const; -const SWITCHLOOM_VERSION = "0.3.1"; +export const SWITCHLOOM_VERSION = "0.3.1"; +export type PrimaryRecommendationId = typeof PRIMARY_RECOMMENDATION_ID; +export type ChildRoleId = (typeof CHILD_ROLE_IDS)[number]; export type RoleId = (typeof ROLE_IDS)[number]; export type HostId = (typeof HOST_IDS)[number]; export type PresetId = (typeof PRESET_IDS)[number]; @@ -14,6 +18,7 @@ export type ModelOption = { provider?: string; efforts: readonly string[]; tier: "standard" | "premium"; + disabledReason?: string; }; export type RoleAssignment = { model: string; effort?: string }; @@ -21,10 +26,17 @@ export type GeneratorConfig = { host: HostId; integration: SetupIntegration; usagePolicy: string; - roles: RoleId[]; + roles: ChildRoleId[]; assignments: Record; }; +export type PresetSelection = { + selected: PresetId | "custom"; + lastSelected: PresetId; +}; export type HostCatalog = Record; +export type HostCapabilities = { + nativeParentRecommendation: boolean; +}; export const ROLES: Record = { orchestrator: { @@ -58,6 +70,7 @@ export const HOSTS: Record; }> = { codex: { @@ -65,6 +78,7 @@ export const HOSTS: Record = { light: { label: "Light", short: "Prioritizes lower-cost models and effort." }, balanced: { label: "Balanced", short: "A practical quality and cost mix for daily work." }, - high: { label: "High", short: "Prioritizes stronger models and deeper reasoning." }, + high: { label: "High", short: "Prioritizes the strongest model and a larger execution budget." }, }; const PRESET_USAGE_POLICIES: Record = { @@ -212,7 +230,7 @@ const PRESET_ASSIGNMENTS: Record = { "gpt-5.6-sol": "Sol", "gpt-5.6-terra": "Terra", @@ -231,14 +249,7 @@ function modelLabel(host: HostId, model: string) { opus: "Opus", sonnet: "Sonnet", }; - const label = labels[model] ?? model; - if (host === "codex" && (model === "gpt-5.6-sol" || model === "gpt-5.6-terra")) { - return `${label} (certified)`; - } - if (host === "codex" && model === "gpt-5.6-luna") { - return `${label} (experimental)`; - } - return label; + return labels[model] ?? model; } function modelProvider(model: string) { @@ -266,8 +277,9 @@ type CatalogShape = { export function hostCatalogFrom(catalog: CatalogShape): HostCatalog { const result = {} as HostCatalog; + const setupHosts = new Map(catalog.setupContract?.hosts?.map((entry) => [entry.id, entry]) ?? []); for (const host of HOST_IDS) { - const setupHost = catalog.setupContract?.hosts?.find((entry) => entry.id === host); + const setupHost = setupHosts.get(host); if (!setupHost?.binding) throw new Error(`canonical setup contract has no ${host} binding`); const models = setupHost.models?.map((model) => { if (!model.id || !Array.isArray(model.efforts) || (model.tier !== "standard" && model.tier !== "premium")) { @@ -276,20 +288,25 @@ export function hostCatalogFrom(catalog: CatalogShape): HostCatalog { const tier: ModelOption["tier"] = model.tier === "premium" ? "premium" : "standard"; return { id: model.id, - label: modelLabel(host, model.id), + label: modelLabel(model.id), provider: modelProvider(model.id), efforts: model.efforts, tier, + ...(host === "codex" && model.id === "gpt-5.6-luna" + ? { disabledReason: "not supported yet in v2" } + : {}), }; }) ?? []; result[host] = { binding: setupHost.binding, models }; } for (const host of HOST_IDS) { if (result[host].models.length === 0) throw new Error(`canonical setup contract has no ${host} model profiles`); + const modelById = new Map(result[host].models.map((model) => [model.id, model])); for (const [role, assignment] of Object.entries(HOSTS[host].defaults)) { - const model = result[host].models.find((candidate) => candidate.id === assignment.model); + const model = modelById.get(assignment.model); if (!model) throw new Error(`canonical setup contract has no ${host} default model for ${role}: ${assignment.model}`); - if (assignment.effort && !model.efforts.includes(assignment.effort)) { + const modelEfforts = new Set(model.efforts); + if (assignment.effort && !modelEfforts.has(assignment.effort)) { throw new Error(`canonical setup contract has no ${host} default effort for ${role}: ${assignment.effort}`); } } @@ -309,13 +326,74 @@ export function createConfig(host: HostId = "codex"): GeneratorConfig { host, integration: "standalone", usagePolicy: "balanced", - roles: [...ROLE_IDS], + roles: [...CHILD_ROLE_IDS], assignments: structuredClone(HOSTS[host].defaults), }; } +export function isPrimaryRecommendationId(role: string): role is PrimaryRecommendationId { + return role === PRIMARY_RECOMMENDATION_ID; +} + +export function isChildRoleId(role: string): role is ChildRoleId { + return CHILD_ROLE_IDS.includes(role as ChildRoleId); +} + +export function primaryRecommendation(config: GeneratorConfig) { + return { + id: PRIMARY_RECOMMENDATION_ID, + assignment: config.assignments[PRIMARY_RECOMMENDATION_ID], + }; +} + +export function canRenderParentRecommendation(config: GeneratorConfig) { + return HOSTS[config.host].capabilities.nativeParentRecommendation; +} + +function effortDisplayName(effort: string) { + const labels: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "XHigh", + max: "Max", + ultra: "Ultra", + }; + return labels[effort] ?? effort; +} + +export function parentRecommendationEffortCopy(config: GeneratorConfig, preset: PresetId) { + const host = HOSTS[config.host]; + const recommendation = primaryRecommendation(config); + const effortLabel = host.effortLabel?.toLowerCase(); + if (!recommendation.assignment.effort || !effortLabel) { + return `${PRESETS[preset].label} keeps ${host.label} parent orchestration host-managed.`; + } + return `Set ${host.label} ${effortLabel} to ${effortDisplayName(recommendation.assignment.effort)}.`; +} + +export function selectedChildRoleIds(config: GeneratorConfig): ChildRoleId[] { + return [...config.roles]; +} + +export function setupSpecRoleIds(config: GeneratorConfig): RoleId[] { + return canRenderParentRecommendation(config) ? selectedChildRoleIds(config) : [PRIMARY_RECOMMENDATION_ID, ...config.roles]; +} + +export function createPresetSelection(initial: PresetId = "balanced"): PresetSelection { + return { selected: initial, lastSelected: initial }; +} + +export function choosePreset(selection: PresetSelection, preset: PresetId): PresetSelection { + return { ...selection, selected: preset, lastSelected: preset }; +} + +export function markPresetCustom(selection: PresetSelection): PresetSelection { + return { ...selection, selected: "custom" }; +} + export function changeHost(config: GeneratorConfig, host: HostId): GeneratorConfig { - return { ...createConfig(host), integration: config.integration, usagePolicy: config.usagePolicy, roles: [...config.roles] }; + return { ...createConfig(host), integration: config.integration, roles: [...config.roles] }; } export function setIntegration(config: GeneratorConfig, integration: SetupIntegration): GeneratorConfig { @@ -324,10 +402,12 @@ export function setIntegration(config: GeneratorConfig, integration: SetupIntegr export function applyPreset(config: GeneratorConfig, preset: PresetId, catalog: HostCatalog): GeneratorConfig { const assignments = structuredClone(PRESET_ASSIGNMENTS[config.host][preset]); + const modelById = new Map(catalog[config.host].models.map((model) => [model.id, model])); for (const [role, assignment] of Object.entries(assignments)) { - const model = catalog[config.host].models.find((candidate) => candidate.id === assignment.model); + const model = modelById.get(assignment.model); if (!model) throw new Error(`${preset} preset has no ${config.host} model for ${role}: ${assignment.model}`); - if (assignment.effort && !model.efforts.includes(assignment.effort)) { + const modelEfforts = new Set(model.efforts); + if (assignment.effort && !modelEfforts.has(assignment.effort)) { throw new Error(`${preset} preset has no ${config.host} effort for ${role}: ${assignment.effort}`); } } @@ -335,8 +415,36 @@ export function applyPreset(config: GeneratorConfig, preset: PresetId, catalog: } export function setRoles(config: GeneratorConfig, roles: readonly string[]): GeneratorConfig { - const valid = ROLE_IDS.filter((role) => role === "orchestrator" || roles.includes(role)); - return { ...config, roles: valid.slice(0, 4) }; + const selected = new Set(roles); + const valid = CHILD_ROLE_IDS.filter((role) => selected.has(role)); + return { ...config, roles: valid.length > 0 ? valid : config.roles }; +} + +export function removeChildRole(config: GeneratorConfig, role: ChildRoleId): GeneratorConfig { + const childRoles = selectedChildRoleIds(config); + if (childRoles.length <= 1 || !childRoles.includes(role)) return config; + return setRoles(config, childRoles.filter((candidate) => candidate !== role)); +} + +export function resetRolesToPreset(config: GeneratorConfig, preset: PresetId, catalog: HostCatalog): GeneratorConfig { + return applyPreset(setRoles(config, CHILD_ROLE_IDS), preset, catalog); +} + +function roleAssignmentEquals(left: RoleAssignment, right: RoleAssignment) { + return left.model === right.model && left.effort === right.effort; +} + +function roleListEquals(left: readonly ChildRoleId[], right: readonly ChildRoleId[]) { + return left.length === right.length && left.every((role, index) => role === right[index]); +} + +export function isPresetDirty(config: GeneratorConfig, preset: PresetId, catalog: HostCatalog): boolean { + const baseline = resetRolesToPreset(config, preset, catalog); + return ( + config.usagePolicy !== baseline.usagePolicy || + !roleListEquals(config.roles, baseline.roles) || + ROLE_IDS.some((role) => !roleAssignmentEquals(config.assignments[role], baseline.assignments[role])) + ); } export function setModel(config: GeneratorConfig, role: RoleId, modelId: string, catalog: HostCatalog): GeneratorConfig { @@ -373,16 +481,22 @@ export type SetupSpecV1 = { }; }>; routes: Array<{ work_type: string; role: string; fallbacks: string[] }>; - route_default: { role: string; fallbacks: string[] }; + route_default?: { role: string; fallbacks: string[] }; }; -function routingRole(config: GeneratorConfig, preferred: RoleId, fallback: RoleId = "orchestrator") { - return config.roles.includes(preferred) ? preferred : fallback; +function routingRole(config: GeneratorConfig, preferred: ChildRoleId, fallbacks: readonly ChildRoleId[] = []) { + const selectedRoles = selectedChildRoleIds(config); + const selected = new Set(selectedRoles); + for (const role of [preferred, ...fallbacks]) { + if (selected.has(role)) return role; + } + return selectedRoles[0]; } export function setupSpec(config: GeneratorConfig, catalog: HostCatalog): SetupSpecV1 { + const hasNativeParentRecommendation = canRenderParentRecommendation(config); const selected_roles: SetupSpecV1["selected_roles"] = {}; - for (const role of config.roles) { + for (const role of setupSpecRoleIds(config)) { const assignment = config.assignments[role]; selected_roles[role] = { model: assignment.model, @@ -398,7 +512,26 @@ export function setupSpec(config: GeneratorConfig, catalog: HostCatalog): SetupS : {}), }; } - const reviewFallback = routingRole(config, "reviewer"); + if (!hasNativeParentRecommendation) { + const reviewFallback = routingRole(config, "reviewer"); + return { + schema_version: 1, + host: catalog[config.host].binding, + integration: config.integration, + usage_policy: config.usagePolicy, + selected_roles, + routes: [ + { work_type: "planning", role: PRIMARY_RECOMMENDATION_ID, fallbacks: [] }, + { work_type: "code", role: routingRole(config, "implementer"), fallbacks: [] }, + { work_type: "review", role: reviewFallback, fallbacks: [] }, + { work_type: "verification", role: routingRole(config, "verifier", [reviewFallback]), fallbacks: [] }, + ], + route_default: { role: PRIMARY_RECOMMENDATION_ID, fallbacks: [] }, + }; + } + const codeRole = routingRole(config, "implementer", ["reviewer", "verifier"]); + const reviewRole = routingRole(config, "reviewer", ["verifier", "implementer"]); + const verificationRole = routingRole(config, "verifier", ["reviewer", "implementer"]); return { schema_version: 1, host: catalog[config.host].binding, @@ -406,12 +539,10 @@ export function setupSpec(config: GeneratorConfig, catalog: HostCatalog): SetupS usage_policy: config.usagePolicy, selected_roles, routes: [ - { work_type: "planning", role: "orchestrator", fallbacks: [] }, - { work_type: "code", role: routingRole(config, "implementer"), fallbacks: [] }, - { work_type: "review", role: reviewFallback, fallbacks: [] }, - { work_type: "verification", role: routingRole(config, "verifier", reviewFallback), fallbacks: [] }, + { work_type: "code", role: codeRole, fallbacks: [] }, + { work_type: "review", role: reviewRole, fallbacks: [] }, + { work_type: "verification", role: verificationRole, fallbacks: [] }, ], - route_default: { role: "orchestrator", fallbacks: [] }, }; } @@ -449,8 +580,10 @@ export function setupConfigToml(config: GeneratorConfig, catalog: HostCatalog) { for (const route of spec.routes) { lines.push("[[routes]]", `work_type = ${tomlString(route.work_type)}`, `role = ${tomlString(route.role)}`, `fallbacks = ${tomlArray(route.fallbacks)}`, ""); } - lines.push("[route_default]", `role = ${tomlString(spec.route_default.role)}`, `fallbacks = ${tomlArray(spec.route_default.fallbacks)}`, ""); - for (const role of config.roles) { + if (spec.route_default) { + lines.push("[route_default]", `role = ${tomlString(spec.route_default.role)}`, `fallbacks = ${tomlArray(spec.route_default.fallbacks)}`, ""); + } + for (const role of setupSpecRoleIds(config)) { const selection = spec.selected_roles[role]; lines.push(`[selected_roles.${role}]`, `model = ${tomlString(selection.model)}`); if (selection.effort) lines.push(`effort = ${tomlString(selection.effort)}`); @@ -478,25 +611,79 @@ export function recipeApplyCommand(config: GeneratorConfig, catalog: HostCatalog return `npx switchloom@${SWITCHLOOM_VERSION} apply --recipe ${shellQuote(setupRecipe(config, catalog, recipePrefix))} --repository .`; } -export function lifecycleCommands(config: GeneratorConfig, catalog: HostCatalog, recipePrefix = "sw1_") { +export type LifecycleCommand = { + id: string; + title: string; + description: string; + command: string; +}; + +export function lifecycleCommands(config: GeneratorConfig, catalog: HostCatalog, recipePrefix = "sw1_"): LifecycleCommand[] { const recipe = shellQuote(setupRecipe(config, catalog, recipePrefix)); const host = config.host; return [ - `npm install -g switchloom@${SWITCHLOOM_VERSION}`, - `switchloom preview --recipe ${recipe} --repository .`, - `switchloom apply --recipe ${recipe} --repository .`, - `switchloom doctor ${host}`, - "switchloom update --repository .", - "switchloom status --repository .", - "switchloom rollback --repository .", - "switchloom uninstall --repository .", + { + id: "install", + title: "Install", + description: "Install the Switchloom CLI globally.", + command: `npm install -g switchloom@${SWITCHLOOM_VERSION}`, + }, + { + id: "preview", + title: "Preview", + description: "Dry-run the recipe and list files that would be written.", + command: `switchloom preview --recipe ${recipe} --repository .`, + }, + { + id: "apply", + title: "Apply", + description: "Write the team setup into this repository.", + command: `switchloom apply --recipe ${recipe} --repository .`, + }, + { + id: "doctor", + title: "Doctor", + description: "Check that the host setup looks healthy.", + command: `switchloom doctor ${host}`, + }, + { + id: "update", + title: "Update", + description: "Refresh an existing Switchloom install in place.", + command: "switchloom update --repository .", + }, + { + id: "status", + title: "Status", + description: "Show the current repository install state.", + command: "switchloom status --repository .", + }, + { + id: "rollback", + title: "Rollback", + description: "Restore the previous Switchloom snapshot.", + command: "switchloom rollback --repository .", + }, + { + id: "uninstall", + title: "Uninstall", + description: "Remove Switchloom files from the repository.", + command: "switchloom uninstall --repository .", + }, ]; } export function setupSummary(config: GeneratorConfig) { + const summaryRoles = setupSpecRoleIds(config); return [ `${HOSTS[config.host].label} ${config.integration === "planr" ? "with Planr" : "standalone"} team`, - ...config.roles.map((role) => { + ...(canRenderParentRecommendation(config) + ? [`${config.roles.length} generated child ${config.roles.length === 1 ? "role" : "roles"}`] + : [`${summaryRoles.length} focused ${summaryRoles.length === 1 ? "role" : "roles"}`]), + ...(canRenderParentRecommendation(config) + ? [`Host-managed parent: ${ROLES.orchestrator.label}${config.assignments.orchestrator.effort ? ` (${config.assignments.orchestrator.effort})` : ""}`] + : []), + ...summaryRoles.map((role) => { const value = config.assignments[role]; return `${ROLES[role].label}: ${value.model}${value.effort ? ` · ${value.effort}` : ""}`; }), diff --git a/website/src/lib/model-family.test.ts b/website/src/lib/model-family.test.ts new file mode 100644 index 0000000..77b2143 --- /dev/null +++ b/website/src/lib/model-family.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { effortHint, formatEffortLabel, modelFamilyInfo } from "./model-family"; + +describe("model family metadata", () => { + it("maps Sol/Terra/Luna to increasing celestial strength", () => { + expect(modelFamilyInfo("gpt-5.6-luna")?.strength).toBe(1); + expect(modelFamilyInfo("gpt-5.6-terra")?.strength).toBe(2); + expect(modelFamilyInfo("gpt-5.6-sol")?.strength).toBe(3); + expect(modelFamilyInfo("gpt-5.6-sol")?.short.length).toBeGreaterThan(20); + }); + + it("formats effort labels for the strength picker", () => { + expect(formatEffortLabel("xhigh")).toBe("Extra High"); + expect(formatEffortLabel("ultra")).toBe("Ultra"); + expect(effortHint("ultra")).toMatch(/multi-agent/i); + expect(effortHint("medium")).toMatch(/balanced/i); + }); +}); diff --git a/website/src/lib/model-family.ts b/website/src/lib/model-family.ts new file mode 100644 index 0000000..6683504 --- /dev/null +++ b/website/src/lib/model-family.ts @@ -0,0 +1,56 @@ +export type ModelFamilyId = "sol" | "terra" | "luna"; + +export type ModelFamilyInfo = { + id: ModelFamilyId; + /** Relative capability mass: Luna lightest, Sol heaviest. */ + strength: 1 | 2 | 3; + short: string; +}; + +const MODEL_FAMILY_BY_ID: Record = { + "gpt-5.6-sol": { + id: "sol", + strength: 3, + short: "For complex, open-ended work where deeper investigation and polish matter.", + }, + "gpt-5.6-terra": { + id: "terra", + strength: 2, + short: "Pragmatic all-rounder for everyday implementation and multi-step work.", + }, + "gpt-5.6-luna": { + id: "luna", + strength: 1, + short: "Fast option for clear, well-scoped work and high-volume workflows.", + }, +}; + +export function modelFamilyInfo(modelId: string): ModelFamilyInfo | undefined { + return MODEL_FAMILY_BY_ID[modelId]; +} + +const EFFORT_LABELS: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + ultra: "Ultra", + max: "Max", +}; + +export function formatEffortLabel(effort: string) { + return EFFORT_LABELS[effort] ?? effort; +} + +const EFFORT_HINTS: Record = { + low: "Faster replies with lighter reasoning.", + medium: "Balanced depth for most day-to-day work.", + high: "Deeper reasoning when quality matters more.", + xhigh: "Extra depth for harder, multi-step problems.", + ultra: "Max depth plus multi-agent help. Costs more.", + max: "Maximum available reasoning for this model.", +}; + +export function effortHint(effort: string): string | undefined { + return EFFORT_HINTS[effort]; +} diff --git a/website/src/lib/onboarding.test.ts b/website/src/lib/onboarding.test.ts new file mode 100644 index 0000000..62f2748 --- /dev/null +++ b/website/src/lib/onboarding.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { HOST_IDS } from "./generator"; +import { PROVIDER_ONBOARDING, providerOnboarding } from "./onboarding"; + +describe("provider onboarding templates", () => { + it("defines one complete provider-owned template for every runtime", () => { + expect(Object.keys(PROVIDER_ONBOARDING).sort()).toEqual([...HOST_IDS].sort()); + expect(new Set(Object.values(PROVIDER_ONBOARDING).map((template) => template.title)).size).toBe(HOST_IDS.length); + + for (const host of HOST_IDS) { + const template = PROVIDER_ONBOARDING[host]; + expect(template.host).toBe(host); + expect(template.icon).toMatch(/^\/brand\/[a-z-]+\.svg$/); + expect(template.steps.map((step) => step.id)).toEqual(["requirements", "project", "install", "activate"]); + expect(template.steps.every((step) => step.title.length > 0 && step.description.length > 0)).toBe(true); + expect(template.steps.filter((step) => step.command?.kind === "apply")).toHaveLength(1); + } + }); + + it("injects the exact generated recipe command without mutating provider copy", () => { + const command = "npx switchloom@0.3.1 apply --recipe 'sw1_example' --repository ."; + const onboarding = providerOnboarding("codex", command); + + expect(onboarding.steps.find((step) => step.id === "install")?.command).toBe(command); + expect(onboarding.description).toContain("native V2 child roles"); + expect(onboarding.description).toContain("parent orchestrator"); + expect(onboarding.steps.find((step) => step.id === "project")?.description).toContain("Codex child-role files"); + expect(onboarding.steps.find((step) => step.id === "project")?.description).toContain("does not touch your global Codex settings"); + expect(PROVIDER_ONBOARDING.codex.steps.find((step) => step.id === "install")?.command).toEqual({ kind: "apply" }); + }); +}); diff --git a/website/src/lib/onboarding/claude-code.ts b/website/src/lib/onboarding/claude-code.ts new file mode 100644 index 0000000..e0a2032 --- /dev/null +++ b/website/src/lib/onboarding/claude-code.ts @@ -0,0 +1,34 @@ +import type { ProviderOnboardingTemplate } from "./types"; + +export const claudeCodeOnboarding = { + host: "claude-code", + icon: "/brand/claude.svg", + title: "Set up your Claude Code agent team", + description: "Switchloom installs Claude Code project child subagents. Claude Code remains the parent runtime.", + status: "experimental", + steps: [ + { + id: "requirements", + title: "Check Claude Code", + description: "Use a current Claude Code CLI with project subagent support.", + command: { kind: "literal", value: "claude --version" }, + }, + { + id: "project", + title: "Keep agents in the repository", + description: "cd into your project first. Switchloom sets up the Claude child-agent files there and does not touch your global Claude settings.", + }, + { + id: "install", + title: "Apply the team from your project", + description: "Copy the command and run it in a terminal from that project directory.", + command: { kind: "apply" }, + }, + { + id: "activate", + title: "Restart Claude Code and verify", + description: "Start a fresh Claude Code session so project subagents are rediscovered, then run doctor before relying on the setup.", + command: { kind: "doctor" }, + }, + ], +} satisfies ProviderOnboardingTemplate; diff --git a/website/src/lib/onboarding/codex.ts b/website/src/lib/onboarding/codex.ts new file mode 100644 index 0000000..01cdef9 --- /dev/null +++ b/website/src/lib/onboarding/codex.ts @@ -0,0 +1,34 @@ +import type { ProviderOnboardingTemplate } from "./types"; + +export const codexOnboarding = { + host: "codex", + icon: "/brand/codex.svg", + title: "Set up your Codex agent team", + description: "Switchloom installs native V2 child roles into this repository. Codex remains the parent orchestrator and permission authority.", + status: "certified", + steps: [ + { + id: "requirements", + title: "Check your Codex version", + description: "Use Codex 0.145.0 or newer.", + command: { kind: "literal", value: "codex --version" }, + }, + { + id: "project", + title: "Keep configuration project-local", + description: "cd into your project first. Switchloom sets up the Codex child-role files there and does not touch your global Codex settings.", + }, + { + id: "install", + title: "Apply the team from your project", + description: "Copy the command and run it in a terminal from that project directory.", + command: { kind: "apply" }, + }, + { + id: "activate", + title: "Trust, restart, and verify", + description: "Trust the repository if Codex asks, start a fresh Codex session, then run doctor. Ask Codex to delegate when you want a specific handoff.", + command: { kind: "doctor" }, + }, + ], +} satisfies ProviderOnboardingTemplate; diff --git a/website/src/lib/onboarding/cursor.ts b/website/src/lib/onboarding/cursor.ts new file mode 100644 index 0000000..fa3db83 --- /dev/null +++ b/website/src/lib/onboarding/cursor.ts @@ -0,0 +1,34 @@ +import type { ProviderOnboardingTemplate } from "./types"; + +export const cursorOnboarding = { + host: "cursor", + icon: "/brand/cursor.svg", + title: "Set up your Cursor agent team", + description: "Switchloom installs native Cursor child agents while Cursor remains the parent runtime and model-selection authority.", + status: "experimental", + steps: [ + { + id: "requirements", + title: "Check Cursor Agent", + description: "Use the current Cursor Agent CLI and confirm it launches in the target repository.", + command: { kind: "literal", value: "cursor-agent --version" }, + }, + { + id: "project", + title: "Use repository-native agents", + description: "cd into your project first. Switchloom sets up the Cursor child-agent files there and does not touch your global Cursor settings.", + }, + { + id: "install", + title: "Apply the team from your project", + description: "Copy the command and run it in a terminal from that project directory.", + command: { kind: "apply" }, + }, + { + id: "activate", + title: "Start a fresh session and verify", + description: "Restart Cursor Agent so it discovers the new project agents, then run doctor. Effective model claims remain advisory until Cursor exposes stronger receipts.", + command: { kind: "doctor" }, + }, + ], +} satisfies ProviderOnboardingTemplate; diff --git a/website/src/lib/onboarding/index.ts b/website/src/lib/onboarding/index.ts new file mode 100644 index 0000000..98d5e36 --- /dev/null +++ b/website/src/lib/onboarding/index.ts @@ -0,0 +1,48 @@ +import { type HostId } from "../generator"; + +import { claudeCodeOnboarding } from "./claude-code"; +import { codexOnboarding } from "./codex"; +import { cursorOnboarding } from "./cursor"; +import { opencodeOnboarding } from "./opencode"; +import { piOnboarding } from "./pi"; +import type { + OnboardingCommand, + ProviderOnboardingTemplate, + ResolvedOnboardingStep, + ResolvedProviderOnboarding, +} from "./types"; + +export const PROVIDER_ONBOARDING = { + codex: codexOnboarding, + cursor: cursorOnboarding, + "claude-code": claudeCodeOnboarding, + opencode: opencodeOnboarding, + pi: piOnboarding, +} satisfies Record; + +function resolveCommand(command: OnboardingCommand, host: HostId, applyCommand: string) { + switch (command.kind) { + case "apply": + return applyCommand; + case "doctor": + return `npx switchloom doctor ${host}`; + case "literal": + return command.value; + } +} + +export function providerOnboarding(host: HostId, applyCommand: string): ResolvedProviderOnboarding { + const template = PROVIDER_ONBOARDING[host]; + return { + ...template, + steps: template.steps.map((step): ResolvedOnboardingStep => { + const { command, ...content } = step; + return { + ...content, + ...(command ? { command: resolveCommand(command, host, applyCommand) } : {}), + }; + }), + }; +} + +export type { ProviderOnboardingTemplate, ResolvedProviderOnboarding } from "./types"; diff --git a/website/src/lib/onboarding/opencode.ts b/website/src/lib/onboarding/opencode.ts new file mode 100644 index 0000000..4c69482 --- /dev/null +++ b/website/src/lib/onboarding/opencode.ts @@ -0,0 +1,34 @@ +import type { ProviderOnboardingTemplate } from "./types"; + +export const opencodeOnboarding = { + host: "opencode", + icon: "/brand/opencode.svg", + title: "Set up your OpenCode agent team", + description: "Switchloom installs provider-qualified OpenCode child agents with explicit task permissions.", + status: "experimental", + steps: [ + { + id: "requirements", + title: "Check OpenCode", + description: "Use a current OpenCode CLI with project agent and Task support.", + command: { kind: "literal", value: "opencode --version" }, + }, + { + id: "project", + title: "Keep agents in the repository", + description: "cd into your project first. Switchloom sets up the OpenCode child-agent files there and does not touch your global OpenCode settings.", + }, + { + id: "install", + title: "Apply the team from your project", + description: "Copy the command and run it in a terminal from that project directory.", + command: { kind: "apply" }, + }, + { + id: "activate", + title: "Restart OpenCode and verify", + description: "Start a fresh OpenCode session so project agents are rediscovered, then run doctor before relying on the setup.", + command: { kind: "doctor" }, + }, + ], +} satisfies ProviderOnboardingTemplate; diff --git a/website/src/lib/onboarding/pi.ts b/website/src/lib/onboarding/pi.ts new file mode 100644 index 0000000..0d5c332 --- /dev/null +++ b/website/src/lib/onboarding/pi.ts @@ -0,0 +1,34 @@ +import type { ProviderOnboardingTemplate } from "./types"; + +export const piOnboarding = { + host: "pi", + icon: "/brand/pi.svg", + title: "Set up your Pi agent team", + description: "Switchloom installs isolated external-runner workflows. Pi remains a process boundary rather than a native child-thread runtime.", + status: "experimental", + steps: [ + { + id: "requirements", + title: "Check Pi", + description: "Use a current Pi CLI with print-mode provider, model, and thinking controls.", + command: { kind: "literal", value: "pi --version" }, + }, + { + id: "project", + title: "Keep workflows in the repository", + description: "cd into your project first. Switchloom sets up the Pi workflow files there and does not touch your global Pi settings.", + }, + { + id: "install", + title: "Apply the team from your project", + description: "Copy the command and run it in a terminal from that project directory.", + command: { kind: "apply" }, + }, + { + id: "activate", + title: "Inspect and verify the runner", + description: "Review the generated workflow before execution, then run doctor. Pi roles execute as isolated processes, not native subagents.", + command: { kind: "doctor" }, + }, + ], +} satisfies ProviderOnboardingTemplate; diff --git a/website/src/lib/onboarding/types.ts b/website/src/lib/onboarding/types.ts new file mode 100644 index 0000000..3bb4a6c --- /dev/null +++ b/website/src/lib/onboarding/types.ts @@ -0,0 +1,30 @@ +import type { HostId } from "../generator"; + +export type OnboardingCommand = + | { kind: "apply" } + | { kind: "doctor" } + | { kind: "literal"; value: string }; + +export type OnboardingStep = { + id: "requirements" | "project" | "install" | "activate"; + title: string; + description: string; + command?: OnboardingCommand; +}; + +export type ProviderOnboardingTemplate = { + host: HostId; + icon: string; + title: string; + description: string; + status: "certified" | "experimental"; + steps: readonly OnboardingStep[]; +}; + +export type ResolvedOnboardingStep = Omit & { + command?: string; +}; + +export type ResolvedProviderOnboarding = Omit & { + steps: readonly ResolvedOnboardingStep[]; +}; diff --git a/website/src/lib/security-headers.test.ts b/website/src/lib/security-headers.test.ts index 301c00a..74aab39 100644 --- a/website/src/lib/security-headers.test.ts +++ b/website/src/lib/security-headers.test.ts @@ -4,10 +4,11 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; describe("website security headers", () => { - it("permits Astro's inline island bootstrap while keeping scripts same-origin", async () => { + it("permits Astro's inline island bootstrap and the Plausible endpoint", async () => { const headers = await readFile(resolve("website/public/_headers"), "utf8"); - expect(headers).toContain("script-src 'self' 'unsafe-inline'"); + expect(headers).toContain("script-src 'self' 'unsafe-inline' https://analytics.int.macherjek.com"); + expect(headers).toContain("connect-src 'self' https://analytics.int.macherjek.com"); expect(headers).toContain("font-src 'self' data:"); expect(headers).toContain("object-src 'none'"); expect(headers).toContain("frame-ancestors 'none'"); diff --git a/website/src/lib/website-cli-parity.test.ts b/website/src/lib/website-cli-parity.test.ts index 1483c0e..badf58c 100644 --- a/website/src/lib/website-cli-parity.test.ts +++ b/website/src/lib/website-cli-parity.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { applyPreset, + CHILD_ROLE_IDS, createConfig, HOST_IDS, hostCatalogFrom, @@ -21,7 +22,7 @@ import { setupTransportFrom, shellQuote, } from "./generator"; -import type { GeneratorConfig, RoleId } from "./generator"; +import type { ChildRoleId, GeneratorConfig } from "./generator"; type LifecycleReport = { repository: string; @@ -122,15 +123,13 @@ async function validateCliParity(name: string, config: GeneratorConfig) { } } -function roleSubsets(): RoleId[][] { - const optionalRoles = ROLE_IDS.filter((role) => role !== "orchestrator"); - return Array.from({ length: 1 << optionalRoles.length }, (_, mask) => [ - "orchestrator", - ...optionalRoles.filter((_, index) => (mask & (1 << index)) !== 0), - ] as RoleId[]); +function roleSubsets(): ChildRoleId[][] { + return Array.from({ length: 1 << CHILD_ROLE_IDS.length }, (_, mask) => + CHILD_ROLE_IDS.filter((_, index) => (mask & (1 << index)) !== 0), + ).filter((roles) => roles.length > 0); } -function withRoles(config: GeneratorConfig, roles: readonly RoleId[]): GeneratorConfig { +function withRoles(config: GeneratorConfig, roles: readonly ChildRoleId[]): GeneratorConfig { return { ...config, roles: [...roles] }; } @@ -158,7 +157,7 @@ describe("website SetupSpec to CLI parity", () => { it("validates every website-selectable per-role model and effort through Rust config and recipe compilation", async () => { for (const host of HOST_IDS) { for (const role of ROLE_IDS) { - const roleSet = role === "orchestrator" ? ["orchestrator"] as RoleId[] : ["orchestrator", role] as RoleId[]; + const roleSet = role === "orchestrator" ? CHILD_ROLE_IDS : [role] as ChildRoleId[]; for (const model of hostCatalog[host].models) { const efforts = model.efforts.length > 0 ? model.efforts : [undefined]; for (const effort of efforts) { @@ -177,6 +176,36 @@ describe("website SetupSpec to CLI parity", () => { } }, 180_000); + it("compiles child-only Codex setup without emitting native parent artifacts", async () => { + const config = setIntegration( + withRoles(setEffort(createConfig("codex"), "orchestrator", "ultra", hostCatalog), ["reviewer"]), + "planr", + ); + await validateCliParity("codex-parent-recommendation-child-only-planr", config); + + const { repository } = await tempRepo("switchloom-website-child-only-codex-"); + const applied = report(run([ + "apply", + "--recipe", + setupRecipe(config, hostCatalog, setupTransport.recipePrefix), + "--repository", + repository, + "--yes", + ])); + const paths = applied.artifacts.map((artifact) => artifact.path); + expect(paths).toContain(".codex/agents/switchloom_reviewer.toml"); + expect(paths).toContain(".planr/agents.toml"); + expect(paths).not.toContain(".codex/agents/switchloom_orchestrator.toml"); + + const configToml = await readFile(join(repository, ".switchloom/config.toml"), "utf8"); + const planrAgents = await readFile(join(repository, ".planr/agents.toml"), "utf8"); + expect(configToml).toContain("[selected_roles.reviewer]"); + expect(configToml).not.toContain("[selected_roles.orchestrator]"); + expect(configToml).not.toContain("[route_default]"); + expect(planrAgents).toContain("switchloom_reviewer"); + expect(planrAgents).not.toContain("switchloom_orchestrator"); + }, 60_000); + it("keeps preset coverage over all roles as a compact smoke for copied website defaults", async () => { for (const host of HOST_IDS) { for (const preset of PRESET_IDS) { @@ -215,7 +244,7 @@ describe("website SetupSpec to CLI parity", () => { it("executes a Planr-mode website command and writes Planr declarations plus thin native roles", async () => { const config = setIntegration(applyPreset(createConfig("codex"), "balanced", hostCatalog), "planr"); const commands = lifecycleCommands(config, hostCatalog, setupTransport.recipePrefix); - const recipe = commands[2].match(/--recipe '([^']+)'/)?.[1]; + const recipe = commands[2].command.match(/--recipe '([^']+)'/)?.[1]; expect(recipe).toBeTruthy(); const { repository } = await tempRepo("switchloom-website-planr-"); diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 43160a1..9c08a1a 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -16,6 +16,7 @@ const setupTransport = setupTransportFrom(catalog); + Switchloom — Build your coding-agent team diff --git a/xtask/src/certify/live.rs b/xtask/src/certify/live.rs index 18d72b1..c49487e 100644 --- a/xtask/src/certify/live.rs +++ b/xtask/src/certify/live.rs @@ -1010,7 +1010,7 @@ fn codex_host_spec( "-c", &sol_override, "-c", - "multi_agent_v2.hide_spawn_agent_metadata=false", + "features.multi_agent_v2.hide_spawn_agent_metadata=true", "-c", "cli_auth_credentials_store=\"file\"", "-c", @@ -1303,6 +1303,23 @@ mod tests { owned.workdir.display() ) })); + assert!( + spec.args + .iter() + .any(|arg| arg == "features.multi_agent_v2.hide_spawn_agent_metadata=true") + ); + assert!( + !spec + .args + .iter() + .any(|arg| arg == "multi_agent_v2.hide_spawn_agent_metadata=false") + ); + assert!( + !spec + .args + .iter() + .any(|arg| arg == "features.multi_agent_v2.hide_spawn_agent_metadata=false") + ); } let negative = codex_negative_prompt(); diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ae347d3..1ee4079 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -154,7 +154,7 @@ struct PlanrArgs { report_root: PathBuf, #[arg(long, default_value_t = 180)] timeout_seconds: u64, - #[arg(long, default_value = "/Users/kregenrek/projects/planr")] + #[arg(long, default_value = "../planr")] protected_planr_root: PathBuf, } diff --git a/xtask/src/release.rs b/xtask/src/release.rs index 2d77d9c..6afdfb7 100644 --- a/xtask/src/release.rs +++ b/xtask/src/release.rs @@ -15,7 +15,6 @@ const FORBIDDEN_PUBLIC_PATHS: &[&str] = &[ "scripts/", "reports/", "retained-evidence/", - "evidence/", ".planr/", ".codex/", ".claude/", @@ -23,6 +22,42 @@ const FORBIDDEN_PUBLIC_PATHS: &[&str] = &[ "tmp/", ]; const FORBIDDEN_PUBLIC_WORDS: &[&str] = &["credential", "secret", "receipt"]; +const REQUIRED_CARGO_EVIDENCE_FILES: &[&str] = &[ + "evidence/codex/0.145.0/exact-version-capture.txt", + "evidence/codex/0.145.0/runtime-evidence.json", +]; +const CURRENT_MAINTAINER_DOCS: &[&str] = &[ + "model-routing-policy.md", + "ownership.md", + "package-policy.md", + "preset-composition.md", + "preset-evaluation.md", + "preset-registry.md", +]; +const REQUIRED_RETAINED_RECORDS: &[&str] = &[ + "retained-evidence/handoffs/v0.3.1/planr-hard-cut-handoff.md", + "retained-evidence/migrations/v0.3.0/migration-baseline.md", + "retained-evidence/migrations/v0.3.0/migration-manifest.tsv", + "retained-evidence/migrations/v0.3.0/v0.3.0-migration-characterization.md", + "retained-evidence/releases/v0.2.2/prepublish-certification-0.2.2.md", + "retained-evidence/releases/v0.3.0/prepublish-certification-0.3.0.md", + "retained-evidence/releases/v0.3.1/prepublish-certification-0.3.1.md", +]; +const REMOVED_BROWSER_ARTIFACT_WORDING: &[&str] = &[ + "download .switchloom/config.toml", + "download setup (.zip)", + "download host-native project files", + "downloadable `.switchloom/config.toml`", + "secondary action downloads", + "secondary result is a readable setup config", +]; +const REQUIRED_NPM_PUBLIC_FILES: &[&str] = &[ + "LICENSE", + "README.md", + "npm/bin/model-routing.js", + "package.json", +]; +const OPTIONAL_NPM_PUBLIC_FILES: &[&str] = &["npm/native/provenance.json"]; pub(crate) struct PrepareOptions { pub root: PathBuf, @@ -112,6 +147,7 @@ pub(crate) fn verify(options: VerifyOptions) -> Result<()> { println!("release contract passed for v{version}"); return Ok(()); } + verify_documentation_boundary(&options.root)?; verify_catalog(&options.root)?; verify_public_inventories(&options.root)?; if options.require_provenance { @@ -425,6 +461,52 @@ fn verify_catalog(root: &Path) -> Result<()> { Ok(()) } +fn verify_documentation_boundary(root: &Path) -> Result<()> { + let mut actual_docs = Vec::new(); + for entry in fs::read_dir(root.join("docs"))? { + let entry = entry?; + ensure!( + entry.file_type()?.is_file(), + "docs may contain current maintainer files only: {}", + entry.path().display() + ); + actual_docs.push(entry.file_name().to_string_lossy().into_owned()); + } + actual_docs.sort(); + ensure!( + actual_docs == CURRENT_MAINTAINER_DOCS, + "docs ownership mismatch: expected {CURRENT_MAINTAINER_DOCS:?}, found {actual_docs:?}" + ); + for record in REQUIRED_RETAINED_RECORDS { + ensure!( + root.join(record).is_file(), + "required retained record is missing: {record}" + ); + } + verify_current_document_wording("README.md", &fs::read_to_string(root.join("README.md"))?)?; + for document in CURRENT_MAINTAINER_DOCS { + let path = format!("docs/{document}"); + verify_current_document_wording(&path, &fs::read_to_string(root.join(&path))?)?; + } + println!( + "documentation boundary passed: {} current docs, {} retained records", + actual_docs.len(), + REQUIRED_RETAINED_RECORDS.len() + ); + Ok(()) +} + +fn verify_current_document_wording(path: &str, content: &str) -> Result<()> { + let normalized = content.to_ascii_lowercase(); + for removed in REMOVED_BROWSER_ARTIFACT_WORDING { + ensure!( + !normalized.contains(removed), + "current documentation {path} contains removed browser artifact wording: {removed}" + ); + } + Ok(()) +} + fn verify_public_inventories(root: &Path) -> Result<()> { verify_publication_boundary(root)?; let cargo = output( @@ -441,7 +523,7 @@ fn verify_public_inventories(root: &Path) -> Result<()> { ], )?; let cargo_files = lines(&cargo.stdout); - verify_inventory("Cargo", &cargo_files)?; + verify_cargo_inventory(&cargo_files)?; let npm = output(root, "npm", &["pack", "--dry-run", "--json"])?; let npm_value: Value = serde_json::from_slice(&npm.stdout)?; let npm_files = npm_value @@ -451,17 +533,11 @@ fn verify_public_inventories(root: &Path) -> Result<()> { .iter() .map(|entry| entry["path"].as_str().unwrap_or_default().to_owned()) .collect::>(); - verify_inventory("npm", &npm_files)?; + verify_npm_inventory(&npm_files)?; ensure!( cargo_files.iter().any(|path| path == "src/lib.rs"), "Cargo package omitted src/lib.rs" ); - ensure!( - npm_files - .iter() - .any(|path| path == "npm/bin/model-routing.js"), - "npm package omitted launcher" - ); println!( "public inventories passed: {} Cargo files, {} npm files", cargo_files.len(), @@ -565,6 +641,51 @@ fn verify_inventory(kind: &str, files: &[String]) -> Result<()> { Ok(()) } +fn verify_cargo_inventory(files: &[String]) -> Result<()> { + verify_inventory("Cargo", files)?; + let actual = files.iter().map(String::as_str).collect::>(); + for required in REQUIRED_CARGO_EVIDENCE_FILES { + ensure!( + actual.contains(required), + "Cargo package omitted required runtime evidence path {required}" + ); + } + Ok(()) +} + +fn verify_npm_inventory(files: &[String]) -> Result<()> { + verify_inventory("npm", files)?; + let actual = files.iter().map(String::as_str).collect::>(); + ensure!( + actual.len() == files.len(), + "npm package inventory contains duplicate paths" + ); + for required in REQUIRED_NPM_PUBLIC_FILES { + ensure!( + actual.contains(required), + "npm package omitted required path {required}" + ); + } + for file in files { + if REQUIRED_NPM_PUBLIC_FILES.contains(&file.as_str()) + || OPTIONAL_NPM_PUBLIC_FILES.contains(&file.as_str()) + { + continue; + } + let components = file.split('/').collect::>(); + if components.len() == 4 + && components[0] == "npm" + && components[1] == "native" + && valid_target(components[2]) + && components[3] == "model-routing" + { + continue; + } + bail!("npm public artifact contains path outside positive allowlist: {file}"); + } + Ok(()) +} + fn verify_version_contract(root: &Path) -> Result { let version = workspace_version(root)?; ensure!( @@ -963,6 +1084,103 @@ mod tests { verify_inventory("test", &["src/lib.rs".into(), "README.md".into()]).unwrap(); } + #[test] + fn npm_inventory_accepts_only_the_minimal_positive_allowlist() { + let minimal = [ + "LICENSE", + "README.md", + "npm/bin/model-routing.js", + "package.json", + ] + .map(str::to_owned); + verify_npm_inventory(&minimal).unwrap(); + + let with_native = minimal + .into_iter() + .chain(["npm/native/darwin-arm64/model-routing".to_owned()]) + .collect::>(); + verify_npm_inventory(&with_native).unwrap(); + + let with_provenance = with_native + .into_iter() + .chain(["npm/native/provenance.json".to_owned()]) + .collect::>(); + verify_npm_inventory(&with_provenance).unwrap(); + + for unexpected in [ + "CHANGELOG.md", + "docs/package-policy.md", + "evidence/codex/0.145.0/runtime.json", + "npm/native/darwin-arm64/debug.log", + "npm/native/unsupported/model-routing", + ] { + let mut inventory = with_provenance.clone(); + inventory.push(unexpected.to_owned()); + assert!( + verify_npm_inventory(&inventory).is_err(), + "accepted {unexpected}" + ); + } + } + + #[test] + fn cargo_inventory_requires_versioned_runtime_evidence() { + let complete = REQUIRED_CARGO_EVIDENCE_FILES + .iter() + .map(|path| (*path).to_owned()) + .collect::>(); + verify_cargo_inventory(&complete).unwrap(); + + for omitted in REQUIRED_CARGO_EVIDENCE_FILES { + let inventory = REQUIRED_CARGO_EVIDENCE_FILES + .iter() + .filter(|path| *path != omitted) + .map(|path| (*path).to_owned()) + .collect::>(); + assert!( + verify_cargo_inventory(&inventory).is_err(), + "accepted Cargo inventory without {omitted}" + ); + } + } + + #[test] + fn current_documentation_rejects_removed_browser_artifact_wording() { + verify_current_document_wording( + "README.md", + "Use the provider onboarding flow, apply from the CLI, then run doctor.", + ) + .unwrap(); + + for removed in REMOVED_BROWSER_ARTIFACT_WORDING { + assert!( + verify_current_document_wording("README.md", removed).is_err(), + "accepted removed wording: {removed}" + ); + } + } + + #[test] + fn npm_inventory_requires_metadata_launcher_readme_and_license() { + let complete = [ + "LICENSE", + "README.md", + "npm/bin/model-routing.js", + "package.json", + ]; + for omitted in complete { + let inventory = complete + .into_iter() + .filter(|path| *path != omitted) + .map(str::to_owned) + .collect::>(); + assert!( + verify_npm_inventory(&inventory).is_err(), + "accepted inventory without {omitted}" + ); + } + } + #[test] fn archive_label_must_match_host_or_explicit_cargo_triple() { assert!(validate_package_target("darwin-arm64", None, "darwin-arm64").is_ok());