From 52abd96b488fbbd30329d6d1a538b1c9fcaab4a0 Mon Sep 17 00:00:00 2001 From: "aicia[bot]" Date: Sun, 9 Aug 2026 23:39:43 +0200 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=8E=89=20introduce=20dotnet-remote-te?= =?UTF-8?q?sting=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New skill provides deterministic remote testing of .NET projects in Docker containers using Microsoft's official SDK images. Supports testenvironments.json configuration or zero-config discovery from official release metadata. Orchestrates container setup, NuGet cache management, test execution, and result parsing transparently behind a single reusable runner script. --- skills/dotnet-remote-testing/FORMS.md | 81 + skills/dotnet-remote-testing/SKILL.md | 142 + skills/dotnet-remote-testing/evals/evals.json | 94 + .../test/Api.Tests/Api.Tests.csproj | 12 + .../configured/test/Api.Tests/HealthTests.cs | 9 + .../files/configured/testenvironments.json | 10 + .../test/Worker.Tests/Worker.Tests.csproj | 12 + .../test/Worker.Tests/WorkerTests.cs | 9 + .../files/unsupported/testenvironments.json | 10 + .../evals/files/zero-config/Sample.slnx | 4 + .../zero-config/src/Sample/Calculator.cs | 8 + .../zero-config/src/Sample/Sample.csproj | 7 + .../test/Sample.Tests/CalculatorTests.cs | 13 + .../test/Sample.Tests/Sample.Tests.csproj | 15 + .../references/docker-execution.md | 76 + .../references/release-discovery.md | 70 + .../references/testenvironments-json.md | 70 + .../scripts/remote-test.cs | 2915 +++++++++++++++++ .../scripts/test-remote-testing.ps1 | 157 + .../scripts/validate-skill.ps1 | 48 + 20 files changed, 3762 insertions(+) create mode 100644 skills/dotnet-remote-testing/FORMS.md create mode 100644 skills/dotnet-remote-testing/SKILL.md create mode 100644 skills/dotnet-remote-testing/evals/evals.json create mode 100644 skills/dotnet-remote-testing/evals/files/configured/test/Api.Tests/Api.Tests.csproj create mode 100644 skills/dotnet-remote-testing/evals/files/configured/test/Api.Tests/HealthTests.cs create mode 100644 skills/dotnet-remote-testing/evals/files/configured/testenvironments.json create mode 100644 skills/dotnet-remote-testing/evals/files/unsupported/test/Worker.Tests/Worker.Tests.csproj create mode 100644 skills/dotnet-remote-testing/evals/files/unsupported/test/Worker.Tests/WorkerTests.cs create mode 100644 skills/dotnet-remote-testing/evals/files/unsupported/testenvironments.json create mode 100644 skills/dotnet-remote-testing/evals/files/zero-config/Sample.slnx create mode 100644 skills/dotnet-remote-testing/evals/files/zero-config/src/Sample/Calculator.cs create mode 100644 skills/dotnet-remote-testing/evals/files/zero-config/src/Sample/Sample.csproj create mode 100644 skills/dotnet-remote-testing/evals/files/zero-config/test/Sample.Tests/CalculatorTests.cs create mode 100644 skills/dotnet-remote-testing/evals/files/zero-config/test/Sample.Tests/Sample.Tests.csproj create mode 100644 skills/dotnet-remote-testing/references/docker-execution.md create mode 100644 skills/dotnet-remote-testing/references/release-discovery.md create mode 100644 skills/dotnet-remote-testing/references/testenvironments-json.md create mode 100644 skills/dotnet-remote-testing/scripts/remote-test.cs create mode 100644 skills/dotnet-remote-testing/scripts/test-remote-testing.ps1 create mode 100644 skills/dotnet-remote-testing/scripts/validate-skill.ps1 diff --git a/skills/dotnet-remote-testing/FORMS.md b/skills/dotnet-remote-testing/FORMS.md new file mode 100644 index 0000000..e8e7213 --- /dev/null +++ b/skills/dotnet-remote-testing/FORMS.md @@ -0,0 +1,81 @@ +# .NET Remote Testing Input Form + +Collect only the fields that are still unresolved after inspecting the request and the repository. Most remote-test requests are fully determined and need **no** questions — for example, "remote test this solution" against a repository with a single applicable environment. Prefer native structured controls when the host provides them; otherwise use the plain-text fallback below without changing field order, defaults, or the final confirmation. + +## Fields + +### environment + +- **type:** single-choice +- **prompt:** Which environment should run the tests? +- **choices:** Dynamically list the environments from `remote-test.cs list` — the configured Docker environments from `testenvironments.json`, or the Microsoft-derived environments (for example `dotnet-10-lts`, `dotnet-9-sts`, `dotnet-11-preview`) when no `testenvironments.json` exists +- **default:** The only applicable Docker environment, or the environment explicitly named by the user (Recommended) +- **required:** true + +### test_scope + +- **type:** single-choice +- **prompt:** What should be tested? +- **choices:** + - Entire solution / auto-resolved target (Recommended) + - A specific project + - A class or test filter +- **default:** Entire solution / auto-resolved target (Recommended) +- **required:** true + +### project + +- **type:** text +- **prompt:** Which project or solution should be tested (path relative to the source root)? +- **default:** Auto-resolved (root solution, single solution, or single project) +- **required:** false +- **show_when:** `test_scope` is `A specific project` + +### filter + +- **type:** text +- **prompt:** Which `dotnet test` filter or fully-qualified test name should be run? +- **default:** (none) +- **required:** false +- **show_when:** `test_scope` is `A class or test filter` + +### configuration + +- **type:** single-choice +- **prompt:** Which build configuration? +- **choices:** + - Debug (Recommended) + - Release +- **default:** Debug (Recommended) +- **required:** true + +### coverage + +- **type:** single-choice +- **prompt:** Collect code coverage? (Only when the project already supports it; packages are never added.) +- **choices:** + - No (Recommended) + - Yes +- **default:** No (Recommended) +- **required:** true + +### confirmation + +- **type:** single-choice +- **prompt:** Run the tests using the summarized environment, target, configuration, and coverage plan? +- **choices:** + - Yes (Recommended) + - No +- **default:** Yes (Recommended) +- **required:** true + +## Presentation rules + +- Infer explicit answers from the request and from `remote-test.cs list`/`plan`; do not ask them again. +- Ask one unresolved field at a time. Never bundle multiple questions. +- Present the recommended/default choice first and suffix it with `(Recommended)`. +- For the `environment` field, offer the discovered environment names as selectable choices rather than free text. When exactly one environment applies, select it without asking. +- For `project`, offer the auto-resolved target as a selectable choice alongside a free-text path. +- In plain-text fallback mode, start immediately with `Field: ` and show numbered choices. Do not add a conversational preamble. +- If the user leaves a shown computed/default choice blank, accept it and continue. +- After all fields are resolved, summarize the exact environment, target, configuration, and coverage, then ask `confirmation`. diff --git a/skills/dotnet-remote-testing/SKILL.md b/skills/dotnet-remote-testing/SKILL.md new file mode 100644 index 0000000..349dd67 --- /dev/null +++ b/skills/dotnet-remote-testing/SKILL.md @@ -0,0 +1,142 @@ +--- +name: dotnet-remote-testing +description: > + Run .NET tests inside a resolved remote Docker environment and return structured results — Visual Studio's Remote Testing experience (choose an environment, run tests, see results) without hand-writing container plumbing. Use when asked to remote test, run tests in Docker or a container, run tests against a specific .NET SDK, list or select test environments, or honor an existing testenvironments.json. Honors testenvironments.json Docker environments or derives zero-config environments from Microsoft's live .NET release index (LTS, STS, preview) using official mcr.microsoft.com/dotnet/sdk images via the bundled runner scripts/remote-test.cs. Docker only; WSL and SSH are reported unsupported. Do NOT use to author or refactor test code, choose a testing framework, generate Dockerfiles, or run tests on the host. +compatibility: > + Requires the .NET SDK, Docker, and PowerShell 7+. Zero-config discovery needs network access to Microsoft's release index and mcr.microsoft.com; a cache enables offline reuse. +--- + +# .NET Remote Testing + +Give developers the experience Visual Studio's experimental [Remote Testing](https://learn.microsoft.com/en-us/visualstudio/test/remote-testing?view=visualstudio) was meant to provide: + +> **Choose a test environment → run tests → see results.** + +Everything between those two actions — configuration discovery, .NET release discovery, image resolution, source staging, NuGet caching, restore, build, test execution, result collection, cancellation, and cleanup — is infrastructure that belongs *behind* the abstraction. Your job is orchestration: understand what the developer means, then hand the work to the deterministic runner. Do not turn a routine "run my tests in .NET 10" into a Docker tutorial, and never compose ad-hoc `docker run` command lines yourself. + +## Architecture: you orchestrate, the runner executes + +The bundled .NET file-based program `scripts/remote-test.cs` is the **execution layer**. It is deterministic and self-tested. You are the **orchestration layer**. Always route execution through it instead of driving Docker directly: + +``` +dotnet run --file "/scripts/remote-test.cs" -- [options] +``` + +Commands: `list` (show environments), `plan` (resolve an environment + image and print the execution plan without running), `run` (execute restore/build/test in the resolved container), and `--self-test` (built-in deterministic tests). Add `--json` to any command for machine-readable output. + +## Critical + +- **Never run tests on the host and never silently fall back to local.** If remote testing was requested, tests must execute in the resolved Docker environment. If Docker is unavailable, report that (the runner exits `DockerUnavailable`) — do not run `dotnet test` locally instead. +- **`testenvironments.json` is the configuration contract.** Honor Microsoft's existing version-1 schema. Do not invent a competing format, and do not modify `testenvironments.json` unless explicitly asked. +- **Do not generate container plumbing.** Never create a `Dockerfile`, `docker-compose.yml`/`compose.yml`, `.devcontainer/`, `.vscode/`, `Directory.Build.*`, or throwaway scripts to make remote testing work. Zero-configuration testing uses official Microsoft SDK images directly. An *existing* configured `dockerFile` is honored because it is deliberate repository intent. +- **Do not hardcode .NET versions.** Supported LTS/STS channels and the current preview channel are discovered from Microsoft's release metadata at runtime. `.NET 10`/`.NET 11` are examples, never constants. +- **Do not modify the repository to make tests pass.** Never edit `global.json`, project files, target frameworks, or test packages. Report incompatibilities instead. +- **Report infrastructure failures as infrastructure, not as failing unit tests.** The runner classifies each phase distinctly; preserve that distinction when you summarize. + +## Step 1: Understand intent and inputs + +Read `FORMS.md` and infer everything you can from the request and repository. Most invocations need no questions at all — "remote test this solution" against a repo with one applicable environment is fully determined. Only ask (one field at a time) when a genuine choice remains, such as which environment when several apply. Resolve the workspace/solution root (`--repo-root`, default: current directory). + +Typical intents map directly to a command: + +| The developer says… | You run… | +|---|---| +| "What environments can I test in?" / "list remote environments" | `list` | +| "Show me the plan / which image will you use?" | `plan` | +| "Remote test this solution" / "run these tests in .NET 10" | `run` | + +## Step 2: List and resolve the environment + +Resolution is deterministic and follows this precedence, which the runner enforces — do not second-guess it: + +1. An environment the user names explicitly (`--environment `). +2. An applicable Docker environment from `testenvironments.json` (authoritative when the file exists — never supplement it with invented environments). +3. Microsoft-derived environments when no `testenvironments.json` exists. + +Run `list` to show the choices. When exactly one applicable Docker environment exists, use it. When several exist and the user has not chosen, present the names concisely and let them pick — do not guess intent: + +``` +dotnet run --file "/scripts/remote-test.cs" -- list --repo-root "" +``` + +Absence of `testenvironments.json` is **not** an error. In that case the runner derives environments from Microsoft's live release index (`mcr.microsoft.com/dotnet/sdk` images for each supported LTS/STS channel plus the current preview), so no files need to be added to the repository. Environment names look like `dotnet-10-lts`, `dotnet-9-sts`, `dotnet-11-preview`; the exact set comes from metadata at runtime. + +If the user names a WSL or SSH environment, the runner reports it as unsupported (Docker only for now). Relay that clearly instead of trying to convert it. + +## Step 3: Plan when transparency helps + +Before a long run — or whenever the developer wants to see what will happen — `plan` resolves the environment, validates the image tag against Microsoft's registry, pre-resolves the immutable digest, inspects target frameworks for SDK compatibility, and prints the deterministic execution plan without touching Docker: + +``` +dotnet run --file "/scripts/remote-test.cs" -- plan --repo-root "" -e [-p ] [-c Release] --json +``` + +Use the plan's `compatibility` result to catch an SDK/target-framework mismatch early. If it is incompatible, report the reason — do not change the repository to force it. + +## Step 4: Run the tests + +``` +dotnet run --file "/scripts/remote-test.cs" -- run --repo-root "" [-e ] [options] +``` + +Common scoping options (pass through only what the developer asked for): + +- `-p, --project ` — a specific solution or project (relative to the source root). When omitted, the runner resolves a root solution, a single solution, or a single project automatically. +- `--filter ` — a `dotnet test --filter` expression (test class, trait, etc.). +- `--test ` — shortcut for a fully-qualified-name filter (a single test or class). +- `-c, --configuration ` — build configuration. +- `-f, --framework ` — restrict a multi-targeted test project to one TFM. +- `--coverage` — collect coverage when the project already supports it (never add packages to enable it). +- `--timeout ` — abort the run after N seconds; the runner still cleans up. + +The runner establishes an isolated staged workspace (so container builds never leave Linux `bin`/`obj` in the working tree), mounts a persistent NuGet cache outside the repository, pins the image to its digest, runs restore → build → test, collects TRX results, and removes all transient Docker resources afterward. You do not manage any of that. + +## Step 5: Report results concisely + +Lead with the outcome, not the infrastructure. Mirror the runner's concise result and suppress pull/restore/build log noise unless something failed: + +``` +Remote Test: dotnet-10-lts + +Image: mcr.microsoft.com/dotnet/sdk:10.0.302 +Digest: sha256:... +SDK: 10.0.302 + +Tests: 1842 passed, 3 skipped, 0 failed +Time: 21.8 s +``` + +When tests fail, prioritize actionable detail — the failing test, its class, the expected/actual message, and location — over container startup output: + +``` +3 tests failed + +Cuemon.Text.Tests.StringUtilityTest + Sanitize_WithUnicode_ReturnsExpectedValue + Expected: ... Actual: ... +``` + +A run is reproducible in terms of environment, requested image, resolved digest, SDK, architecture, and runner version; include the image and digest so the result can be reproduced. + +## Failure handling + +The runner distinguishes failure kinds via exit code and the `failureKind` field: `Configuration`, `UnsupportedEnvironment`, `DockerUnavailable`, `ImageResolution`, `SdkIncompatibility`, `SourceStaging`, `Restore`, `Compilation`, `TestHost`, `TestFailure`, `ResultProcessing`, `Cleanup`, `Cancelled`, and `ReleaseMetadataUnavailable`. Report the kind honestly: + +- A container/infrastructure problem (image pull, restore, build, test-host crash) is **not** a failing unit test — say which phase failed. +- If cleanup leaves resources behind, relay the exact resource identifiers the runner reports. +- If Microsoft's release index is unreachable and no cache exists, automatic discovery fails with an explanation; an explicit `testenvironments.json` environment with a resolvable image still works offline. + +## What this skill must never do + +- Generate a `Dockerfile`, dev container, editor config, or any repository-specific plumbing. (Honor an *existing* configured `dockerFile`; never create one.) +- Run privileged containers, mount the Docker socket, mount the whole user profile, forward host credentials indiscriminately, disable TLS validation, expose ports, or print secrets. The runner already avoids these; do not add them. +- Substitute third-party or unofficial images for auto-generated environments (only `mcr.microsoft.com/dotnet/sdk`). An explicit `dockerImage` in `testenvironments.json` is exempt because it is deliberate. +- Fall back to running tests locally. + +## References + +Consult these only when you need the detail: + +- `references/testenvironments-json.md` — Microsoft's version-1 schema, supported/unsupported properties, and how the runner honors them. +- `references/release-discovery.md` — how supported channels and image tags are derived from Microsoft's release index, plus offline/cache behavior. +- `references/docker-execution.md` — the deterministic execution model: staging, mounts, caching, security posture, lifecycle, and cleanup. diff --git a/skills/dotnet-remote-testing/evals/evals.json b/skills/dotnet-remote-testing/evals/evals.json new file mode 100644 index 0000000..55f3ae7 --- /dev/null +++ b/skills/dotnet-remote-testing/evals/evals.json @@ -0,0 +1,94 @@ +{ + "skill_name": "dotnet-remote-testing", + "evals": [ + { + "id": 1, + "prompt": "This repo has no testenvironments.json. Remote test this solution using .NET 10 in a container and show me the results.", + "expected_output": "The skill runs the bundled deterministic runner (scripts/remote-test.cs) to derive a zero-configuration Microsoft SDK environment from the live release index, executes restore/build/test inside Docker against an mcr.microsoft.com/dotnet/sdk image pinned to a digest, and reports a concise structured result (image, digest, passed/skipped/failed, duration) without creating any Dockerfile or container plumbing.", + "expectations": [ + "Recognizes that an absent testenvironments.json is not an error and uses the zero-configuration path", + "Invokes scripts/remote-test.cs (list/plan/run) rather than composing ad-hoc docker commands", + "Selects an official mcr.microsoft.com/dotnet/sdk image for a supported .NET 10 channel derived from Microsoft release metadata, not a hardcoded version", + "Runs tests inside Docker and does not fall back to running dotnet test on the host", + "Does NOT create a Dockerfile, docker-compose.yml, .devcontainer/, .vscode/, or Directory.Build.* file", + "Reports a concise result including the image and resolved digest, and the passed/skipped/failed counts" + ], + "files": [ + "evals/files/zero-config/Sample.slnx", + "evals/files/zero-config/src/Sample/Sample.csproj", + "evals/files/zero-config/src/Sample/Calculator.cs", + "evals/files/zero-config/test/Sample.Tests/Sample.Tests.csproj", + "evals/files/zero-config/test/Sample.Tests/CalculatorTests.cs" + ] + }, + { + "id": 2, + "prompt": "Run my tests remotely and show me the results.", + "expected_output": "The skill discovers the repository's testenvironments.json, resolves the single configured Docker environment (linux-dotnet-10) as authoritative, and runs the tests in that container via scripts/remote-test.cs, honoring the configured dockerImage without substituting a different image or generating any plumbing.", + "expectations": [ + "Discovers and honors the existing testenvironments.json version 1 configuration", + "Resolves the single configured Docker environment 'linux-dotnet-10' without asking or inventing alternatives", + "Uses the configured dockerImage (mcr.microsoft.com/dotnet/sdk:10.0) as written rather than a generated or third-party image", + "Executes through scripts/remote-test.cs and returns a structured result", + "Does not modify testenvironments.json or add any container/editor plumbing" + ], + "files": [ + "evals/files/configured/testenvironments.json", + "evals/files/configured/test/Api.Tests/Api.Tests.csproj", + "evals/files/configured/test/Api.Tests/HealthTests.cs" + ] + }, + { + "id": 3, + "prompt": "What remote test environments can I use in this repository?", + "expected_output": "The skill runs scripts/remote-test.cs list and reports the Microsoft-derived environments for the currently supported LTS/STS channels plus the current preview channel (names and SDK image tags), all discovered from Microsoft's release metadata at runtime rather than hardcoded.", + "expectations": [ + "Runs the runner's list command instead of guessing environments", + "Presents Microsoft-derived environments (e.g. dotnet--lts, dotnet--sts, dotnet--preview) discovered from release metadata", + "Does not hardcode specific .NET version numbers as permanent truth", + "Associates each environment with an official mcr.microsoft.com/dotnet/sdk image" + ], + "files": [ + "evals/files/zero-config/Sample.slnx", + "evals/files/zero-config/src/Sample/Sample.csproj", + "evals/files/zero-config/src/Sample/Calculator.cs", + "evals/files/zero-config/test/Sample.Tests/Sample.Tests.csproj", + "evals/files/zero-config/test/Sample.Tests/CalculatorTests.cs" + ] + }, + { + "id": 4, + "prompt": "Remote test using the linux-wsl environment from my testenvironments.json.", + "expected_output": "The skill reports that the requested environment type (wsl) is not supported yet (Docker only) rather than silently ignoring it, converting it to Docker, or running the tests locally.", + "expectations": [ + "Reads testenvironments.json and identifies linux-wsl as a wsl-type environment", + "Clearly reports the wsl environment as unsupported (Docker only) instead of running it", + "Does not convert the wsl environment into a Docker environment or fabricate an image for it", + "Does not fall back to running the tests on the local host" + ], + "files": [ + "evals/files/unsupported/testenvironments.json", + "evals/files/unsupported/test/Worker.Tests/Worker.Tests.csproj", + "evals/files/unsupported/test/Worker.Tests/WorkerTests.cs" + ] + }, + { + "id": 5, + "prompt": "Set up remote testing for this repo so I can run the tests in a container, but I don't want a bunch of Docker files added to my repository.", + "expected_output": "The skill explains that zero-configuration remote testing needs no files added to the repository: it derives an official Microsoft SDK environment from release metadata and runs through scripts/remote-test.cs. It does not generate a Dockerfile, dev container, compose file, or editor configuration.", + "expectations": [ + "Explains that no files need to be added to the repository to run tests remotely", + "Does NOT generate a Dockerfile, docker-compose.yml/compose.yml, .devcontainer/, .vscode/, or Directory.Build.* file", + "Uses official mcr.microsoft.com/dotnet/sdk images directly via the deterministic runner", + "Optionally notes that an existing configured dockerFile would be honored, but is never generated" + ], + "files": [ + "evals/files/zero-config/Sample.slnx", + "evals/files/zero-config/src/Sample/Sample.csproj", + "evals/files/zero-config/src/Sample/Calculator.cs", + "evals/files/zero-config/test/Sample.Tests/Sample.Tests.csproj", + "evals/files/zero-config/test/Sample.Tests/CalculatorTests.cs" + ] + } + ] +} diff --git a/skills/dotnet-remote-testing/evals/files/configured/test/Api.Tests/Api.Tests.csproj b/skills/dotnet-remote-testing/evals/files/configured/test/Api.Tests/Api.Tests.csproj new file mode 100644 index 0000000..00cd155 --- /dev/null +++ b/skills/dotnet-remote-testing/evals/files/configured/test/Api.Tests/Api.Tests.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + false + + + + + + + diff --git a/skills/dotnet-remote-testing/evals/files/configured/test/Api.Tests/HealthTests.cs b/skills/dotnet-remote-testing/evals/files/configured/test/Api.Tests/HealthTests.cs new file mode 100644 index 0000000..639d43e --- /dev/null +++ b/skills/dotnet-remote-testing/evals/files/configured/test/Api.Tests/HealthTests.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace Api.Tests; + +public class HealthTests +{ + [Fact] + public void Status_IsOk() => Assert.Equal("OK", "OK"); +} diff --git a/skills/dotnet-remote-testing/evals/files/configured/testenvironments.json b/skills/dotnet-remote-testing/evals/files/configured/testenvironments.json new file mode 100644 index 0000000..1557e07 --- /dev/null +++ b/skills/dotnet-remote-testing/evals/files/configured/testenvironments.json @@ -0,0 +1,10 @@ +{ + "version": "1", + "environments": [ + { + "name": "linux-dotnet-10", + "type": "docker", + "dockerImage": "mcr.microsoft.com/dotnet/sdk:10.0" + } + ] +} diff --git a/skills/dotnet-remote-testing/evals/files/unsupported/test/Worker.Tests/Worker.Tests.csproj b/skills/dotnet-remote-testing/evals/files/unsupported/test/Worker.Tests/Worker.Tests.csproj new file mode 100644 index 0000000..00cd155 --- /dev/null +++ b/skills/dotnet-remote-testing/evals/files/unsupported/test/Worker.Tests/Worker.Tests.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + false + + + + + + + diff --git a/skills/dotnet-remote-testing/evals/files/unsupported/test/Worker.Tests/WorkerTests.cs b/skills/dotnet-remote-testing/evals/files/unsupported/test/Worker.Tests/WorkerTests.cs new file mode 100644 index 0000000..8a5e7e2 --- /dev/null +++ b/skills/dotnet-remote-testing/evals/files/unsupported/test/Worker.Tests/WorkerTests.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace Worker.Tests; + +public class WorkerTests +{ + [Fact] + public void Processes_Message() => Assert.True(1 + 1 == 2); +} diff --git a/skills/dotnet-remote-testing/evals/files/unsupported/testenvironments.json b/skills/dotnet-remote-testing/evals/files/unsupported/testenvironments.json new file mode 100644 index 0000000..d727322 --- /dev/null +++ b/skills/dotnet-remote-testing/evals/files/unsupported/testenvironments.json @@ -0,0 +1,10 @@ +{ + "version": "1", + "environments": [ + { + "name": "linux-wsl", + "type": "wsl", + "wslDistribution": "Ubuntu-22.04" + } + ] +} diff --git a/skills/dotnet-remote-testing/evals/files/zero-config/Sample.slnx b/skills/dotnet-remote-testing/evals/files/zero-config/Sample.slnx new file mode 100644 index 0000000..d40fb1f --- /dev/null +++ b/skills/dotnet-remote-testing/evals/files/zero-config/Sample.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/skills/dotnet-remote-testing/evals/files/zero-config/src/Sample/Calculator.cs b/skills/dotnet-remote-testing/evals/files/zero-config/src/Sample/Calculator.cs new file mode 100644 index 0000000..55e2f98 --- /dev/null +++ b/skills/dotnet-remote-testing/evals/files/zero-config/src/Sample/Calculator.cs @@ -0,0 +1,8 @@ +namespace Sample; + +public static class Calculator +{ + public static int Add(int a, int b) => a + b; + + public static int Multiply(int a, int b) => a * b; +} diff --git a/skills/dotnet-remote-testing/evals/files/zero-config/src/Sample/Sample.csproj b/skills/dotnet-remote-testing/evals/files/zero-config/src/Sample/Sample.csproj new file mode 100644 index 0000000..07199a9 --- /dev/null +++ b/skills/dotnet-remote-testing/evals/files/zero-config/src/Sample/Sample.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/skills/dotnet-remote-testing/evals/files/zero-config/test/Sample.Tests/CalculatorTests.cs b/skills/dotnet-remote-testing/evals/files/zero-config/test/Sample.Tests/CalculatorTests.cs new file mode 100644 index 0000000..e9b6e34 --- /dev/null +++ b/skills/dotnet-remote-testing/evals/files/zero-config/test/Sample.Tests/CalculatorTests.cs @@ -0,0 +1,13 @@ +using Sample; +using Xunit; + +namespace Sample.Tests; + +public class CalculatorTests +{ + [Fact] + public void Add_ReturnsSum() => Assert.Equal(4, Calculator.Add(2, 2)); + + [Fact] + public void Multiply_ReturnsProduct() => Assert.Equal(6, Calculator.Multiply(2, 3)); +} diff --git a/skills/dotnet-remote-testing/evals/files/zero-config/test/Sample.Tests/Sample.Tests.csproj b/skills/dotnet-remote-testing/evals/files/zero-config/test/Sample.Tests/Sample.Tests.csproj new file mode 100644 index 0000000..05e3c2d --- /dev/null +++ b/skills/dotnet-remote-testing/evals/files/zero-config/test/Sample.Tests/Sample.Tests.csproj @@ -0,0 +1,15 @@ + + + net10.0 + enable + false + + + + + + + + + + diff --git a/skills/dotnet-remote-testing/references/docker-execution.md b/skills/dotnet-remote-testing/references/docker-execution.md new file mode 100644 index 0000000..b174f16 --- /dev/null +++ b/skills/dotnet-remote-testing/references/docker-execution.md @@ -0,0 +1,76 @@ +# Deterministic Docker execution model + +The runner (`scripts/remote-test.cs`) owns the entire container lifecycle so remote testing stays reproducible and never leaks infrastructure concerns to the caller. The AI orchestration layer never composes `docker run` commands directly. + +## Lifecycle + +Every `run` follows the same deterministic sequence: + +1. Validate Docker availability. If unavailable, exit `DockerUnavailable` — never fall back to the host. +2. Resolve the environment (configured or Microsoft-derived). +3. Resolve the image and pin it to an immutable digest. +4. Establish an isolated, disposable staged workspace. +5. Prepare deterministic mounts and caches. +6. Execute restore → build → test. +7. Collect structured results (TRX). +8. Clean up transient resources. + +Cleanup is mandatory after success, test failure, build failure, restore failure, cancellation, and exceptions. If cleanup fails, the exact remaining Docker resource identifiers are reported. + +## Repository isolation + +Container execution must not pollute or mutate the developer's working tree. The runner stages the source (respecting `localRoot`) into an isolated temporary workspace and mounts *that*, so container builds write Linux `bin`/`obj` into the disposable copy — never next to the developer's native Windows build outputs. When the source is a git repository, staging enumerates tracked and untracked-but-not-ignored files so ignored build artifacts stay out of the staged copy. + +The runner never creates `Dockerfile`, `docker-compose.yml`/`compose.yml`, `.devcontainer/`, `.vscode/`, `Directory.Build.*`, temporary scripts, or generated test configuration in the repository. + +## Mounts + +Three bind mounts, nothing more: + +| Mount | Container path | Purpose | Persists? | +|---|---|---|---| +| Staged workspace | `/workspace` | Disposable copy of the source under test | No (removed after the run) | +| NuGet cache | `/nuget` | Persistent package cache owned outside the repository | Yes | +| Results | `/results` | TRX output read back by the host | No (removed after the run) | + +The dependency cache (`/nuget`, via `NUGET_PACKAGES`) is deliberately separated from the per-execution build/test workspace: the immutable, reusable dependency cache may persist for fast feedback, while the build/test workspace is isolated per execution so results never depend on stale source. + +## In-container phases + +The entrypoint runs three ordered phases and emits a machine-readable marker with each phase's exit code: + +``` +dotnet restore +dotnet build -c --no-restore +dotnet test -c --no-build [--filter …] [--framework …] [--collect "XPlat Code Coverage"] --results-directory /results --logger trx +``` + +`restore` and `build` stop the run on failure; `test` always runs to completion so a TRX is produced even when tests fail. The runner works with the repository's configured .NET testing infrastructure; it does not install or alter test packages, and it does not assume a single testing framework. + +## Test scope + +Supported through options that pass straight into the phases: entire solution, a project, an individual test or class (`--test`/`--filter`), configuration (`--configuration`), a single TFM of a multi-targeted project (`--framework`), and coverage (`--coverage`, only when the project already supports it). + +## Result collection and classification + +The runner parses every TRX in `/results` (multi-targeted projects emit one per TFM) into a single structured result: passed, skipped, failed counts, duration, and actionable failure detail (test name, class, message, location). It prioritizes machine-readable results and suppresses pull/restore/build noise on success. + +Failures are classified into distinct kinds so a container/infrastructure problem is never misreported as a failing unit test: `Configuration`, `UnsupportedEnvironment`, `DockerUnavailable`, `ImageResolution`, `SdkIncompatibility`, `SourceStaging`, `Restore`, `Compilation`, `TestHost`, `TestFailure`, `ResultProcessing`, `Cleanup`, `Cancelled`, `ReleaseMetadataUnavailable`. A non-zero `dotnet test` exit with a TRX containing failures is a `TestFailure`; a non-zero exit with no failing results (crash, no discovered tests, missing adapter) is a `TestHost` failure. + +## Cancellation and cleanup + +The container is given a deterministic, knowable name so it can always be targeted for cleanup — even after Ctrl+C or a `--timeout`. On cancellation the runner force-removes the container and deletes the staged workspace and results directory; the persistent NuGet cache is kept. `docker run --rm` also auto-removes the container on normal completion. + +## Security posture + +The runner deliberately avoids risky Docker practices and the orchestration layer must not add them: + +- no privileged containers (`--privileged`); +- the Docker socket is never mounted into the test container; +- the whole user profile is never mounted; +- host credentials are not forwarded indiscriminately; +- TLS validation is never disabled; +- no ports are published; +- NuGet credentials are not leaked and secrets are not printed. + +Private package-source authentication, when needed, must use an explicit and ephemeral mechanism; host credentials are never assumed to be injectable into containers. diff --git a/skills/dotnet-remote-testing/references/release-discovery.md b/skills/dotnet-remote-testing/references/release-discovery.md new file mode 100644 index 0000000..601db1b --- /dev/null +++ b/skills/dotnet-remote-testing/references/release-discovery.md @@ -0,0 +1,70 @@ +# .NET release discovery and image selection + +When no `testenvironments.json` exists, remote testing provides a zero-configuration experience built exclusively on official Microsoft .NET SDK container images. The available channels are discovered from Microsoft's authoritative release metadata at runtime — never hardcoded. + +## Source of truth + +The runner fetches and parses Microsoft's release index: + +``` +https://raw.githubusercontent.com/dotnet/core/refs/heads/main/release-notes/releases-index.json +``` + +It uses the explicit lifecycle fields rather than any assumption about version numbers. Never encode patterns such as "even versions are LTS", "odd versions are STS", or "highest version is preview" — those are not the contract. + +Relevant fields per channel: `channel-version`, `latest-release`, `latest-release-date`, `latest-runtime`, `latest-sdk`, `support-phase`, `release-type`, and `eol-date`. + +## Which channels become environments + +- **Supported stable channels** — `support-phase` is not `eol` and not `preview`, and `release-type` is `lts` or `sts`. These become environments named `dotnet--lts` or `dotnet--sts`. +- **Current preview channel** — `support-phase` is `preview` (or `go-live`). This becomes `dotnet--preview`. +- **EOL channels are excluded** entirely. + +The exact set is whatever the metadata says today. When .NET 12, 13, or later eventually occupy these roles, they are discovered automatically with no skill change. `.NET 10`/`.NET 11` in any example are illustrative only. + +## Image selection + +Auto-generated environments always use Microsoft's official SDK repository: + +``` +mcr.microsoft.com/dotnet/sdk +``` + +The runner prefers an **exact SDK-version image tag** derived from `latest-sdk` over a moving channel tag, so an execution pins to a specific SDK: + +- Stable: `latest-sdk` `10.0.302` → tag `10.0.302`. +- Preview: `latest-sdk` `11.0.100-preview.6.26359.118` → tag `11.0.100-preview.6` (build metadata is stripped because Microsoft's preview image tags do not include it). + +Because a version string does not always transform mechanically into a valid tag, the selected tag is validated against Microsoft's official SDK image metadata (the `mcr.microsoft.com` registry) before execution. If the exact tag is unavailable, the channel tag (`10.0`) is tried as a fallback candidate. + +Third-party images, unofficial Docker Hub images, and locally discovered look-alike images are never substituted for auto-generated environments. An explicit `dockerImage` in `testenvironments.json` is the only exception, because it is deliberate configuration. + +## Immutable image identity + +A tag is a convenient selector but not an immutable identity. For every execution the runner: + +1. resolves/pulls the requested image, +2. determines its immutable image digest, +3. executes against that resolved identity, and +4. reports both the human-readable image/tag and the resolved digest. + +The image identity never changes mid-execution, so a result is reproducible in terms of environment, requested image, resolved digest, .NET SDK, architecture, and runner version. + +## Target-framework awareness + +The runner inspects the solution/projects being tested and will not select an SDK that cannot build the requested target framework: + +- A channel SDK builds its own major and every lower one; it cannot build a newer runtime major. +- A Linux .NET SDK container cannot build .NET Framework (`net4x`) targets. +- An existing `global.json` pin is respected (never modified). If the repository requires an SDK that no supported Microsoft image can satisfy, the incompatibility is reported. + +Multi-targeted projects are accounted for. The runner never edits `global.json`, project files, or target frameworks to make remote testing succeed. + +## Offline behavior and caching + +Successfully retrieved release metadata is cached outside the repository together with the retrieval timestamp. When Microsoft cannot be reached: + +- the most recently validated cache is used, and the result clearly states that cached metadata is in use; +- current release information is never fabricated. + +If no authoritative metadata and no valid cache exist, automatic environment discovery fails with a useful explanation. Explicit `testenvironments.json` environments remain usable without release-index discovery whenever their Docker image can be resolved. Pass `--offline` to force cache-only behavior, or `--releases-index-file ` to supply metadata from a local file. diff --git a/skills/dotnet-remote-testing/references/testenvironments-json.md b/skills/dotnet-remote-testing/references/testenvironments-json.md new file mode 100644 index 0000000..caa47c3 --- /dev/null +++ b/skills/dotnet-remote-testing/references/testenvironments-json.md @@ -0,0 +1,70 @@ +# testenvironments.json — the configuration contract + +Remote testing honors Microsoft's existing [`testenvironments.json`](https://learn.microsoft.com/en-us/visualstudio/test/remote-testing?view=visualstudio) file rather than inventing a competing format. The runner searches for it at the workspace/solution/repository root and then in ancestor directories, or you can point at it explicitly with `--config-path`. + +The skill never modifies `testenvironments.json` unless the user explicitly asks. + +## Version + +Only `version` `"1"` is supported. Any other version is reported (`UNSUPPORTED_VERSION`) instead of guessed. + +```json +{ + "version": "1", + "environments": [ + { + "name": "dotnet-10", + "type": "docker", + "dockerImage": "mcr.microsoft.com/dotnet/sdk:10.0" + } + ] +} +``` + +## Environment properties + +The runner recognizes Microsoft's version-1 properties: + +| Property | Support | Notes | +|---|---|---| +| `name` | Supported | Unique, user-friendly name shown in listings and selected with `--environment`. | +| `type` | `docker` supported | `wsl` and `ssh` are recognized and reported as unsupported (Docker only for now). Unknown types are reported too. | +| `dockerImage` | Supported | Name/tag of a Docker image. Required for a `docker` environment that does not use `dockerFile`. | +| `dockerFile` | Supported | Path to an existing Dockerfile (relative to the solution/repo root), built into a local image. Never generated. | +| `localRoot` | Honored | Local path projected into the environment. When set, it becomes the source root that is staged. Defaults to the repo/solution root. | +| `wslDistribution` | Reported unsupported | Present only so WSL environments are surfaced, not run. | +| `remoteUri` | Reported unsupported | Present only so SSH environments are surfaced, not run. | + +### Docker source rule + +Following Microsoft's rule, a `docker` environment must specify **either** `dockerImage` **or** `dockerFile`, never both: + +- Both present → `CONFLICTING_DOCKER_SOURCE` and the environment is not usable. +- Neither present → `MISSING_DOCKER_SOURCE`. + +### Configured images are deliberate + +An explicit `dockerImage` in `testenvironments.json` is exempt from the Microsoft-only restriction that governs auto-generated environments, because it represents intentional repository configuration. The runner uses it as written (after pulling and resolving its digest). Auto-generated environments, by contrast, always use `mcr.microsoft.com/dotnet/sdk`. + +### Configured Dockerfiles are honored, never created + +If an environment points at `dockerFile`, the runner builds that existing Dockerfile into a local image and runs against it. The skill will **never** create a Dockerfile (or `docker-compose.yml`, `.devcontainer/`, editor config, or other plumbing) to make remote testing work. The distinction is intentional: + +``` +Existing configured Dockerfile: supported. +Skill-generated Dockerfile: prohibited. +``` + +## Authoritative precedence + +When `testenvironments.json` exists it is authoritative — the runner does not silently supplement it with Microsoft-derived environments. Resolution precedence is: + +1. An environment explicitly named by the user. +2. An applicable Docker environment from `testenvironments.json`. +3. Microsoft-derived environments only when no `testenvironments.json` exists. + +If multiple configured Docker environments exist and none is named, the runner lists them and asks for a selection rather than guessing. If exactly one applies, it is used. + +## Unsupported environments + +WSL/SSH/unknown environments are reported clearly (with the offending type) instead of being silently ignored or converted. Naming an unsupported environment returns an `UnsupportedEnvironment` result, not a "not found". diff --git a/skills/dotnet-remote-testing/scripts/remote-test.cs b/skills/dotnet-remote-testing/scripts/remote-test.cs new file mode 100644 index 0000000..ccc424d --- /dev/null +++ b/skills/dotnet-remote-testing/scripts/remote-test.cs @@ -0,0 +1,2915 @@ +#:property TargetFramework=net10.0 +#:property Nullable=enable +#:property LangVersion=latest +#:property PublishAot=false + +// dotnet-remote-testing deterministic runner. +// +// This file is the *execution layer* for the dotnet-remote-testing skill. The AI skill is only the +// orchestration layer: it decides intent (list, plan, run) and which environment the developer means, +// then hands the actual work to this program. Everything between "choose an environment" and "see +// results" — configuration discovery, .NET release discovery, image resolution, source staging, NuGet +// caching, container execution, result collection, failure classification, and cleanup — lives here so +// remote testing stays deterministic and reproducible instead of being re-improvised on every call. +// +// Docker is the first (and currently only) supported transport. The code is organized so other +// transports (WSL, SSH, ...) could be added later without disturbing the Docker path, but none of that +// is implemented now on purpose. + +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +return await RemoteTestProgram.RunAsync(args); + +internal static class RemoteTestProgram +{ + internal const string ToolName = "dotnet-remote-testing"; + internal const string SdkRepository = "mcr.microsoft.com/dotnet/sdk"; + internal const string SdkRepositoryPath = "dotnet/sdk"; + internal const string ReleasesIndexUrl = + "https://raw.githubusercontent.com/dotnet/core/refs/heads/main/release-notes/releases-index.json"; + + internal static readonly JsonSerializerOptions JsonOut = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }; + + public static async Task RunAsync(string[] args) + { + Options options; + try + { + options = Options.Parse(args); + } + catch (OptionException ex) + { + Console.Error.WriteLine($"{ToolName}: {ex.Message}"); + Options.PrintUsage(Console.Error); + return (int)ExitCode.InvalidArguments; + } + + if (options.ShowHelp) + { + Options.PrintUsage(Console.Out); + return (int)ExitCode.Success; + } + + try + { + return options.Command switch + { + Command.SelfTest => SelfTest.Run(), + Command.List => await Commands.ListAsync(options), + Command.Plan => await Commands.PlanAsync(options), + Command.Run => await Commands.RunAsync(options), + _ => Fail(options, ExitCode.InvalidArguments, "No command specified. Use list, plan, run, or --self-test."), + }; + } + catch (OperationCanceledException) + { + return (int)ExitCode.Cancelled; + } + catch (Exception ex) + { + return Fail(options, ExitCode.ResultProcessing, $"Unhandled error: {ex.Message}"); + } + } + + private static int Fail(Options options, ExitCode code, string message) + { + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new { tool = ToolName, status = "error", failureKind = code.ToString(), message }, + JsonOut)); + } + else + { + Console.Error.WriteLine($"{ToolName}: {message}"); + } + + return (int)code; + } +} + +// External exit codes. These map onto the failure taxonomy required by the skill so a caller (human or +// CI) can tell a container/infrastructure problem apart from an actual failing unit test. +internal enum ExitCode +{ + Success = 0, + TestFailures = 1, + InvalidArguments = 2, + Configuration = 3, + UnsupportedEnvironment = 4, + DockerUnavailable = 5, + ImageResolution = 6, + SdkIncompatibility = 7, + SourceStaging = 8, + Restore = 9, + Compilation = 10, + TestHost = 11, + ResultProcessing = 12, + Cleanup = 13, + Cancelled = 14, + ReleaseMetadataUnavailable = 15, + SelectionRequired = 16, +} + +internal enum Command { None, List, Plan, Run, SelfTest } + +internal sealed class OptionException(string message) : Exception(message); + +internal sealed class Options +{ + public Command Command { get; private set; } = Command.None; + public bool ShowHelp { get; private set; } + public bool Json { get; private set; } + public bool Offline { get; private set; } + public bool NoRegistryCheck { get; private set; } + + public string RepoRoot { get; private set; } = Directory.GetCurrentDirectory(); + public string? ConfigPath { get; private set; } + public string? EnvironmentName { get; private set; } + public string? ReleasesIndexFile { get; private set; } + public string? CacheRoot { get; private set; } + + // Test scoping. These flow into the container command plan; they never mutate the repository. + public string? Project { get; private set; } + public string? Filter { get; private set; } + public string? Test { get; private set; } + public string Configuration { get; private set; } = "Debug"; + public string? Framework { get; private set; } + public bool Coverage { get; private set; } + public int TimeoutSeconds { get; private set; } + + public static Options Parse(string[] args) + { + var o = new Options(); + for (var i = 0; i < args.Length; i++) + { + var a = args[i]; + switch (a) + { + case "list": o.SetCommand(Command.List); break; + case "plan": o.SetCommand(Command.Plan); break; + case "run": o.SetCommand(Command.Run); break; + case "--self-test": o.SetCommand(Command.SelfTest); break; + case "-h" or "--help": o.ShowHelp = true; break; + case "--json": o.Json = true; break; + case "--offline": o.Offline = true; break; + case "--no-registry-check": o.NoRegistryCheck = true; break; + case "--coverage": o.Coverage = true; break; + case "--repo-root": o.RepoRoot = Path.GetFullPath(Next(args, ref i, a)); break; + case "--config-path": o.ConfigPath = Next(args, ref i, a); break; + case "--environment" or "-e": o.EnvironmentName = Next(args, ref i, a); break; + case "--releases-index-file": o.ReleasesIndexFile = Next(args, ref i, a); break; + case "--cache-root": o.CacheRoot = Next(args, ref i, a); break; + case "--project" or "-p": o.Project = Next(args, ref i, a); break; + case "--filter": o.Filter = Next(args, ref i, a); break; + case "--test": o.Test = Next(args, ref i, a); break; + case "--configuration" or "-c": o.Configuration = Next(args, ref i, a); break; + case "--framework" or "-f": o.Framework = Next(args, ref i, a); break; + case "--timeout": o.TimeoutSeconds = ParseInt(Next(args, ref i, a)); break; + default: + if (a.StartsWith("--repo-root=", StringComparison.Ordinal)) { o.RepoRoot = Path.GetFullPath(a["--repo-root=".Length..]); break; } + throw new OptionException($"Unknown argument: {a}"); + } + } + + if (o.Command == Command.None && !o.ShowHelp) + { + throw new OptionException("No command specified."); + } + + return o; + } + + private void SetCommand(Command c) + { + if (Command != Command.None) + { + throw new OptionException($"Only one command may be specified (already '{Command}')."); + } + + Command = c; + } + + private static string Next(string[] args, ref int i, string flag) + { + if (i + 1 >= args.Length) + { + throw new OptionException($"Missing value for {flag}."); + } + + return args[++i]; + } + + private static int ParseInt(string s) => + int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) && v >= 0 + ? v + : throw new OptionException($"Invalid integer: {s}"); + + public string CacheDirectory => CacheRoot ?? DefaultCacheRoot(); + + private static string DefaultCacheRoot() + { + var baseDir = Environment.GetEnvironmentVariable("LOCALAPPDATA") + ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(baseDir)) + { + baseDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache"); + } + + return Path.Combine(baseDir, "dotnet-remote-testing"); + } + + public static void PrintUsage(TextWriter w) => w.WriteLine( + $$""" + {{RemoteTestProgram.ToolName}} - run .NET tests in a resolved remote (Docker) environment. + + Usage: + dotnet run --file remote-test.cs -- [options] + + Commands: + list List available remote test environments (configured or Microsoft-derived). + plan Resolve an environment + image and print the deterministic execution plan without running. + run Execute restore/build/test inside the resolved Docker environment. + --self-test Run the built-in deterministic unit tests and exit. + + Selection: + -e, --environment Select an environment by name. + --config-path Path to testenvironments.json (default: search from --repo-root upward). + --repo-root Workspace/solution root (default: current directory). + + Test scoping (plan/run): + -p, --project Solution/project to test (relative to the source root). + --filter dotnet test --filter expression. + --test Shortcut for --filter "FullyQualifiedName~". + -c, --configuration Build configuration (default: Debug). + -f, --framework Restrict multi-targeted test projects to one TFM. + --coverage Collect code coverage (XPlat Code Coverage) when the project supports it. + --timeout Abort the container run after N seconds (0 = no timeout). + + Release discovery / networking: + --offline Use cached release metadata only; never reach the network. + --no-registry-check Skip Docker registry tag validation and digest pre-resolution. + --releases-index-file Load Microsoft release metadata from a local file instead of the network. + --cache-root Override the metadata/NuGet cache root (outside the repository). + + Output: + --json Emit machine-readable JSON. + -h, --help Show this help. + + Exit codes: 0 success, 1 test failures, 2 invalid args, 3 configuration, 4 unsupported environment, + 5 docker unavailable, 6 image resolution, 7 sdk incompatibility, 8 source staging, 9 restore, + 10 compilation, 11 test host, 12 result processing, 13 cleanup, 14 cancelled, + 15 release metadata unavailable, 16 selection required. + """); +} + +// --------------------------------------------------------------------------------------------------- +// testenvironments.json — Microsoft's version 1 configuration contract. +// We honor the existing schema rather than invent a competing format. Only Docker is supported now; +// WSL/SSH are recognized so we can report them as unsupported instead of silently ignoring them. +// --------------------------------------------------------------------------------------------------- + +internal enum EnvironmentType { Docker, Wsl, Ssh, Unknown } + +internal sealed record EnvironmentDefinition +{ + public required string Name { get; init; } + public EnvironmentType Type { get; init; } + public string RawType { get; init; } = ""; + public string? DockerImage { get; init; } + public string? DockerFile { get; init; } + public string? LocalRoot { get; init; } + public string? WslDistribution { get; init; } + public string? RemoteUri { get; init; } + + // A configured environment is deliberate repository intent, so its dockerImage is exempt from the + // Microsoft-only image restriction that governs auto-generated environments. + public bool IsConfigured => true; +} + +internal sealed record ConfigDiagnostic(string Code, string Message, string? EnvironmentName = null); + +internal sealed record TestEnvironmentsConfig +{ + public string? Version { get; init; } + public IReadOnlyList Environments { get; init; } = []; + public IReadOnlyList Diagnostics { get; init; } = []; + public string? SourcePath { get; init; } + + public bool VersionSupported => Version == "1"; + + // Docker environments that are structurally valid enough to use. + public IReadOnlyList SupportedDockerEnvironments => + [.. Environments.Where(e => e.Type == EnvironmentType.Docker + && !string.IsNullOrWhiteSpace(e.Name) + && (!string.IsNullOrWhiteSpace(e.DockerImage) ^ !string.IsNullOrWhiteSpace(e.DockerFile)))]; + + public IReadOnlyList UnsupportedEnvironments => + [.. Environments.Where(e => e.Type is EnvironmentType.Wsl or EnvironmentType.Ssh or EnvironmentType.Unknown)]; +} + +internal static class TestEnvironmentsConfigReader +{ + // Search order: explicit --config-path, then testenvironments.json at repoRoot, then nearest ancestor. + public static string? Locate(string repoRoot, string? explicitPath) + { + if (!string.IsNullOrWhiteSpace(explicitPath)) + { + return File.Exists(explicitPath) ? Path.GetFullPath(explicitPath) : null; + } + + var dir = new DirectoryInfo(repoRoot); + while (dir is not null) + { + var candidate = Path.Combine(dir.FullName, "testenvironments.json"); + if (File.Exists(candidate)) + { + return candidate; + } + + dir = dir.Parent; + } + + return null; + } + + public static TestEnvironmentsConfig Parse(string json, string? sourcePath = null) + { + var diagnostics = new List(); + var environments = new List(); + string? version = null; + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json); + } + catch (JsonException ex) + { + return new TestEnvironmentsConfig + { + SourcePath = sourcePath, + Diagnostics = [new ConfigDiagnostic("INVALID_JSON", $"testenvironments.json is not valid JSON: {ex.Message}")], + }; + } + + using (doc) + { + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + diagnostics.Add(new ConfigDiagnostic("INVALID_ROOT", "testenvironments.json must be a JSON object.")); + return new TestEnvironmentsConfig { SourcePath = sourcePath, Diagnostics = diagnostics }; + } + + if (root.TryGetProperty("version", out var v)) + { + version = v.ValueKind == JsonValueKind.String ? v.GetString() : v.GetRawText(); + } + + if (version != "1") + { + diagnostics.Add(new ConfigDiagnostic("UNSUPPORTED_VERSION", + $"Only testenvironments.json version \"1\" is supported; found {version ?? ""}.")); + } + + if (root.TryGetProperty("environments", out var envs) && envs.ValueKind == JsonValueKind.Array) + { + var index = 0; + foreach (var e in envs.EnumerateArray()) + { + index++; + var name = GetString(e, "name"); + var rawType = GetString(e, "type") ?? ""; + var type = ParseType(rawType); + var def = new EnvironmentDefinition + { + Name = name ?? $"", + Type = type, + RawType = rawType, + DockerImage = GetString(e, "dockerImage"), + DockerFile = GetString(e, "dockerFile"), + LocalRoot = GetString(e, "localRoot"), + WslDistribution = GetString(e, "wslDistribution"), + RemoteUri = GetString(e, "remoteUri"), + }; + environments.Add(def); + + if (string.IsNullOrWhiteSpace(name)) + { + diagnostics.Add(new ConfigDiagnostic("MISSING_NAME", $"Environment #{index} has no name.", def.Name)); + } + + switch (type) + { + case EnvironmentType.Docker: + var hasImage = !string.IsNullOrWhiteSpace(def.DockerImage); + var hasFile = !string.IsNullOrWhiteSpace(def.DockerFile); + if (hasImage && hasFile) + { + diagnostics.Add(new ConfigDiagnostic("CONFLICTING_DOCKER_SOURCE", + "A docker environment uses either dockerImage or dockerFile, not both.", def.Name)); + } + else if (!hasImage && !hasFile) + { + diagnostics.Add(new ConfigDiagnostic("MISSING_DOCKER_SOURCE", + "A docker environment requires dockerImage or dockerFile.", def.Name)); + } + + break; + case EnvironmentType.Wsl: + case EnvironmentType.Ssh: + diagnostics.Add(new ConfigDiagnostic("UNSUPPORTED_TYPE", + $"Environment type '{rawType}' is defined by Microsoft but not supported yet (Docker only).", def.Name)); + break; + default: + diagnostics.Add(new ConfigDiagnostic("UNKNOWN_TYPE", + $"Unknown environment type '{rawType}'.", def.Name)); + break; + } + } + } + } + + return new TestEnvironmentsConfig + { + Version = version, + Environments = environments, + Diagnostics = diagnostics, + SourcePath = sourcePath, + }; + } + + private static string? GetString(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + + private static EnvironmentType ParseType(string raw) => raw.Trim().ToLowerInvariant() switch + { + "docker" => EnvironmentType.Docker, + "wsl" => EnvironmentType.Wsl, + "ssh" => EnvironmentType.Ssh, + _ => EnvironmentType.Unknown, + }; +} + +// --------------------------------------------------------------------------------------------------- +// Microsoft .NET release discovery (releases-index.json). +// The lifecycle status comes from the metadata itself — never from assumptions like "even = LTS" or +// "highest = preview". We treat support-phase and release-type as the contract. +// --------------------------------------------------------------------------------------------------- + +internal sealed record ReleaseChannel +{ + public required string ChannelVersion { get; init; } + public string? LatestRelease { get; init; } + public string? LatestReleaseDate { get; init; } + public string? LatestRuntime { get; init; } + public string? LatestSdk { get; init; } + public string SupportPhase { get; init; } = ""; + public string ReleaseType { get; init; } = ""; + public string? EolDate { get; init; } + public string? Product { get; init; } + + public bool IsEol => string.Equals(SupportPhase, "eol", StringComparison.OrdinalIgnoreCase); + public bool IsPreview => string.Equals(SupportPhase, "preview", StringComparison.OrdinalIgnoreCase) + || string.Equals(SupportPhase, "go-live", StringComparison.OrdinalIgnoreCase); + + public bool IsLts => string.Equals(ReleaseType, "lts", StringComparison.OrdinalIgnoreCase); + public bool IsSts => string.Equals(ReleaseType, "sts", StringComparison.OrdinalIgnoreCase); + + // A stable, supported channel: not EOL, not preview, and an LTS or STS release type. + public bool IsSupportedStable => !IsEol && !IsPreview && (IsLts || IsSts); + + public int MajorVersion => + int.TryParse(ChannelVersion.Split('.')[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var m) ? m : 0; +} + +internal sealed record ReleaseMetadata +{ + public IReadOnlyList Channels { get; init; } = []; + public DateTimeOffset RetrievedAt { get; init; } + public string Source { get; init; } = ""; + public bool IsStale { get; init; } + + public IReadOnlyList SupportedStableChannels => + [.. Channels.Where(c => c.IsSupportedStable).OrderByDescending(c => c.MajorVersion)]; + + public IReadOnlyList PreviewChannels => + [.. Channels.Where(c => c.IsPreview && !c.IsEol).OrderByDescending(c => c.MajorVersion)]; +} + +internal static class ReleaseIndexReader +{ + public static IReadOnlyList Parse(string json) + { + var channels = new List(); + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("releases-index", out var arr) || arr.ValueKind != JsonValueKind.Array) + { + return channels; + } + + foreach (var e in arr.EnumerateArray()) + { + var channelVersion = Str(e, "channel-version"); + if (string.IsNullOrWhiteSpace(channelVersion)) + { + continue; + } + + channels.Add(new ReleaseChannel + { + ChannelVersion = channelVersion, + LatestRelease = Str(e, "latest-release"), + LatestReleaseDate = Str(e, "latest-release-date"), + LatestRuntime = Str(e, "latest-runtime"), + LatestSdk = Str(e, "latest-sdk"), + SupportPhase = Str(e, "support-phase") ?? "", + ReleaseType = Str(e, "release-type") ?? "", + EolDate = Str(e, "eol-date"), + Product = Str(e, "product"), + }); + } + + return channels; + } + + private static string? Str(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; +} + +// A parsed .NET SDK version: major.minor.feature with an optional prerelease label such as +// "preview.6" or "rc.1". Build metadata (the trailing ".26359.118") is intentionally discarded because +// Microsoft's SDK container image tags do not include it. +internal sealed record SdkVersion(int Major, int Minor, int Feature, string? PreLabel, int? PreNumber, string Raw) +{ + private static readonly Regex Pattern = new( + @"^(?\d+)\.(?\d+)\.(?\d+)(?:-(?
[a-zA-Z]+)\.(?\d+))?",
+        RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+    public bool IsPrerelease => PreLabel is not null;
+
+    public static SdkVersion? TryParse(string? value)
+    {
+        if (string.IsNullOrWhiteSpace(value))
+        {
+            return null;
+        }
+
+        var m = Pattern.Match(value.Trim());
+        if (!m.Success)
+        {
+            return null;
+        }
+
+        return new SdkVersion(
+            int.Parse(m.Groups["major"].Value, CultureInfo.InvariantCulture),
+            int.Parse(m.Groups["minor"].Value, CultureInfo.InvariantCulture),
+            int.Parse(m.Groups["feature"].Value, CultureInfo.InvariantCulture),
+            m.Groups["pre"].Success ? m.Groups["pre"].Value : null,
+            m.Groups["pren"].Success ? int.Parse(m.Groups["pren"].Value, CultureInfo.InvariantCulture) : null,
+            value.Trim());
+    }
+
+    // The image-compatible SDK tag. Stable: "10.0.302". Preview: "11.0.100-preview.6".
+    public string ImageTag => IsPrerelease
+        ? $"{Major}.{Minor}.{Feature}-{PreLabel}.{PreNumber}"
+        : $"{Major}.{Minor}.{Feature}";
+
+    public string ChannelTag => $"{Major}.{Minor}";
+}
+
+internal static class ImageTagResolver
+{
+    // Ordered candidate tags for an SDK. The exact SDK-version tag is preferred over the moving channel
+    // tag so an execution pins to a specific SDK rather than "whatever the channel points at today".
+    public static IReadOnlyList CandidateTags(SdkVersion sdk)
+    {
+        var tags = new List { sdk.ImageTag };
+        if (!tags.Contains(sdk.ChannelTag))
+        {
+            tags.Add(sdk.ChannelTag);
+        }
+
+        return tags;
+    }
+
+    public static string SdkImageReference(string tag) => $"{RemoteTestProgram.SdkRepository}:{tag}";
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Environment resolution.
+// A resolved environment is what the runner actually executes against, whether it came from
+// testenvironments.json or was generated from Microsoft release metadata.
+// ---------------------------------------------------------------------------------------------------
+
+internal enum EnvironmentOrigin { Configured, Generated }
+
+internal sealed record ResolvedEnvironment
+{
+    public required string Name { get; init; }
+    public EnvironmentOrigin Origin { get; init; }
+    public string? Channel { get; init; }
+    public string? ReleaseType { get; init; }   // LTS / STS / Preview (for generated) 
+    public string? Sdk { get; init; }
+
+    // Exactly one of these is set for a runnable Docker environment.
+    public string? DockerImage { get; init; }
+    public string? DockerFile { get; init; }
+
+    public string? LocalRoot { get; init; }
+
+    // Whether this environment's image is exempt from the Microsoft-only restriction (configured images
+    // are deliberate; generated images must come from mcr.microsoft.com/dotnet/sdk).
+    public bool ImageIsConfigured => Origin == EnvironmentOrigin.Configured;
+}
+
+internal static class GeneratedEnvironments
+{
+    // Turn Microsoft release metadata into ready-to-run zero-configuration environments. No files are
+    // ever written to the repository to make these exist.
+    public static IReadOnlyList FromMetadata(ReleaseMetadata metadata)
+    {
+        var result = new List();
+
+        foreach (var c in metadata.SupportedStableChannels)
+        {
+            var sdk = SdkVersion.TryParse(c.LatestSdk);
+            var suffix = c.IsLts ? "lts" : "sts";
+            result.Add(new ResolvedEnvironment
+            {
+                Name = $"dotnet-{c.MajorVersion}-{suffix}",
+                Origin = EnvironmentOrigin.Generated,
+                Channel = c.ChannelVersion,
+                ReleaseType = c.ReleaseType.ToUpperInvariant(),
+                Sdk = c.LatestSdk,
+                DockerImage = sdk is null ? null : ImageTagResolver.SdkImageReference(sdk.ImageTag),
+            });
+        }
+
+        foreach (var c in metadata.PreviewChannels)
+        {
+            var sdk = SdkVersion.TryParse(c.LatestSdk);
+            result.Add(new ResolvedEnvironment
+            {
+                Name = $"dotnet-{c.MajorVersion}-preview",
+                Origin = EnvironmentOrigin.Generated,
+                Channel = c.ChannelVersion,
+                ReleaseType = "Preview",
+                Sdk = c.LatestSdk,
+                DockerImage = sdk is null ? null : ImageTagResolver.SdkImageReference(sdk.ImageTag),
+            });
+        }
+
+        return result;
+    }
+
+    public static ResolvedEnvironment FromConfigured(EnvironmentDefinition def) => new()
+    {
+        Name = def.Name,
+        Origin = EnvironmentOrigin.Configured,
+        DockerImage = def.DockerImage,
+        DockerFile = def.DockerFile,
+        LocalRoot = def.LocalRoot,
+    };
+}
+
+internal enum ResolutionStatus { Resolved, NotFound, Ambiguous, Unsupported, NoEnvironments }
+
+internal sealed record EnvironmentResolution
+{
+    public ResolutionStatus Status { get; init; }
+    public ResolvedEnvironment? Environment { get; init; }
+    public IReadOnlyList Candidates { get; init; } = [];
+    public string? Message { get; init; }
+}
+
+internal static class EnvironmentResolver
+{
+    // Deterministic precedence:
+    //   1. An environment explicitly named by the user (configured first, then generated).
+    //   2. An applicable Docker environment from testenvironments.json (authoritative when present).
+    //   3. Microsoft-derived environments when no testenvironments.json exists.
+    public static EnvironmentResolution Resolve(
+        TestEnvironmentsConfig? config,
+        IReadOnlyList generated,
+        string? requestedName)
+    {
+        var configured = config?.SupportedDockerEnvironments ?? [];
+
+        if (!string.IsNullOrWhiteSpace(requestedName))
+        {
+            var byConfig = configured.FirstOrDefault(e =>
+                string.Equals(e.Name, requestedName, StringComparison.OrdinalIgnoreCase));
+            if (byConfig is not null)
+            {
+                return Resolved(GeneratedEnvironments.FromConfigured(byConfig));
+            }
+
+            // A configured but unsupported environment was named explicitly: report it as unsupported
+            // rather than pretending it does not exist.
+            var unsupported = config?.UnsupportedEnvironments.FirstOrDefault(e =>
+                string.Equals(e.Name, requestedName, StringComparison.OrdinalIgnoreCase));
+            if (unsupported is not null)
+            {
+                return new EnvironmentResolution
+                {
+                    Status = ResolutionStatus.Unsupported,
+                    Message = $"Environment '{requestedName}' has unsupported type '{unsupported.RawType}'. Only Docker is supported.",
+                };
+            }
+
+            var byGenerated = generated.FirstOrDefault(e =>
+                string.Equals(e.Name, requestedName, StringComparison.OrdinalIgnoreCase));
+            if (byGenerated is not null)
+            {
+                return Resolved(byGenerated);
+            }
+
+            return new EnvironmentResolution
+            {
+                Status = ResolutionStatus.NotFound,
+                Candidates = [.. configured.Select(e => e.Name), .. generated.Select(e => e.Name)],
+                Message = $"No environment named '{requestedName}' was found.",
+            };
+        }
+
+        // No explicit name. If testenvironments.json exists, it is authoritative — do not supplement it
+        // with generated environments.
+        if (config is not null && config.SourcePath is not null)
+        {
+            if (configured.Count == 1)
+            {
+                return Resolved(GeneratedEnvironments.FromConfigured(configured[0]));
+            }
+
+            if (configured.Count > 1)
+            {
+                return new EnvironmentResolution
+                {
+                    Status = ResolutionStatus.Ambiguous,
+                    Candidates = [.. configured.Select(e => e.Name)],
+                    Message = "Multiple Docker environments are configured. Select one with --environment .",
+                };
+            }
+
+            // A config exists but defines no usable Docker environment.
+            return new EnvironmentResolution
+            {
+                Status = ResolutionStatus.NoEnvironments,
+                Message = "testenvironments.json defines no supported Docker environment.",
+            };
+        }
+
+        // Zero-configuration: use Microsoft-derived environments.
+        if (generated.Count == 0)
+        {
+            return new EnvironmentResolution
+            {
+                Status = ResolutionStatus.NoEnvironments,
+                Message = "No environments could be derived from Microsoft release metadata.",
+            };
+        }
+
+        if (generated.Count == 1)
+        {
+            return Resolved(generated[0]);
+        }
+
+        return new EnvironmentResolution
+        {
+            Status = ResolutionStatus.Ambiguous,
+            Candidates = [.. generated.Select(e => e.Name)],
+            Message = "Multiple Microsoft-derived environments are available. Select one with --environment .",
+        };
+    }
+
+    private static EnvironmentResolution Resolved(ResolvedEnvironment env) =>
+        new() { Status = ResolutionStatus.Resolved, Environment = env };
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Target-framework awareness.
+// We must not pick an SDK that cannot build the requested target framework, and we must respect an
+// existing global.json rather than modifying it. We never rewrite project files or TFMs.
+// ---------------------------------------------------------------------------------------------------
+
+internal sealed record TargetFrameworkInfo
+{
+    public IReadOnlyList TargetFrameworks { get; init; } = [];
+    public string? GlobalJsonSdkVersion { get; init; }
+    public string? GlobalJsonRollForward { get; init; }
+
+    public IReadOnlyList NetCoreMajors =>
+        [.. TargetFrameworks.Select(TargetFrameworkInspector.NetMajor).Where(m => m > 0).Distinct().OrderBy(m => m)];
+
+    public bool HasNetFramework => TargetFrameworks.Any(t =>
+        t.StartsWith("net4", StringComparison.OrdinalIgnoreCase)
+        || t.StartsWith("net3", StringComparison.OrdinalIgnoreCase) && !t.Contains('.'));
+}
+
+internal sealed record SdkCompatibility(bool Compatible, string? Reason);
+
+internal static class TargetFrameworkInspector
+{
+    private static readonly Regex TfmRegex = new(
+        @"(?[^<]+)",
+        RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+    // netX.0 / netcoreappX.0 → X; netstandard/net4x → 0 (not a modern runnable major).
+    public static int NetMajor(string tfm)
+    {
+        tfm = tfm.Trim();
+        var m = Regex.Match(tfm, @"^net(?:coreapp)?(?\d+)\.\d+", RegexOptions.IgnoreCase);
+        return m.Success ? int.Parse(m.Groups["maj"].Value, CultureInfo.InvariantCulture) : 0;
+    }
+
+    public static IReadOnlyList ExtractTargetFrameworks(string projectXml)
+    {
+        var set = new List();
+        foreach (Match m in TfmRegex.Matches(projectXml))
+        {
+            foreach (var tfm in m.Groups["v"].Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+            {
+                if (!tfm.Contains("$(") && !set.Contains(tfm))
+                {
+                    set.Add(tfm);
+                }
+            }
+        }
+
+        return set;
+    }
+
+    public static (string? sdkVersion, string? rollForward) ParseGlobalJson(string json)
+    {
+        try
+        {
+            using var doc = JsonDocument.Parse(json);
+            if (doc.RootElement.TryGetProperty("sdk", out var sdk) && sdk.ValueKind == JsonValueKind.Object)
+            {
+                var version = sdk.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
+                var roll = sdk.TryGetProperty("rollForward", out var r) && r.ValueKind == JsonValueKind.String ? r.GetString() : null;
+                return (version, roll);
+            }
+        }
+        catch (JsonException)
+        {
+            // A malformed global.json is reported at execution time by the SDK; we do not fail discovery on it.
+        }
+
+        return (null, null);
+    }
+
+    // Inspect the source root for target frameworks and an optional global.json.
+    public static TargetFrameworkInfo Inspect(string sourceRoot, string? project)
+    {
+        var frameworks = new List();
+        IEnumerable projectFiles;
+        if (!string.IsNullOrWhiteSpace(project))
+        {
+            var full = Path.IsPathRooted(project) ? project : Path.Combine(sourceRoot, project);
+            projectFiles = File.Exists(full) ? [full] : [];
+        }
+        else
+        {
+            projectFiles = SafeEnumerate(sourceRoot, "*.csproj");
+        }
+
+        foreach (var file in projectFiles)
+        {
+            try
+            {
+                foreach (var tfm in ExtractTargetFrameworks(File.ReadAllText(file)))
+                {
+                    if (!frameworks.Contains(tfm))
+                    {
+                        frameworks.Add(tfm);
+                    }
+                }
+            }
+            catch (IOException)
+            {
+                // Ignore unreadable project files; discovery is best-effort.
+            }
+        }
+
+        string? sdkVersion = null, rollForward = null;
+        var globalJson = Path.Combine(sourceRoot, "global.json");
+        if (File.Exists(globalJson))
+        {
+            (sdkVersion, rollForward) = ParseGlobalJson(File.ReadAllText(globalJson));
+        }
+
+        return new TargetFrameworkInfo
+        {
+            TargetFrameworks = frameworks,
+            GlobalJsonSdkVersion = sdkVersion,
+            GlobalJsonRollForward = rollForward,
+        };
+    }
+
+    // Can a channel (identified by its SDK version) build these target frameworks? An SDK builds its own
+    // major and every lower one; it cannot build a newer runtime major, and the Linux SDK cannot build
+    // .NET Framework (net4x) targets.
+    public static SdkCompatibility CanBuild(SdkVersion? channelSdk, TargetFrameworkInfo tfms)
+    {
+        if (channelSdk is null)
+        {
+            return new SdkCompatibility(false, "The environment SDK version could not be determined.");
+        }
+
+        if (tfms.HasNetFramework)
+        {
+            return new SdkCompatibility(false,
+                "The project targets .NET Framework (net4x), which cannot be built by a Linux .NET SDK container.");
+        }
+
+        foreach (var major in tfms.NetCoreMajors)
+        {
+            if (major > channelSdk.Major)
+            {
+                return new SdkCompatibility(false,
+                    $"The project targets net{major}.0 but the selected SDK is {channelSdk.Major}.x and cannot build a newer runtime.");
+            }
+        }
+
+        // Honor an explicit global.json pin: the container SDK major must satisfy it.
+        var pinned = SdkVersion.TryParse(tfms.GlobalJsonSdkVersion);
+        if (pinned is not null
+            && string.Equals(tfms.GlobalJsonRollForward, "disable", StringComparison.OrdinalIgnoreCase)
+            && (pinned.Major != channelSdk.Major || pinned.Minor != channelSdk.Minor || pinned.Feature != channelSdk.Feature))
+        {
+            return new SdkCompatibility(false,
+                $"global.json pins SDK {pinned.Raw} with rollForward disabled, which the selected image SDK {channelSdk.Raw} does not satisfy.");
+        }
+
+        if (pinned is not null && pinned.Major > channelSdk.Major)
+        {
+            return new SdkCompatibility(false,
+                $"global.json requires SDK {pinned.Major}.x but the selected image provides {channelSdk.Major}.x.");
+        }
+
+        return new SdkCompatibility(true, null);
+    }
+
+    private static IEnumerable SafeEnumerate(string root, string pattern)
+    {
+        if (!Directory.Exists(root))
+        {
+            return [];
+        }
+
+        try
+        {
+            return Directory.EnumerateFiles(root, pattern, new EnumerationOptions
+            {
+                RecurseSubdirectories = true,
+                IgnoreInaccessible = true,
+                MatchCasing = MatchCasing.CaseInsensitive,
+            }).Where(p => !p.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")
+                && !p.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}"));
+        }
+        catch (IOException)
+        {
+            return [];
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Container command planning.
+// The docker command line and the in-container entrypoint are built here as pure functions so the plan
+// can be asserted deterministically (and printed by `plan`) without ever launching Docker.
+// ---------------------------------------------------------------------------------------------------
+
+internal sealed record ContainerMount(string HostPath, string ContainerPath, bool ReadOnly);
+
+internal sealed record TestCommandOptions
+{
+    public string? Target { get; init; }
+    public string Configuration { get; init; } = "Debug";
+    public string? Framework { get; init; }
+    public string? Filter { get; init; }
+    public bool Coverage { get; init; }
+    public string WorkDir { get; init; } = "/workspace";
+    public string NuGetDir { get; init; } = "/nuget";
+    public string ResultsDir { get; init; } = "/results";
+}
+
+internal sealed record ContainerPlan
+{
+    public required string Image { get; init; }
+    public required string ContainerName { get; init; }
+    public required IReadOnlyList Mounts { get; init; }
+    public required IReadOnlyDictionary Environment { get; init; }
+    public required string Entrypoint { get; init; }
+    public required IReadOnlyList DockerRunArgs { get; init; }
+}
+
+internal static class ContainerPlanner
+{
+    internal const string PhaseMarkerPrefix = "##RT_PHASE_END:";
+
+    // Resolve the dotnet test --filter expression. --test is sugar for a FullyQualifiedName contains match.
+    public static string? ResolveFilter(string? filter, string? test)
+    {
+        if (!string.IsNullOrWhiteSpace(filter))
+        {
+            return filter;
+        }
+
+        return string.IsNullOrWhiteSpace(test) ? null : $"FullyQualifiedName~{test}";
+    }
+
+    // The in-container script. Phases run in order; each emits a machine-readable end marker with its
+    // exit code so the host can classify restore vs build vs test outcomes precisely. restore/build stop
+    // the run on failure; test always runs to completion so a TRX is produced even when tests fail.
+    public static string BuildEntrypoint(TestCommandOptions o)
+    {
+        var target = string.IsNullOrWhiteSpace(o.Target) ? "" : $" {Shell.Quote(o.Target)}";
+        var fw = string.IsNullOrWhiteSpace(o.Framework) ? "" : $" --framework {Shell.Quote(o.Framework)}";
+        var cfg = $" -c {Shell.Quote(o.Configuration)}";
+        var filter = string.IsNullOrWhiteSpace(o.Filter) ? "" : $" --filter {Shell.Quote(o.Filter)}";
+        var coverage = o.Coverage ? " --collect \"XPlat Code Coverage\"" : "";
+
+        var sb = new StringBuilder();
+        sb.Append("set -o pipefail\n");
+        sb.Append($"export NUGET_PACKAGES={Shell.Quote(o.NuGetDir)}\n");
+        sb.Append("export DOTNET_CLI_TELEMETRY_OPTOUT=1\n");
+        sb.Append("export DOTNET_NOLOGO=1\n");
+        sb.Append("export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1\n");
+        sb.Append($"cd {Shell.Quote(o.WorkDir)} || {{ echo '{PhaseMarkerPrefix}staging:1##'; exit 8; }}\n");
+        sb.Append("run_phase() { name=\"$1\"; shift; \"$@\"; code=$?; echo \"" + PhaseMarkerPrefix + "${name}:${code}##\"; return $code; }\n");
+        sb.Append($"run_phase restore dotnet restore{target}{fw} || exit 9\n");
+        sb.Append($"run_phase build dotnet build{target}{cfg}{fw} --no-restore || exit 10\n");
+        sb.Append($"run_phase test dotnet test{target}{cfg}{fw} --no-build{filter}{coverage} --results-directory {Shell.Quote(o.ResultsDir)} --logger trx\n");
+        sb.Append("exit $?\n");
+        return sb.ToString();
+    }
+
+    public static ContainerPlan Build(
+        string image,
+        string containerName,
+        IReadOnlyList mounts,
+        TestCommandOptions test)
+    {
+        var env = new Dictionary
+        {
+            ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1",
+            ["DOTNET_NOLOGO"] = "1",
+            ["NUGET_PACKAGES"] = test.NuGetDir,
+        };
+
+        var entrypoint = BuildEntrypoint(test);
+        var args = new List { "run", "--rm", "--name", containerName, "-w", test.WorkDir };
+
+        // Security posture: no --privileged, no docker socket, no host profile mount, no port publishing.
+        foreach (var m in mounts)
+        {
+            var spec = $"type=bind,source={m.HostPath},target={m.ContainerPath}";
+            if (m.ReadOnly)
+            {
+                spec += ",readonly";
+            }
+
+            args.Add("--mount");
+            args.Add(spec);
+        }
+
+        foreach (var (k, v) in env)
+        {
+            args.Add("-e");
+            args.Add($"{k}={v}");
+        }
+
+        args.Add(image);
+        args.Add("bash");
+        args.Add("-c");
+        args.Add(entrypoint);
+
+        return new ContainerPlan
+        {
+            Image = image,
+            ContainerName = containerName,
+            Mounts = mounts,
+            Environment = env,
+            Entrypoint = entrypoint,
+            DockerRunArgs = args,
+        };
+    }
+
+    // A deterministic, knowable container name so cancellation and cleanup can always target it.
+    public static string ContainerName(string runId) =>
+        $"dotnet-remote-testing-{runId}";
+}
+
+internal static class Shell
+{
+    // Minimal POSIX single-quote quoting for values embedded in the container bash script.
+    public static string Quote(string value) =>
+        "'" + value.Replace("'", "'\\''", StringComparison.Ordinal) + "'";
+}
+
+// Only Microsoft-derived (generated) environments block on an SDK/target-framework incompatibility,
+// because their SDK version is known from release metadata. A configured environment's image SDK is not
+// statically known, so it is trusted as provided and validated at run time rather than failing planning.
+internal static class SdkCompatibilityPolicy
+{
+    public static bool IsBlocking(EnvironmentOrigin origin, bool compatible) =>
+        origin == EnvironmentOrigin.Generated && !compatible;
+}
+
+// Resolve the dotnet test target (project/solution) as a container-relative, forward-slashed path.
+// Preference: explicit --project, then a solution at the source root, then a single solution anywhere,
+// then a single project anywhere. Null means "let dotnet discover in the working directory".
+internal static class TargetResolver
+{
+    public static string? Resolve(string sourceRoot, string? project)
+    {
+        if (!string.IsNullOrWhiteSpace(project))
+        {
+            return project.Replace('\\', '/');
+        }
+
+        if (!Directory.Exists(sourceRoot))
+        {
+            return null;
+        }
+
+        var rootSolution = TopLevel(sourceRoot, "*.slnx").Concat(TopLevel(sourceRoot, "*.sln")).FirstOrDefault();
+        if (rootSolution is not null)
+        {
+            return Relative(sourceRoot, rootSolution);
+        }
+
+        var solutions = Recursive(sourceRoot, "*.slnx").Concat(Recursive(sourceRoot, "*.sln")).ToList();
+        if (solutions.Count == 1)
+        {
+            return Relative(sourceRoot, solutions[0]);
+        }
+
+        if (solutions.Count == 0)
+        {
+            var projects = Recursive(sourceRoot, "*.csproj").ToList();
+            if (projects.Count == 1)
+            {
+                return Relative(sourceRoot, projects[0]);
+            }
+        }
+
+        return null;
+    }
+
+    private static IEnumerable TopLevel(string root, string pattern) =>
+        Directory.EnumerateFiles(root, pattern, SearchOption.TopDirectoryOnly);
+
+    private static IEnumerable Recursive(string root, string pattern) =>
+        Directory.EnumerateFiles(root, pattern, new EnumerationOptions
+        {
+            RecurseSubdirectories = true,
+            IgnoreInaccessible = true,
+        }).Where(p => !p.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")
+            && !p.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}"));
+
+    private static string Relative(string root, string path) =>
+        Path.GetRelativePath(root, path).Replace('\\', '/');
+}
+
+// ---------------------------------------------------------------------------------------------------
+// TRX result parsing.
+// dotnet test writes Visual Studio TRX. We aggregate every TRX produced (multi-targeted test projects
+// emit one per TFM) into a single structured result, prioritizing actionable failure detail.
+// ---------------------------------------------------------------------------------------------------
+
+internal sealed record TestFailureDetail(string TestName, string? ClassName, string? Message, string? StackTrace);
+
+internal sealed record TestRunResult
+{
+    public int Total { get; init; }
+    public int Passed { get; init; }
+    public int Failed { get; init; }
+    public int Skipped { get; init; }
+    public int NotExecuted { get; init; }
+    public double DurationSeconds { get; init; }
+    public IReadOnlyList Failures { get; init; } = [];
+    public int TrxFilesParsed { get; init; }
+
+    public static TestRunResult Empty => new();
+
+    public TestRunResult Merge(TestRunResult other) => new()
+    {
+        Total = Total + other.Total,
+        Passed = Passed + other.Passed,
+        Failed = Failed + other.Failed,
+        Skipped = Skipped + other.Skipped,
+        NotExecuted = NotExecuted + other.NotExecuted,
+        DurationSeconds = DurationSeconds + other.DurationSeconds,
+        Failures = [.. Failures, .. other.Failures],
+        TrxFilesParsed = TrxFilesParsed + other.TrxFilesParsed,
+    };
+}
+
+internal static class TrxParser
+{
+    private static readonly XNamespace Ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010";
+
+    public static TestRunResult ParseFile(string path) => Parse(File.ReadAllText(path));
+
+    public static TestRunResult Parse(string trxXml)
+    {
+        var doc = XDocument.Parse(trxXml);
+        var root = doc.Root ?? throw new InvalidOperationException("TRX has no root element.");
+
+        // Map testId -> class name via TestDefinitions so failures carry their owning class.
+        var classById = new Dictionary(StringComparer.OrdinalIgnoreCase);
+        foreach (var ut in root.Descendants(Ns + "UnitTest"))
+        {
+            var id = ut.Attribute("id")?.Value;
+            var className = ut.Element(Ns + "TestMethod")?.Attribute("className")?.Value;
+            if (id is not null && className is not null)
+            {
+                classById[id] = className.Split(',')[0];
+            }
+        }
+
+        int passed = 0, failed = 0, skipped = 0, notExecuted = 0, total = 0;
+        var failures = new List();
+        double duration = 0;
+
+        var summary = root.Element(Ns + "ResultSummary")?.Element(Ns + "Counters");
+        if (summary is not null)
+        {
+            total = IntAttr(summary, "total");
+            passed = IntAttr(summary, "passed");
+            failed = IntAttr(summary, "failed");
+            notExecuted = IntAttr(summary, "notExecuted");
+        }
+
+        foreach (var r in root.Descendants(Ns + "UnitTestResult"))
+        {
+            var outcome = r.Attribute("outcome")?.Value ?? "";
+            duration += ParseDuration(r.Attribute("duration")?.Value);
+
+            if (string.Equals(outcome, "Failed", StringComparison.OrdinalIgnoreCase))
+            {
+                var testId = r.Attribute("testId")?.Value;
+                var testName = r.Attribute("testName")?.Value ?? "(unknown test)";
+                var error = r.Element(Ns + "Output")?.Element(Ns + "ErrorInfo");
+                failures.Add(new TestFailureDetail(
+                    testName,
+                    testId is not null && classById.TryGetValue(testId, out var cls) ? cls : null,
+                    error?.Element(Ns + "Message")?.Value?.Trim(),
+                    error?.Element(Ns + "StackTrace")?.Value?.Trim()));
+            }
+            else if (outcome is "NotExecuted" or "Skipped")
+            {
+                skipped++;
+            }
+        }
+
+        // notExecuted in the TRX counters is the authoritative skipped count when present.
+        if (notExecuted > 0)
+        {
+            skipped = notExecuted;
+        }
+
+        if (total == 0)
+        {
+            total = passed + failed + skipped;
+        }
+
+        return new TestRunResult
+        {
+            Total = total,
+            Passed = passed,
+            Failed = failed,
+            Skipped = skipped,
+            NotExecuted = notExecuted,
+            DurationSeconds = Math.Round(duration, 3),
+            Failures = failures,
+            TrxFilesParsed = 1,
+        };
+    }
+
+    public static TestRunResult ParseDirectory(string directory)
+    {
+        var result = TestRunResult.Empty;
+        if (!Directory.Exists(directory))
+        {
+            return result;
+        }
+
+        foreach (var file in Directory.EnumerateFiles(directory, "*.trx", SearchOption.AllDirectories))
+        {
+            try
+            {
+                result = result.Merge(ParseFile(file));
+            }
+            catch (Exception)
+            {
+                // A single unreadable TRX must not lose the results of the others.
+            }
+        }
+
+        return result;
+    }
+
+    private static int IntAttr(XElement e, string name) =>
+        int.TryParse(e.Attribute(name)?.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : 0;
+
+    private static double ParseDuration(string? value) =>
+        TimeSpan.TryParse(value, CultureInfo.InvariantCulture, out var ts) ? ts.TotalSeconds : 0;
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Failure classification.
+// A container/infrastructure failure must never be reported as a failing unit test, and we never fall
+// back to running tests locally. Each phase maps to a distinct failure kind.
+// ---------------------------------------------------------------------------------------------------
+
+internal enum FailureKind
+{
+    None,
+    Configuration,
+    UnsupportedEnvironment,
+    DockerUnavailable,
+    ImageResolution,
+    SdkIncompatibility,
+    SourceStaging,
+    Restore,
+    Compilation,
+    TestHost,
+    TestFailure,
+    ResultProcessing,
+    Cleanup,
+    Cancelled,
+    ReleaseMetadataUnavailable,
+}
+
+internal sealed record ExecutionOutcome(FailureKind Kind, string Phase, int ExitCode, string Message);
+
+internal static class FailureClassifier
+{
+    public static ExitCode ToExitCode(FailureKind kind) => kind switch
+    {
+        FailureKind.None => ExitCode.Success,
+        FailureKind.Configuration => ExitCode.Configuration,
+        FailureKind.UnsupportedEnvironment => ExitCode.UnsupportedEnvironment,
+        FailureKind.DockerUnavailable => ExitCode.DockerUnavailable,
+        FailureKind.ImageResolution => ExitCode.ImageResolution,
+        FailureKind.SdkIncompatibility => ExitCode.SdkIncompatibility,
+        FailureKind.SourceStaging => ExitCode.SourceStaging,
+        FailureKind.Restore => ExitCode.Restore,
+        FailureKind.Compilation => ExitCode.Compilation,
+        FailureKind.TestHost => ExitCode.TestHost,
+        FailureKind.TestFailure => ExitCode.TestFailures,
+        FailureKind.ResultProcessing => ExitCode.ResultProcessing,
+        FailureKind.Cleanup => ExitCode.Cleanup,
+        FailureKind.Cancelled => ExitCode.Cancelled,
+        FailureKind.ReleaseMetadataUnavailable => ExitCode.ReleaseMetadataUnavailable,
+        _ => ExitCode.ResultProcessing,
+    };
+
+    // Interpret the container run from the phase markers it emitted, its exit code, whether it was
+    // cancelled, and the parsed test results. This is the crux of never misreporting infrastructure as
+    // a unit-test failure.
+    public static ExecutionOutcome Classify(
+        IReadOnlyDictionary phaseExitCodes,
+        int containerExitCode,
+        bool cancelled,
+        TestRunResult results)
+    {
+        if (cancelled)
+        {
+            return new ExecutionOutcome(FailureKind.Cancelled, "test", containerExitCode, "The remote test run was cancelled.");
+        }
+
+        if (phaseExitCodes.TryGetValue("staging", out var stage) && stage != 0)
+        {
+            return new ExecutionOutcome(FailureKind.SourceStaging, "staging", stage, "The staged workspace was not available inside the container.");
+        }
+
+        if (phaseExitCodes.TryGetValue("restore", out var restore) && restore != 0)
+        {
+            return new ExecutionOutcome(FailureKind.Restore, "restore", restore, "dotnet restore failed inside the container.");
+        }
+
+        if (phaseExitCodes.TryGetValue("build", out var build) && build != 0)
+        {
+            return new ExecutionOutcome(FailureKind.Compilation, "build", build, "dotnet build failed inside the container.");
+        }
+
+        var testRan = phaseExitCodes.TryGetValue("test", out var test);
+        if (testRan && test != 0)
+        {
+            // dotnet test returned non-zero. If a TRX with failures exists, these are real test failures;
+            // otherwise the test host itself failed (crash, no tests discovered, adapter missing).
+            if (results.TrxFilesParsed > 0 && results.Failed > 0)
+            {
+                return new ExecutionOutcome(FailureKind.TestFailure, "test", test, $"{results.Failed} test(s) failed.");
+            }
+
+            return new ExecutionOutcome(FailureKind.TestHost, "test", test,
+                "The test host exited non-zero without producing failing test results (crash, no discovered tests, or missing adapter).");
+        }
+
+        if (!testRan && containerExitCode != 0)
+        {
+            return new ExecutionOutcome(FailureKind.TestHost, "test", containerExitCode,
+                "The container exited before the test phase completed.");
+        }
+
+        if (results.Failed > 0)
+        {
+            return new ExecutionOutcome(FailureKind.TestFailure, "test", containerExitCode, $"{results.Failed} test(s) failed.");
+        }
+
+        return new ExecutionOutcome(FailureKind.None, "test", 0, "All tests passed.");
+    }
+
+    // Extract "##RT_PHASE_END::##" markers from captured container stdout.
+    public static IReadOnlyDictionary ParsePhaseMarkers(string output)
+    {
+        var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+        foreach (Match m in Regex.Matches(output,
+            Regex.Escape(ContainerPlanner.PhaseMarkerPrefix) + @"(?[a-zA-Z]+):(?-?\d+)##"))
+        {
+            map[m.Groups["name"].Value] = int.Parse(m.Groups["code"].Value, CultureInfo.InvariantCulture);
+        }
+
+        return map;
+    }
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Process execution utility.
+// ---------------------------------------------------------------------------------------------------
+
+internal sealed record ProcessResult(int ExitCode, string StdOut, string StdErr, bool TimedOut);
+
+internal static class ProcessRunner
+{
+    public static async Task RunAsync(
+        string fileName,
+        IReadOnlyList arguments,
+        string? workingDirectory = null,
+        CancellationToken cancellationToken = default)
+    {
+        var psi = new ProcessStartInfo
+        {
+            FileName = fileName,
+            RedirectStandardOutput = true,
+            RedirectStandardError = true,
+            UseShellExecute = false,
+            CreateNoWindow = true,
+            WorkingDirectory = workingDirectory ?? Directory.GetCurrentDirectory(),
+        };
+
+        foreach (var a in arguments)
+        {
+            psi.ArgumentList.Add(a);
+        }
+
+        using var process = new Process { StartInfo = psi };
+        var stdout = new StringBuilder();
+        var stderr = new StringBuilder();
+        process.OutputDataReceived += (_, e) => { if (e.Data is not null) { lock (stdout) { stdout.AppendLine(e.Data); } } };
+        process.ErrorDataReceived += (_, e) => { if (e.Data is not null) { lock (stderr) { stderr.AppendLine(e.Data); } } };
+
+        process.Start();
+        process.BeginOutputReadLine();
+        process.BeginErrorReadLine();
+
+        try
+        {
+            await process.WaitForExitAsync(cancellationToken);
+        }
+        catch (OperationCanceledException)
+        {
+            TryKill(process);
+            return new ProcessResult(-1, stdout.ToString(), stderr.ToString(), TimedOut: true);
+        }
+
+        return new ProcessResult(process.ExitCode, stdout.ToString(), stderr.ToString(), TimedOut: false);
+    }
+
+    private static void TryKill(Process process)
+    {
+        try
+        {
+            if (!process.HasExited)
+            {
+                process.Kill(entireProcessTree: true);
+            }
+        }
+        catch (Exception)
+        {
+            // Best effort; the caller handles container cleanup separately.
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Release metadata store — network fetch with an outside-the-repo cache and offline fallback.
+// ---------------------------------------------------------------------------------------------------
+
+internal sealed record ReleaseMetadataResult(ReleaseMetadata? Metadata, string? Error);
+
+internal static class ReleaseMetadataStore
+{
+    private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) };
+
+    private static string CacheFile(string cacheRoot) => Path.Combine(cacheRoot, "releases-index.cache.json");
+
+    public static async Task LoadAsync(Options options, CancellationToken ct)
+    {
+        // Explicit local file wins: it is a deliberate input (used by tests and controlled environments).
+        if (!string.IsNullOrWhiteSpace(options.ReleasesIndexFile))
+        {
+            if (!File.Exists(options.ReleasesIndexFile))
+            {
+                return new ReleaseMetadataResult(null, $"releases-index file not found: {options.ReleasesIndexFile}");
+            }
+
+            var channels = ReleaseIndexReader.Parse(await File.ReadAllTextAsync(options.ReleasesIndexFile, ct));
+            return new ReleaseMetadataResult(new ReleaseMetadata
+            {
+                Channels = channels,
+                RetrievedAt = File.GetLastWriteTimeUtc(options.ReleasesIndexFile),
+                Source = $"file:{options.ReleasesIndexFile}",
+            }, null);
+        }
+
+        var cacheFile = CacheFile(options.CacheDirectory);
+
+        if (options.Offline)
+        {
+            return LoadFromCache(cacheFile, "Offline mode: ");
+        }
+
+        try
+        {
+            var json = await Http.GetStringAsync(RemoteTestProgram.ReleasesIndexUrl, ct);
+            var channels = ReleaseIndexReader.Parse(json);
+            if (channels.Count == 0)
+            {
+                return LoadFromCache(cacheFile, "Release index returned no channels: ");
+            }
+
+            var now = DateTimeOffset.UtcNow;
+            TryWriteCache(cacheFile, json, now);
+            return new ReleaseMetadataResult(new ReleaseMetadata
+            {
+                Channels = channels,
+                RetrievedAt = now,
+                Source = RemoteTestProgram.ReleasesIndexUrl,
+            }, null);
+        }
+        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
+        {
+            return LoadFromCache(cacheFile, $"Could not reach Microsoft release metadata ({ex.Message}); ");
+        }
+    }
+
+    private static ReleaseMetadataResult LoadFromCache(string cacheFile, string prefix)
+    {
+        if (!File.Exists(cacheFile))
+        {
+            return new ReleaseMetadataResult(null,
+                prefix + "no cached release metadata is available. Automatic environment discovery cannot proceed.");
+        }
+
+        try
+        {
+            using var doc = JsonDocument.Parse(File.ReadAllText(cacheFile));
+            var root = doc.RootElement;
+            var retrievedAt = root.TryGetProperty("retrievedAt", out var r) && r.ValueKind == JsonValueKind.String
+                ? DateTimeOffset.Parse(r.GetString()!, CultureInfo.InvariantCulture)
+                : File.GetLastWriteTimeUtc(cacheFile);
+            var rawJson = root.GetProperty("releasesIndex").GetRawText();
+            var channels = ReleaseIndexReader.Parse(rawJson);
+            return new ReleaseMetadataResult(new ReleaseMetadata
+            {
+                Channels = channels,
+                RetrievedAt = retrievedAt,
+                Source = $"cache:{cacheFile}",
+                IsStale = true,
+            }, null);
+        }
+        catch (Exception ex)
+        {
+            return new ReleaseMetadataResult(null, prefix + $"cached release metadata is unreadable ({ex.Message}).");
+        }
+    }
+
+    private static void TryWriteCache(string cacheFile, string rawIndexJson, DateTimeOffset retrievedAt)
+    {
+        try
+        {
+            Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)!);
+            using var indexDoc = JsonDocument.Parse(rawIndexJson);
+            var payload = new
+            {
+                retrievedAt = retrievedAt.ToString("O", CultureInfo.InvariantCulture),
+                source = RemoteTestProgram.ReleasesIndexUrl,
+                releasesIndex = indexDoc.RootElement.GetProperty("releases-index"),
+            };
+            File.WriteAllText(cacheFile, JsonSerializer.Serialize(payload, RemoteTestProgram.JsonOut));
+        }
+        catch (Exception)
+        {
+            // A cache write failure must not fail an otherwise successful online run.
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Docker registry client (MCR) — validate an SDK tag and pre-resolve its digest without pulling.
+// ---------------------------------------------------------------------------------------------------
+
+internal sealed record RegistryResolution(bool Exists, string? Digest, string? Error);
+
+internal static class RegistryClient
+{
+    private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) };
+
+    // mcr.microsoft.com implements the anonymous Docker Registry v2 API. A manifest request for a tag
+    // returns 200 and a Docker-Content-Digest header when the tag exists.
+    public static async Task ResolveAsync(string repository, string tag, CancellationToken ct)
+    {
+        var url = $"https://mcr.microsoft.com/v2/{repository}/manifests/{tag}";
+        using var request = new HttpRequestMessage(HttpMethod.Get, url);
+        request.Headers.Accept.ParseAdd("application/vnd.docker.distribution.manifest.list.v2+json");
+        request.Headers.Accept.ParseAdd("application/vnd.oci.image.index.v1+json");
+        request.Headers.Accept.ParseAdd("application/vnd.docker.distribution.manifest.v2+json");
+        request.Headers.Accept.ParseAdd("application/vnd.oci.image.manifest.v1+json");
+
+        try
+        {
+            using var response = await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
+            if (response.StatusCode == HttpStatusCode.NotFound)
+            {
+                return new RegistryResolution(false, null, null);
+            }
+
+            if (!response.IsSuccessStatusCode)
+            {
+                return new RegistryResolution(false, null, $"Registry returned HTTP {(int)response.StatusCode} for tag '{tag}'.");
+            }
+
+            var digest = response.Headers.TryGetValues("Docker-Content-Digest", out var values)
+                ? values.FirstOrDefault()
+                : null;
+            return new RegistryResolution(true, digest, null);
+        }
+        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
+        {
+            return new RegistryResolution(false, null, ex.Message);
+        }
+    }
+
+    // Try each candidate tag in order; return the first that exists.
+    public static async Task<(string? tag, RegistryResolution resolution)> ResolveFirstAsync(
+        string repository, IReadOnlyList tags, CancellationToken ct)
+    {
+        RegistryResolution last = new(false, null, "No candidate tags.");
+        foreach (var tag in tags)
+        {
+            var r = await ResolveAsync(repository, tag, ct);
+            if (r.Exists)
+            {
+                return (tag, r);
+            }
+
+            last = r;
+        }
+
+        return (null, last);
+    }
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Docker client — a thin, deterministic wrapper over the docker CLI.
+// ---------------------------------------------------------------------------------------------------
+
+internal static class DockerClient
+{
+    public static async Task IsAvailableAsync(CancellationToken ct)
+    {
+        try
+        {
+            var r = await ProcessRunner.RunAsync("docker", ["version", "--format", "{{.Server.Version}}"], null, ct);
+            return r.ExitCode == 0 && !string.IsNullOrWhiteSpace(r.StdOut);
+        }
+        catch (Exception)
+        {
+            return false;
+        }
+    }
+
+    public static Task PullAsync(string image, CancellationToken ct) =>
+        ProcessRunner.RunAsync("docker", ["pull", image], null, ct);
+
+    // Resolve the immutable digest of a locally present image (its manifest-list RepoDigest).
+    public static async Task ResolveDigestAsync(string image, CancellationToken ct)
+    {
+        var r = await ProcessRunner.RunAsync(
+            "docker", ["inspect", "--format", "{{join .RepoDigests \"\\n\"}}", image], null, ct);
+        if (r.ExitCode != 0)
+        {
+            return null;
+        }
+
+        var line = r.StdOut.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+            .FirstOrDefault(l => l.Contains('@'));
+        return line?[(line.IndexOf('@') + 1)..];
+    }
+
+    public static async Task ResolveImageIdAsync(string image, CancellationToken ct)
+    {
+        var r = await ProcessRunner.RunAsync("docker", ["inspect", "--format", "{{.Id}}", image], null, ct);
+        return r.ExitCode == 0 ? r.StdOut.Trim() : null;
+    }
+
+    public static Task BuildAsync(string dockerfile, string context, string tag, CancellationToken ct) =>
+        ProcessRunner.RunAsync("docker", ["build", "-f", dockerfile, "-t", tag, context], null, ct);
+
+    public static Task RunAsync(IReadOnlyList runArgs, CancellationToken ct) =>
+        ProcessRunner.RunAsync("docker", runArgs, null, ct);
+
+    // Force-remove a container by name. Returns true if the container is gone afterward.
+    public static async Task RemoveContainerAsync(string name, CancellationToken ct)
+    {
+        var r = await ProcessRunner.RunAsync("docker", ["rm", "-f", name], null, CancellationToken.None);
+        if (r.ExitCode == 0)
+        {
+            return true;
+        }
+
+        // "No such container" means it is already gone (e.g. --rm cleaned it up) — treat as success.
+        return r.StdErr.Contains("No such container", StringComparison.OrdinalIgnoreCase);
+    }
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Source staging — copy the source into an isolated, disposable workspace so container builds never
+// pollute the developer's working tree with Linux bin/obj artifacts.
+// ---------------------------------------------------------------------------------------------------
+
+internal sealed record StagingResult(string? StagedPath, string? Error, int FileCount);
+
+internal static class SourceStager
+{
+    private static readonly string[] ExcludedDirs = ["bin", "obj", ".git", ".vs", ".vscode", "node_modules", "TestResults"];
+
+    public static async Task StageAsync(string sourceRoot, string stagingRoot, CancellationToken ct)
+    {
+        if (!Directory.Exists(sourceRoot))
+        {
+            return new StagingResult(null, $"Source root does not exist: {sourceRoot}", 0);
+        }
+
+        try
+        {
+            Directory.CreateDirectory(stagingRoot);
+
+            // Prefer git to enumerate tracked + untracked-not-ignored files; this keeps ignored build
+            // output out of the staged copy without reimplementing .gitignore.
+            var files = await TryGitEnumerateAsync(sourceRoot, ct);
+            var count = files is not null
+                ? CopyEnumerated(sourceRoot, stagingRoot, files)
+                : CopyRecursive(sourceRoot, stagingRoot);
+
+            return new StagingResult(stagingRoot, null, count);
+        }
+        catch (Exception ex)
+        {
+            return new StagingResult(null, $"Failed to stage source: {ex.Message}", 0);
+        }
+    }
+
+    private static async Task?> TryGitEnumerateAsync(string sourceRoot, CancellationToken ct)
+    {
+        if (!Directory.Exists(Path.Combine(sourceRoot, ".git")))
+        {
+            return null;
+        }
+
+        try
+        {
+            var r = await ProcessRunner.RunAsync(
+                "git", ["-C", sourceRoot, "ls-files", "-co", "--exclude-standard"], sourceRoot, ct);
+            if (r.ExitCode != 0)
+            {
+                return null;
+            }
+
+            return [.. r.StdOut.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
+        }
+        catch (Exception)
+        {
+            return null;
+        }
+    }
+
+    private static int CopyEnumerated(string sourceRoot, string stagingRoot, IReadOnlyList relativeFiles)
+    {
+        var count = 0;
+        foreach (var rel in relativeFiles)
+        {
+            var normalized = rel.Replace('/', Path.DirectorySeparatorChar);
+            var src = Path.Combine(sourceRoot, normalized);
+            if (!File.Exists(src))
+            {
+                continue;
+            }
+
+            var dest = Path.Combine(stagingRoot, normalized);
+            Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
+            File.Copy(src, dest, overwrite: true);
+            count++;
+        }
+
+        return count;
+    }
+
+    private static int CopyRecursive(string sourceRoot, string stagingRoot)
+    {
+        var count = 0;
+        var stack = new Stack();
+        stack.Push(sourceRoot);
+        while (stack.Count > 0)
+        {
+            var dir = stack.Pop();
+            foreach (var sub in Directory.EnumerateDirectories(dir))
+            {
+                var name = Path.GetFileName(sub);
+                if (!ExcludedDirs.Contains(name, StringComparer.OrdinalIgnoreCase))
+                {
+                    stack.Push(sub);
+                }
+            }
+
+            foreach (var file in Directory.EnumerateFiles(dir))
+            {
+                var rel = Path.GetRelativePath(sourceRoot, file);
+                var dest = Path.Combine(stagingRoot, rel);
+                Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
+                File.Copy(file, dest, overwrite: true);
+                count++;
+            }
+        }
+
+        return count;
+    }
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Commands: list, plan, run. This is the boundary the AI orchestration layer calls.
+// ---------------------------------------------------------------------------------------------------
+
+internal sealed record ResolveContext(
+    TestEnvironmentsConfig? Config,
+    ReleaseMetadata? Metadata,
+    string? MetadataError,
+    IReadOnlyList Generated,
+    EnvironmentResolution Resolution);
+
+internal static class Commands
+{
+    private static async Task BuildContextAsync(Options options, CancellationToken ct)
+    {
+        var configPath = TestEnvironmentsConfigReader.Locate(options.RepoRoot, options.ConfigPath);
+        TestEnvironmentsConfig? config = configPath is not null
+            ? TestEnvironmentsConfigReader.Parse(await File.ReadAllTextAsync(configPath, ct), configPath)
+            : null;
+
+        var nameInConfig = options.EnvironmentName is not null && config is not null &&
+            (config.SupportedDockerEnvironments.Any(Match) || config.UnsupportedEnvironments.Any(Match));
+        bool Match(EnvironmentDefinition e) =>
+            string.Equals(e.Name, options.EnvironmentName, StringComparison.OrdinalIgnoreCase);
+
+        var authoritativeNoName = options.EnvironmentName is null && config?.SourcePath is not null;
+        var needMetadata = !authoritativeNoName && !nameInConfig;
+
+        ReleaseMetadata? metadata = null;
+        string? metadataError = null;
+        IReadOnlyList generated = [];
+        if (needMetadata)
+        {
+            var result = await ReleaseMetadataStore.LoadAsync(options, ct);
+            metadata = result.Metadata;
+            metadataError = result.Error;
+            if (metadata is not null)
+            {
+                generated = GeneratedEnvironments.FromMetadata(metadata);
+            }
+        }
+
+        var resolution = EnvironmentResolver.Resolve(config, generated, options.EnvironmentName);
+        return new ResolveContext(config, metadata, metadataError, generated, resolution);
+    }
+
+    public static async Task ListAsync(Options options)
+    {
+        using var cts = new CancellationTokenSource();
+        var configPath = TestEnvironmentsConfigReader.Locate(options.RepoRoot, options.ConfigPath);
+        TestEnvironmentsConfig? config = configPath is not null
+            ? TestEnvironmentsConfigReader.Parse(await File.ReadAllTextAsync(configPath, cts.Token), configPath)
+            : null;
+
+        ReleaseMetadata? metadata = null;
+        string? metadataError = null;
+        IReadOnlyList environments;
+        IReadOnlyList unsupported = [];
+
+        if (config?.SourcePath is not null)
+        {
+            environments = [.. config.SupportedDockerEnvironments.Select(GeneratedEnvironments.FromConfigured)];
+            unsupported = config.UnsupportedEnvironments;
+        }
+        else
+        {
+            var result = await ReleaseMetadataStore.LoadAsync(options, cts.Token);
+            metadata = result.Metadata;
+            metadataError = result.Error;
+            environments = metadata is not null ? GeneratedEnvironments.FromMetadata(metadata) : [];
+        }
+
+        if (options.Json)
+        {
+            Console.WriteLine(JsonSerializer.Serialize(new
+            {
+                tool = RemoteTestProgram.ToolName,
+                source = config?.SourcePath ?? metadata?.Source,
+                metadata = metadata is null ? null : new { retrievedAt = metadata.RetrievedAt, stale = metadata.IsStale, source = metadata.Source },
+                metadataError,
+                environments = environments.Select(e => new
+                {
+                    e.Name, origin = e.Origin.ToString(), e.Channel, e.ReleaseType, e.Sdk,
+                    image = e.DockerImage, dockerFile = e.DockerFile,
+                }),
+                unsupported = unsupported.Select(e => new { e.Name, type = e.RawType }),
+                configDiagnostics = config?.Diagnostics.Select(d => new { d.Code, d.Message, environment = d.EnvironmentName }),
+            }, RemoteTestProgram.JsonOut));
+            return environments.Count > 0 || metadataError is null
+                ? (int)ExitCode.Success
+                : (int)ExitCode.ReleaseMetadataUnavailable;
+        }
+
+        Console.WriteLine("Remote test environments");
+        Console.WriteLine();
+        if (environments.Count == 0)
+        {
+            Console.WriteLine(metadataError ?? "  (none)");
+        }
+
+        foreach (var e in environments)
+        {
+            Console.WriteLine($"{e.Name}");
+            if (e.Channel is not null)
+            {
+                Console.WriteLine($"  .NET {e.Channel}");
+            }
+
+            if (e.ReleaseType is not null)
+            {
+                Console.WriteLine($"  {e.ReleaseType}");
+            }
+
+            if (e.Sdk is not null)
+            {
+                Console.WriteLine($"  SDK {e.Sdk}");
+            }
+
+            if (e.DockerImage is not null)
+            {
+                Console.WriteLine($"  Image {e.DockerImage}");
+            }
+
+            if (e.DockerFile is not null)
+            {
+                Console.WriteLine($"  Dockerfile {e.DockerFile}");
+            }
+
+            Console.WriteLine();
+        }
+
+        if (unsupported.Count > 0)
+        {
+            Console.WriteLine("Unsupported (reported, not run):");
+            foreach (var e in unsupported)
+            {
+                Console.WriteLine($"  {e.Name} — type '{e.RawType}' is not supported (Docker only).");
+            }
+        }
+
+        if (metadata?.IsStale == true)
+        {
+            Console.WriteLine($"Note: using cached release metadata from {metadata.RetrievedAt:u}.");
+        }
+
+        return (int)ExitCode.Success;
+    }
+
+    public static async Task PlanAsync(Options options)
+    {
+        using var cts = new CancellationTokenSource();
+        var ctx = await BuildContextAsync(options, cts.Token);
+
+        if (ctx.Resolution.Status != ResolutionStatus.Resolved)
+        {
+            return ReportResolutionProblem(options, ctx);
+        }
+
+        var env = ctx.Resolution.Environment!;
+        var sourceRoot = ResolveSourceRoot(options, env);
+        var tfmInfo = TargetFrameworkInspector.Inspect(sourceRoot, options.Project);
+
+        // Image identity: for generated environments, validate candidate tags against MCR and pre-resolve
+        // the digest without pulling. Offline / --no-registry-check skips the network probe.
+        string? requestedTag = null;
+        string? reference = env.DockerImage;
+        string? digest = null;
+        string? imageNote = null;
+        SdkVersion? channelSdk = SdkVersion.TryParse(env.Sdk);
+
+        if (env.DockerFile is not null)
+        {
+            imageNote = $"Configured Dockerfile '{env.DockerFile}' will be built into a local image.";
+        }
+        else if (env.Origin == EnvironmentOrigin.Generated && channelSdk is not null)
+        {
+            var candidates = ImageTagResolver.CandidateTags(channelSdk);
+            if (options.Offline || options.NoRegistryCheck)
+            {
+                requestedTag = candidates[0];
+                reference = ImageTagResolver.SdkImageReference(requestedTag);
+                imageNote = "Registry validation skipped; tag derived from release metadata.";
+            }
+            else
+            {
+                var (tag, resolution) = await RegistryClient.ResolveFirstAsync(RemoteTestProgram.SdkRepositoryPath, candidates, cts.Token);
+                if (tag is null)
+                {
+                    imageNote = $"None of the candidate tags [{string.Join(", ", candidates)}] were found on {RemoteTestProgram.SdkRepository}. {resolution.Error}";
+                    requestedTag = candidates[0];
+                    reference = ImageTagResolver.SdkImageReference(requestedTag);
+                }
+                else
+                {
+                    requestedTag = tag;
+                    reference = ImageTagResolver.SdkImageReference(tag);
+                    digest = resolution.Digest;
+                }
+            }
+        }
+
+        var compatibility = TargetFrameworkInspector.CanBuild(channelSdk, tfmInfo);
+        var compatibilityBlocking = SdkCompatibilityPolicy.IsBlocking(env.Origin, compatibility.Compatible);
+        // Configured environments trust their image SDK (validated at run time); do not present or fail
+        // them as incompatible just because the SDK could not be determined statically.
+        var displayCompatible = env.Origin == EnvironmentOrigin.Generated ? compatibility.Compatible : true;
+        var displayReason = env.Origin == EnvironmentOrigin.Generated
+            ? compatibility.Reason
+            : "Configured environment; the image SDK is trusted as provided and validated at run time.";
+        var containerName = ContainerPlanner.ContainerName("planned");
+        var testOptions = BuildTestOptions(options, sourceRoot);
+        var mounts = new[]
+        {
+            new ContainerMount("", "/workspace", ReadOnly: false),
+            new ContainerMount("", "/nuget", ReadOnly: false),
+            new ContainerMount("", "/results", ReadOnly: false),
+        };
+        var plan = ContainerPlanner.Build(reference ?? "", containerName, mounts, testOptions);
+
+        if (options.Json)
+        {
+            Console.WriteLine(JsonSerializer.Serialize(new
+            {
+                tool = RemoteTestProgram.ToolName,
+                environment = new { env.Name, origin = env.Origin.ToString(), env.Channel, env.ReleaseType, env.Sdk },
+                image = new
+                {
+                    requested = env.DockerImage ?? reference,
+                    tag = requestedTag,
+                    reference,
+                    dockerFile = env.DockerFile,
+                    digest,
+                    digestResolved = digest is not null,
+                    microsoftOnlyEnforced = env.Origin == EnvironmentOrigin.Generated,
+                    note = imageNote,
+                },
+                targetFrameworks = new
+                {
+                    frameworks = tfmInfo.TargetFrameworks,
+                    globalJsonSdk = tfmInfo.GlobalJsonSdkVersion,
+                    globalJsonRollForward = tfmInfo.GlobalJsonRollForward,
+                },
+                compatibility = new { Compatible = displayCompatible, Reason = displayReason },
+                test = new { testOptions.Target, testOptions.Configuration, testOptions.Framework, testOptions.Filter, testOptions.Coverage },
+                container = new { plan.ContainerName, mounts = plan.Mounts, environmentVariables = plan.Environment },
+                entrypoint = plan.Entrypoint,
+                dockerRunArgs = plan.DockerRunArgs,
+            }, RemoteTestProgram.JsonOut));
+        }
+        else
+        {
+            Console.WriteLine($"Plan: {env.Name}");
+            Console.WriteLine($"  Image:       {env.DockerImage ?? reference ?? "(from Dockerfile)"}");
+            if (requestedTag is not null)
+            {
+                Console.WriteLine($"  Tag:         {requestedTag}");
+            }
+
+            if (digest is not null)
+            {
+                Console.WriteLine($"  Digest:      {digest}");
+            }
+
+            if (imageNote is not null)
+            {
+                Console.WriteLine($"  Note:        {imageNote}");
+            }
+
+            Console.WriteLine($"  Frameworks:  {(tfmInfo.TargetFrameworks.Count > 0 ? string.Join(", ", tfmInfo.TargetFrameworks) : "(none detected)")}");
+            Console.WriteLine($"  Compatible:  {displayCompatible}{(displayReason is null ? "" : $" — {displayReason}")}");
+            Console.WriteLine($"  Container:   {plan.ContainerName}");
+        }
+
+        return compatibilityBlocking ? (int)ExitCode.SdkIncompatibility : (int)ExitCode.Success;
+    }
+
+    private static TestCommandOptions BuildTestOptions(Options options, string sourceRoot) => new()
+    {
+        Target = TargetResolver.Resolve(sourceRoot, options.Project),
+        Configuration = options.Configuration,
+        Framework = options.Framework,
+        Filter = ContainerPlanner.ResolveFilter(options.Filter, options.Test),
+        Coverage = options.Coverage,
+    };
+
+    private static string ResolveSourceRoot(Options options, ResolvedEnvironment env)
+    {
+        if (string.IsNullOrWhiteSpace(env.LocalRoot))
+        {
+            return options.RepoRoot;
+        }
+
+        return Path.IsPathRooted(env.LocalRoot)
+            ? env.LocalRoot
+            : Path.GetFullPath(Path.Combine(options.RepoRoot, env.LocalRoot));
+    }
+
+    private static int ReportResolutionProblem(Options options, ResolveContext ctx)
+    {
+        var res = ctx.Resolution;
+        var kind = res.Status switch
+        {
+            ResolutionStatus.Unsupported => ExitCode.UnsupportedEnvironment,
+            ResolutionStatus.Ambiguous => ExitCode.SelectionRequired,
+            ResolutionStatus.NoEnvironments when ctx.MetadataError is not null => ExitCode.ReleaseMetadataUnavailable,
+            ResolutionStatus.NoEnvironments => ExitCode.Configuration,
+            _ => ExitCode.Configuration,
+        };
+
+        var message = res.Message ?? ctx.MetadataError ?? "Environment could not be resolved.";
+        if (options.Json)
+        {
+            Console.WriteLine(JsonSerializer.Serialize(new
+            {
+                tool = RemoteTestProgram.ToolName,
+                status = "error",
+                failureKind = kind.ToString(),
+                message,
+                candidates = res.Candidates,
+                metadataError = ctx.MetadataError,
+            }, RemoteTestProgram.JsonOut));
+        }
+        else
+        {
+            Console.Error.WriteLine($"{RemoteTestProgram.ToolName}: {message}");
+            if (res.Candidates.Count > 0)
+            {
+                Console.Error.WriteLine("Available: " + string.Join(", ", res.Candidates));
+            }
+        }
+
+        return (int)kind;
+    }
+
+    private sealed record RunImage(
+        string? Reference = null,
+        string? Digest = null,
+        string? RequestedTag = null,
+        string? Sdk = null,
+        FailureKind Kind = FailureKind.None,
+        string? Error = null);
+
+    private sealed record CleanupReport(bool ContainerRemoved, bool WorkspaceRemoved, IReadOnlyList Leftovers);
+
+    public static async Task RunAsync(Options options)
+    {
+        using var cts = new CancellationTokenSource();
+        if (options.TimeoutSeconds > 0)
+        {
+            cts.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds));
+        }
+
+        var cancelled = false;
+        void OnCancel(object? _, ConsoleCancelEventArgs e) { e.Cancel = true; cancelled = true; cts.Cancel(); }
+        Console.CancelKeyPress += OnCancel;
+
+        var containerName = ContainerPlanner.ContainerName(Guid.NewGuid().ToString("N")[..8]);
+        var runRoot = Path.Combine(Path.GetTempPath(), "dotnet-remote-testing", containerName);
+        string? stagingRoot = null;
+
+        try
+        {
+            var ctx = await BuildContextAsync(options, cts.Token);
+            if (ctx.Resolution.Status != ResolutionStatus.Resolved)
+            {
+                return ReportResolutionProblem(options, ctx);
+            }
+
+            var env = ctx.Resolution.Environment!;
+
+            if (!await DockerClient.IsAvailableAsync(cts.Token))
+            {
+                return Error(options, FailureKind.DockerUnavailable,
+                    "Docker is not available. Start Docker Desktop / the Docker daemon and retry. Remote testing never falls back to the local host.");
+            }
+
+            var sourceRoot = ResolveSourceRoot(options, env);
+            var tfmInfo = TargetFrameworkInspector.Inspect(sourceRoot, options.Project);
+            var channelSdk = SdkVersion.TryParse(env.Sdk);
+            var compat = TargetFrameworkInspector.CanBuild(channelSdk, tfmInfo);
+            if (SdkCompatibilityPolicy.IsBlocking(env.Origin, compat.Compatible))
+            {
+                return Error(options, FailureKind.SdkIncompatibility, compat.Reason ?? "The selected SDK cannot build the requested target framework.");
+            }
+
+            var image = await ResolveImageForRunAsync(options, env, channelSdk, cts.Token);
+            if (image.Error is not null)
+            {
+                return Error(options, image.Kind, image.Error);
+            }
+
+            var resultsRoot = Path.Combine(runRoot, "results");
+            stagingRoot = Path.Combine(runRoot, "workspace");
+            Directory.CreateDirectory(resultsRoot);
+
+            var staging = await SourceStager.StageAsync(sourceRoot, stagingRoot, cts.Token);
+            if (staging.Error is not null || staging.StagedPath is null)
+            {
+                return Error(options, FailureKind.SourceStaging, staging.Error ?? "Source staging produced no workspace.");
+            }
+
+            // NuGet packages cache persists across runs and lives outside the repository.
+            var nugetCache = Path.Combine(options.CacheDirectory, "nuget");
+            Directory.CreateDirectory(nugetCache);
+
+            var testOptions = BuildTestOptions(options, sourceRoot);
+            var mounts = new[]
+            {
+                new ContainerMount(staging.StagedPath, "/workspace", ReadOnly: false),
+                new ContainerMount(nugetCache, "/nuget", ReadOnly: false),
+                new ContainerMount(resultsRoot, "/results", ReadOnly: false),
+            };
+            var plan = ContainerPlanner.Build(image.Reference!, containerName, mounts, testOptions);
+
+            ProcessResult proc;
+            try
+            {
+                proc = await DockerClient.RunAsync(plan.DockerRunArgs, cts.Token);
+            }
+            catch (OperationCanceledException)
+            {
+                cancelled = true;
+                proc = new ProcessResult(-1, "", "", TimedOut: true);
+            }
+
+            cancelled |= proc.TimedOut;
+
+            var results = TrxParser.ParseDirectory(resultsRoot);
+            var phaseMarkers = FailureClassifier.ParsePhaseMarkers(proc.StdOut);
+            var outcome = FailureClassifier.Classify(phaseMarkers, proc.ExitCode, cancelled, results);
+
+            var cleanup = await CleanupAsync(containerName, runRoot, cts.Token);
+
+            return EmitRunResult(options, env, image, tfmInfo, results, outcome, proc, cleanup);
+        }
+        catch (OperationCanceledException)
+        {
+            await CleanupAsync(containerName, runRoot, CancellationToken.None);
+            return Error(options, FailureKind.Cancelled, "The remote test run was cancelled.");
+        }
+        finally
+        {
+            Console.CancelKeyPress -= OnCancel;
+            // Safety net: ensure nothing is left running even if an exception escaped before cleanup.
+            await CleanupAsync(containerName, runRoot, CancellationToken.None);
+        }
+    }
+
+    private static async Task ResolveImageForRunAsync(
+        Options options, ResolvedEnvironment env, SdkVersion? channelSdk, CancellationToken ct)
+    {
+        // Configured Dockerfile: honor it exactly. We never generate one; we only build an existing one.
+        if (env.DockerFile is not null)
+        {
+            var dockerfilePath = Path.IsPathRooted(env.DockerFile) ? env.DockerFile : Path.Combine(options.RepoRoot, env.DockerFile);
+            if (!File.Exists(dockerfilePath))
+            {
+                return new RunImage(Kind: FailureKind.ImageResolution, Error: $"Configured dockerFile not found: {env.DockerFile}");
+            }
+
+            var localTag = $"dotnet-remote-testing/{SanitizeTag(env.Name)}:local";
+            var context = Path.GetDirectoryName(dockerfilePath)!;
+            var build = await DockerClient.BuildAsync(dockerfilePath, context, localTag, ct);
+            if (build.ExitCode != 0)
+            {
+                return new RunImage(Kind: FailureKind.ImageResolution, Error: $"docker build of '{env.DockerFile}' failed: {LastLine(build.StdErr)}");
+            }
+
+            var id = await DockerClient.ResolveImageIdAsync(localTag, ct);
+            return new RunImage(Reference: localTag, Digest: id, RequestedTag: localTag, Sdk: env.Sdk);
+        }
+
+        string reference;
+        string? requestedTag = null;
+        if (env.Origin == EnvironmentOrigin.Generated && channelSdk is not null)
+        {
+            var candidates = ImageTagResolver.CandidateTags(channelSdk);
+            if (options.Offline || options.NoRegistryCheck)
+            {
+                requestedTag = candidates[0];
+            }
+            else
+            {
+                var (tag, _) = await RegistryClient.ResolveFirstAsync(RemoteTestProgram.SdkRepositoryPath, candidates, ct);
+                requestedTag = tag ?? candidates[0];
+            }
+
+            reference = ImageTagResolver.SdkImageReference(requestedTag);
+        }
+        else
+        {
+            // Configured dockerImage is deliberate repository intent — exempt from the Microsoft-only rule.
+            reference = env.DockerImage!;
+        }
+
+        var pull = await DockerClient.PullAsync(reference, ct);
+        if (pull.ExitCode != 0)
+        {
+            return new RunImage(Kind: FailureKind.ImageResolution, Error: $"Could not pull image '{reference}': {LastLine(pull.StdErr)}");
+        }
+
+        var digest = await DockerClient.ResolveDigestAsync(reference, ct) ?? await DockerClient.ResolveImageIdAsync(reference, ct);
+        return new RunImage(Reference: reference, Digest: digest, RequestedTag: requestedTag, Sdk: env.Sdk);
+    }
+
+    private static async Task CleanupAsync(string containerName, string runRoot, CancellationToken ct)
+    {
+        var leftovers = new List();
+
+        var removed = await DockerClient.RemoveContainerAsync(containerName, ct);
+        if (!removed)
+        {
+            leftovers.Add($"container:{containerName}");
+        }
+
+        var workspaceRemoved = true;
+        if (Directory.Exists(runRoot))
+        {
+            try
+            {
+                Directory.Delete(runRoot, recursive: true);
+            }
+            catch (Exception)
+            {
+                workspaceRemoved = false;
+                leftovers.Add($"workspace:{runRoot}");
+            }
+        }
+
+        return new CleanupReport(removed, workspaceRemoved, leftovers);
+    }
+
+    private static int EmitRunResult(
+        Options options,
+        ResolvedEnvironment env,
+        RunImage image,
+        TargetFrameworkInfo tfmInfo,
+        TestRunResult results,
+        ExecutionOutcome outcome,
+        ProcessResult proc,
+        CleanupReport cleanup)
+    {
+        var exit = FailureClassifier.ToExitCode(outcome.Kind);
+        if (outcome.Kind == FailureKind.None && cleanup.Leftovers.Count > 0)
+        {
+            exit = ExitCode.Cleanup;
+        }
+
+        var status = outcome.Kind switch
+        {
+            FailureKind.None => "passed",
+            FailureKind.TestFailure => "failed",
+            _ => "error",
+        };
+
+        if (options.Json)
+        {
+            Console.WriteLine(JsonSerializer.Serialize(new
+            {
+                tool = RemoteTestProgram.ToolName,
+                status,
+                failureKind = outcome.Kind == FailureKind.None ? null : outcome.Kind.ToString(),
+                phase = outcome.Phase,
+                message = outcome.Message,
+                environment = new { env.Name, origin = env.Origin.ToString(), env.Channel, env.ReleaseType },
+                image = new { requested = image.RequestedTag, reference = image.Reference, digest = image.Digest, sdk = image.Sdk },
+                targetFrameworks = tfmInfo.TargetFrameworks,
+                tests = new { results.Total, results.Passed, results.Skipped, results.Failed, results.DurationSeconds, trxFiles = results.TrxFilesParsed },
+                failures = results.Failures.Select(f => new { f.TestName, f.ClassName, f.Message, f.StackTrace }),
+                cleanup = new { cleanup.ContainerRemoved, cleanup.WorkspaceRemoved, cleanup.Leftovers },
+                diagnostics = status == "error" ? new { containerExitCode = proc.ExitCode, stdoutTail = LastLines(proc.StdOut, 30), stderrTail = LastLines(proc.StdErr, 20) } : null,
+            }, RemoteTestProgram.JsonOut));
+            return (int)exit;
+        }
+
+        // Concise human result: environment → image/digest/sdk → tests → duration, actionable on failure.
+        Console.WriteLine($"Remote Test: {env.Name}");
+        Console.WriteLine();
+        Console.WriteLine($"Image:  {image.Reference}");
+        if (image.Digest is not null)
+        {
+            Console.WriteLine($"Digest: {image.Digest}");
+        }
+
+        if (image.Sdk is not null)
+        {
+            Console.WriteLine($"SDK:    {image.Sdk}");
+        }
+
+        Console.WriteLine();
+
+        if (outcome.Kind is FailureKind.None or FailureKind.TestFailure)
+        {
+            Console.WriteLine($"Tests:  {results.Passed} passed, {results.Skipped} skipped, {results.Failed} failed");
+            Console.WriteLine($"Time:   {results.DurationSeconds.ToString("0.0", CultureInfo.InvariantCulture)} s");
+            if (results.Failed > 0)
+            {
+                Console.WriteLine();
+                Console.WriteLine($"{results.Failed} test(s) failed:");
+                foreach (var f in results.Failures)
+                {
+                    Console.WriteLine();
+                    Console.WriteLine($"  {f.ClassName}");
+                    Console.WriteLine($"    {f.TestName}");
+                    if (f.Message is not null)
+                    {
+                        Console.WriteLine($"    {f.Message}");
+                    }
+                }
+            }
+        }
+        else
+        {
+            Console.Error.WriteLine($"{outcome.Kind}: {outcome.Message}");
+            var tail = LastLines(proc.StdErr, 20);
+            if (!string.IsNullOrWhiteSpace(tail))
+            {
+                Console.Error.WriteLine(tail);
+            }
+        }
+
+        if (cleanup.Leftovers.Count > 0)
+        {
+            Console.Error.WriteLine("Cleanup left resources: " + string.Join(", ", cleanup.Leftovers));
+        }
+
+        return (int)exit;
+    }
+
+    private static int Error(Options options, FailureKind kind, string message)
+    {
+        if (options.Json)
+        {
+            Console.WriteLine(JsonSerializer.Serialize(new
+            {
+                tool = RemoteTestProgram.ToolName,
+                status = "error",
+                failureKind = kind.ToString(),
+                message,
+            }, RemoteTestProgram.JsonOut));
+        }
+        else
+        {
+            Console.Error.WriteLine($"{RemoteTestProgram.ToolName}: {message}");
+        }
+
+        return (int)FailureClassifier.ToExitCode(kind);
+    }
+
+    private static string SanitizeTag(string name)
+    {
+        var sb = new StringBuilder();
+        foreach (var c in name.ToLowerInvariant())
+        {
+            sb.Append(char.IsLetterOrDigit(c) || c is '.' or '_' or '-' ? c : '-');
+        }
+
+        return sb.ToString();
+    }
+
+    private static string LastLine(string s) =>
+        s.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).LastOrDefault() ?? "";
+
+    private static string LastLines(string s, int count)
+    {
+        var lines = s.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+        return string.Join('\n', lines.TakeLast(count));
+    }
+}
+
+// ---------------------------------------------------------------------------------------------------
+// Deterministic self-test suite. Covers configuration discovery, release metadata parsing, environment
+// selection, unsupported-environment handling, image resolution/tag derivation, command planning,
+// result parsing, failure classification, cancellation, and cleanup — all without Docker or network.
+// ---------------------------------------------------------------------------------------------------
+
+internal static class SelfTest
+{
+    private static int _passed;
+    private static int _failed;
+
+    public static int Run()
+    {
+        Console.WriteLine($"{RemoteTestProgram.ToolName} self-test");
+        Console.WriteLine();
+
+        ConfigDiscoveryTests();
+        ReleaseMetadataTests();
+        SdkAndImageTagTests();
+        EnvironmentSelectionTests();
+        UnsupportedEnvironmentTests();
+        TargetFrameworkTests();
+        CommandPlanningTests();
+        ResultParsingTests();
+        FailureClassificationTests();
+        CancellationAndCleanupTests();
+
+        Console.WriteLine();
+        Console.WriteLine($"Self-test: {_passed} passed, {_failed} failed.");
+        return _failed == 0 ? (int)ExitCode.Success : (int)ExitCode.TestFailures;
+    }
+
+    private const string SampleReleaseIndex = """
+    {
+      "releases-index": [
+        { "channel-version": "11.0", "latest-sdk": "11.0.100-preview.6.26359.118", "support-phase": "preview", "release-type": "sts" },
+        { "channel-version": "10.0", "latest-sdk": "10.0.302", "support-phase": "active", "release-type": "lts", "eol-date": "2028-11-14" },
+        { "channel-version": "9.0", "latest-sdk": "9.0.316", "support-phase": "maintenance", "release-type": "sts", "eol-date": "2026-11-10" },
+        { "channel-version": "8.0", "latest-sdk": "8.0.423", "support-phase": "maintenance", "release-type": "lts", "eol-date": "2026-11-10" },
+        { "channel-version": "7.0", "latest-sdk": "7.0.410", "support-phase": "eol", "release-type": "sts" },
+        { "channel-version": "6.0", "latest-sdk": "6.0.428", "support-phase": "eol", "release-type": "lts" }
+      ]
+    }
+    """;
+
+    private static void ConfigDiscoveryTests()
+    {
+        Section("Configuration discovery");
+
+        var valid = TestEnvironmentsConfigReader.Parse("""
+        { "version": "1", "environments": [ { "name": "dotnet-10", "type": "docker", "dockerImage": "mcr.microsoft.com/dotnet/sdk:10.0" } ] }
+        """, "testenvironments.json");
+        Check("version 1 supported", valid.VersionSupported);
+        Check("single docker env recognized", valid.SupportedDockerEnvironments.Count == 1);
+        Check("dockerImage captured", valid.SupportedDockerEnvironments[0].DockerImage == "mcr.microsoft.com/dotnet/sdk:10.0");
+        Check("no diagnostics for valid config", valid.Diagnostics.Count == 0);
+
+        var localRoot = TestEnvironmentsConfigReader.Parse("""
+        { "version": "1", "environments": [ { "name": "d", "type": "docker", "dockerFile": "Dockerfile.test", "localRoot": "src" } ] }
+        """);
+        Check("dockerFile captured", localRoot.SupportedDockerEnvironments.Count == 1 && localRoot.SupportedDockerEnvironments[0].DockerFile == "Dockerfile.test");
+        Check("localRoot honored", localRoot.Environments[0].LocalRoot == "src");
+
+        var badVersion = TestEnvironmentsConfigReader.Parse("""{ "version": "2", "environments": [] }""");
+        Check("version 2 rejected", !badVersion.VersionSupported && badVersion.Diagnostics.Any(d => d.Code == "UNSUPPORTED_VERSION"));
+
+        var conflicting = TestEnvironmentsConfigReader.Parse("""
+        { "version": "1", "environments": [ { "name": "x", "type": "docker", "dockerImage": "a", "dockerFile": "b" } ] }
+        """);
+        Check("dockerImage+dockerFile conflict flagged", conflicting.Diagnostics.Any(d => d.Code == "CONFLICTING_DOCKER_SOURCE"));
+        Check("conflicting docker env is not usable", conflicting.SupportedDockerEnvironments.Count == 0);
+
+        var missing = TestEnvironmentsConfigReader.Parse("""
+        { "version": "1", "environments": [ { "name": "x", "type": "docker" } ] }
+        """);
+        Check("docker env without image or file flagged", missing.Diagnostics.Any(d => d.Code == "MISSING_DOCKER_SOURCE"));
+
+        var invalid = TestEnvironmentsConfigReader.Parse("{ not json ");
+        Check("invalid json reported", invalid.Diagnostics.Any(d => d.Code == "INVALID_JSON"));
+    }
+
+    private static void ReleaseMetadataTests()
+    {
+        Section("Release metadata parsing");
+
+        var channels = ReleaseIndexReader.Parse(SampleReleaseIndex);
+        var metadata = new ReleaseMetadata { Channels = channels };
+
+        Check("all channels parsed", channels.Count == 6);
+        Check("eol excluded from stable", metadata.SupportedStableChannels.All(c => !c.IsEol));
+        Check("preview excluded from stable", metadata.SupportedStableChannels.All(c => !c.IsPreview));
+        Check("stable channels are 10, 9, 8", metadata.SupportedStableChannels.Select(c => c.MajorVersion).SequenceEqual([10, 9, 8]));
+        Check("preview channel is 11", metadata.PreviewChannels.Count == 1 && metadata.PreviewChannels[0].MajorVersion == 11);
+        Check("release-type drives lts/sts, not parity", channels.First(c => c.ChannelVersion == "9.0").IsSts && channels.First(c => c.ChannelVersion == "8.0").IsLts);
+
+        var generated = GeneratedEnvironments.FromMetadata(metadata);
+        Check("generated names derived from metadata", generated.Select(e => e.Name).SequenceEqual(
+            ["dotnet-10-lts", "dotnet-9-sts", "dotnet-8-lts", "dotnet-11-preview"]));
+        Check("generated images use mcr sdk repo", generated.All(e => e.DockerImage!.StartsWith("mcr.microsoft.com/dotnet/sdk:")));
+        Check("preview env labeled Preview", generated.First(e => e.Name == "dotnet-11-preview").ReleaseType == "Preview");
+    }
+
+    private static void SdkAndImageTagTests()
+    {
+        Section("SDK version and image tag derivation");
+
+        var stable = SdkVersion.TryParse("10.0.302")!;
+        Check("stable sdk parsed", stable is { Major: 10, Minor: 0, Feature: 302 } && !stable.IsPrerelease);
+        Check("stable image tag is exact sdk", stable.ImageTag == "10.0.302");
+        Check("channel tag is major.minor", stable.ChannelTag == "10.0");
+
+        var preview = SdkVersion.TryParse("11.0.100-preview.6.26359.118")!;
+        Check("preview sdk parsed", preview is { Major: 11, PreLabel: "preview", PreNumber: 6 } && preview.IsPrerelease);
+        Check("preview build metadata stripped from tag", preview.ImageTag == "11.0.100-preview.6");
+
+        var candidates = ImageTagResolver.CandidateTags(stable);
+        Check("exact sdk tag preferred over channel tag", candidates[0] == "10.0.302" && candidates[1] == "10.0");
+        Check("full reference built", ImageTagResolver.SdkImageReference("10.0.302") == "mcr.microsoft.com/dotnet/sdk:10.0.302");
+
+        Check("garbage sdk returns null", SdkVersion.TryParse("not-a-version") is null);
+    }
+
+    private static void EnvironmentSelectionTests()
+    {
+        Section("Environment selection");
+
+        var config = TestEnvironmentsConfigReader.Parse("""
+        { "version": "1", "environments": [ { "name": "ci-docker", "type": "docker", "dockerImage": "mcr.microsoft.com/dotnet/sdk:10.0" } ] }
+        """, "testenvironments.json");
+        var generated = GeneratedEnvironments.FromMetadata(new ReleaseMetadata { Channels = ReleaseIndexReader.Parse(SampleReleaseIndex) });
+
+        var single = EnvironmentResolver.Resolve(config, [], null);
+        Check("single configured docker env auto-selected", single.Status == ResolutionStatus.Resolved && single.Environment!.Name == "ci-docker");
+
+        var named = EnvironmentResolver.Resolve(config, generated, "dotnet-10-lts");
+        Check("explicit name can select a generated env even when config exists", named.Status == ResolutionStatus.Resolved && named.Environment!.Origin == EnvironmentOrigin.Generated);
+
+        var twoConfig = TestEnvironmentsConfigReader.Parse("""
+        { "version": "1", "environments": [
+          { "name": "a", "type": "docker", "dockerImage": "img-a" },
+          { "name": "b", "type": "docker", "dockerImage": "img-b" } ] }
+        """, "testenvironments.json");
+        var ambiguous = EnvironmentResolver.Resolve(twoConfig, [], null);
+        Check("multiple configured envs require selection", ambiguous.Status == ResolutionStatus.Ambiguous && ambiguous.Candidates.SequenceEqual(["a", "b"]));
+
+        var authoritative = EnvironmentResolver.Resolve(twoConfig, generated, null);
+        Check("config is authoritative — generated not silently added", authoritative.Status == ResolutionStatus.Ambiguous && !authoritative.Candidates.Contains("dotnet-10-lts"));
+
+        var zeroConfigSingle = EnvironmentResolver.Resolve(null, [generated[0]], null);
+        Check("single generated env used with no config", zeroConfigSingle.Status == ResolutionStatus.Resolved);
+
+        var zeroConfigMany = EnvironmentResolver.Resolve(null, generated, null);
+        Check("multiple generated envs require selection", zeroConfigMany.Status == ResolutionStatus.Ambiguous);
+
+        var notFound = EnvironmentResolver.Resolve(config, generated, "does-not-exist");
+        Check("unknown name reported as not found", notFound.Status == ResolutionStatus.NotFound);
+    }
+
+    private static void UnsupportedEnvironmentTests()
+    {
+        Section("Unsupported environment handling");
+
+        var config = TestEnvironmentsConfigReader.Parse("""
+        { "version": "1", "environments": [
+          { "name": "wsl-env", "type": "wsl", "wslDistribution": "Ubuntu" },
+          { "name": "ssh-env", "type": "ssh", "remoteUri": "ssh://user@host:22" } ] }
+        """, "testenvironments.json");
+
+        Check("wsl flagged unsupported", config.Diagnostics.Any(d => d.Code == "UNSUPPORTED_TYPE" && d.EnvironmentName == "wsl-env"));
+        Check("ssh flagged unsupported", config.Diagnostics.Any(d => d.Code == "UNSUPPORTED_TYPE" && d.EnvironmentName == "ssh-env"));
+        Check("unsupported envs excluded from docker set", config.SupportedDockerEnvironments.Count == 0);
+        Check("unsupported envs still enumerated for reporting", config.UnsupportedEnvironments.Count == 2);
+
+        var namedUnsupported = EnvironmentResolver.Resolve(config, [], "wsl-env");
+        Check("naming an unsupported env reports Unsupported, not NotFound", namedUnsupported.Status == ResolutionStatus.Unsupported);
+
+        var unknown = TestEnvironmentsConfigReader.Parse("""
+        { "version": "1", "environments": [ { "name": "k8s", "type": "kubernetes" } ] }
+        """);
+        Check("unknown type flagged", unknown.Diagnostics.Any(d => d.Code == "UNKNOWN_TYPE"));
+    }
+
+    private static void TargetFrameworkTests()
+    {
+        Section("Target framework awareness");
+
+        var single = TargetFrameworkInspector.ExtractTargetFrameworks("net10.0");
+        Check("single TFM extracted", single.SequenceEqual(["net10.0"]));
+
+        var multi = TargetFrameworkInspector.ExtractTargetFrameworks("net8.0;net10.0");
+        Check("multi-targeting extracted", multi.SequenceEqual(["net8.0", "net10.0"]));
+
+        Check("net major parsed", TargetFrameworkInspector.NetMajor("net10.0") == 10 && TargetFrameworkInspector.NetMajor("netcoreapp3.1") == 3);
+        Check("netstandard has no core major", TargetFrameworkInspector.NetMajor("netstandard2.0") == 0);
+
+        var (sdk, roll) = TargetFrameworkInspector.ParseGlobalJson("""{ "sdk": { "version": "10.0.302", "rollForward": "latestFeature" } }""");
+        Check("global.json parsed", sdk == "10.0.302" && roll == "latestFeature");
+
+        var sdk10 = SdkVersion.TryParse("10.0.302");
+        Check("sdk builds equal/lower target", TargetFrameworkInspector.CanBuild(sdk10, new TargetFrameworkInfo { TargetFrameworks = ["net8.0", "net10.0"] }).Compatible);
+        Check("sdk cannot build newer target", !TargetFrameworkInspector.CanBuild(sdk10, new TargetFrameworkInfo { TargetFrameworks = ["net11.0"] }).Compatible);
+        Check("linux sdk cannot build net framework", !TargetFrameworkInspector.CanBuild(sdk10, new TargetFrameworkInfo { TargetFrameworks = ["net48"] }).Compatible);
+        Check("global.json disable pin mismatch is incompatible", !TargetFrameworkInspector.CanBuild(sdk10,
+            new TargetFrameworkInfo { TargetFrameworks = ["net10.0"], GlobalJsonSdkVersion = "11.0.100", GlobalJsonRollForward = "disable" }).Compatible);
+
+        // Only generated environments block on incompatibility; configured images are trusted (validated at run time).
+        Check("generated env with incompatible SDK is blocking", SdkCompatibilityPolicy.IsBlocking(EnvironmentOrigin.Generated, compatible: false));
+        Check("generated env with compatible SDK is not blocking", !SdkCompatibilityPolicy.IsBlocking(EnvironmentOrigin.Generated, compatible: true));
+        Check("configured env is never blocked on undetermined SDK", !SdkCompatibilityPolicy.IsBlocking(EnvironmentOrigin.Configured, compatible: false));
+    }
+
+    private static void CommandPlanningTests()
+    {
+        Section("Command planning");
+
+        Check("filter passthrough", ContainerPlanner.ResolveFilter("Category=Unit", null) == "Category=Unit");
+        Check("--test becomes FullyQualifiedName filter", ContainerPlanner.ResolveFilter(null, "StringUtilityTest") == "FullyQualifiedName~StringUtilityTest");
+
+        var entry = ContainerPlanner.BuildEntrypoint(new TestCommandOptions
+        {
+            Target = "test/Foo/Foo.csproj",
+            Configuration = "Release",
+            Framework = "net10.0",
+            Filter = "Category=Unit",
+            Coverage = true,
+        });
+        Check("entrypoint runs restore/build/test in order",
+            entry.IndexOf("run_phase restore", StringComparison.Ordinal) < entry.IndexOf("run_phase build", StringComparison.Ordinal)
+            && entry.IndexOf("run_phase build", StringComparison.Ordinal) < entry.IndexOf("run_phase test", StringComparison.Ordinal));
+        Check("entrypoint sets NUGET_PACKAGES to the cache mount", entry.Contains("export NUGET_PACKAGES='/nuget'"));
+        Check("entrypoint uses --no-restore/--no-build to reuse phases", entry.Contains("--no-restore") && entry.Contains("--no-build"));
+        Check("entrypoint honors configuration/framework/filter/coverage",
+            entry.Contains("-c 'Release'") && entry.Contains("--framework 'net10.0'") && entry.Contains("--filter 'Category=Unit'") && entry.Contains("XPlat Code Coverage"));
+        Check("entrypoint writes trx to results mount", entry.Contains("--results-directory '/results'") && entry.Contains("--logger trx"));
+
+        var plan = ContainerPlanner.Build(
+            "mcr.microsoft.com/dotnet/sdk:10.0.302",
+            ContainerPlanner.ContainerName("abc123"),
+            [
+                new ContainerMount("/host/src", "/workspace", false),
+                new ContainerMount("/host/nuget", "/nuget", false),
+                new ContainerMount("/host/results", "/results", false),
+            ],
+            new TestCommandOptions());
+        Check("container name is deterministic", plan.ContainerName == "dotnet-remote-testing-abc123");
+        Check("docker run auto-removes the container", plan.DockerRunArgs.Contains("--rm"));
+        Check("docker run names the container for cleanup", plan.DockerRunArgs.Contains("--name") && plan.DockerRunArgs.Contains("dotnet-remote-testing-abc123"));
+        Check("bind mounts wired for workspace/nuget/results",
+            plan.DockerRunArgs.Any(a => a.Contains("target=/workspace"))
+            && plan.DockerRunArgs.Any(a => a.Contains("target=/nuget"))
+            && plan.DockerRunArgs.Any(a => a.Contains("target=/results")));
+        Check("security: no privileged flag", !plan.DockerRunArgs.Contains("--privileged"));
+        Check("security: docker socket never mounted", !plan.DockerRunArgs.Any(a => a.Contains("/var/run/docker.sock")));
+        Check("security: no port publishing", !plan.DockerRunArgs.Contains("-p") && !plan.DockerRunArgs.Contains("--publish"));
+        Check("image is the last-but-command arg", plan.DockerRunArgs.Contains("mcr.microsoft.com/dotnet/sdk:10.0.302"));
+
+        // Target resolution against a temp source tree.
+        var tempRoot = Path.Combine(Path.GetTempPath(), "drt-selftest-" + Guid.NewGuid().ToString("N"));
+        try
+        {
+            Directory.CreateDirectory(Path.Combine(tempRoot, "test", "Foo"));
+            File.WriteAllText(Path.Combine(tempRoot, "test", "Foo", "Foo.csproj"), "");
+            Check("single project auto-resolved when no solution", TargetResolver.Resolve(tempRoot, null) == "test/Foo/Foo.csproj");
+            File.WriteAllText(Path.Combine(tempRoot, "App.slnx"), "");
+            Check("root solution preferred over project", TargetResolver.Resolve(tempRoot, null) == "App.slnx");
+            Check("explicit project overrides discovery", TargetResolver.Resolve(tempRoot, "test/Foo/Foo.csproj") == "test/Foo/Foo.csproj");
+        }
+        finally
+        {
+            try { Directory.Delete(tempRoot, true); } catch (Exception) { /* best effort */ }
+        }
+    }
+
+    private static void ResultParsingTests()
+    {
+        Section("Result parsing");
+
+        const string trx = """
+        
+        
+          
+            
+            
+              Expected: foo Actual: barat StringUtilityTest.cs:line 142
+            
+            
+          
+          
+            
+          
+          
+        
+        """;
+
+        var result = TrxParser.Parse(trx);
+        Check("trx totals parsed", result is { Total: 3, Passed: 1, Failed: 1 });
+        Check("skipped derived from notExecuted", result.Skipped == 1);
+        Check("failure detail captured", result.Failures.Count == 1 && result.Failures[0].TestName == "Sanitize_WithUnicode_ReturnsExpectedValue");
+        Check("failure class resolved from TestDefinitions", result.Failures[0].ClassName == "Cuemon.Text.Tests.StringUtilityTest");
+        Check("failure message captured", result.Failures[0].Message == "Expected: foo Actual: bar");
+
+        var merged = result.Merge(TrxParser.Parse(trx));
+        Check("multiple trx files aggregate", merged is { Total: 6, Failed: 2, TrxFilesParsed: 2 });
+    }
+
+    private static void FailureClassificationTests()
+    {
+        Section("Failure classification");
+
+        var passing = new TestRunResult { Total = 5, Passed = 5, TrxFilesParsed = 1 };
+        var withFailures = new TestRunResult { Total = 5, Passed = 3, Failed = 2, TrxFilesParsed = 1 };
+
+        Check("restore failure classified from phase marker",
+            FailureClassifier.Classify(new Dictionary { ["restore"] = 1 }, 9, false, TestRunResult.Empty).Kind == FailureKind.Restore);
+        Check("build failure classified",
+            FailureClassifier.Classify(new Dictionary { ["restore"] = 0, ["build"] = 1 }, 10, false, TestRunResult.Empty).Kind == FailureKind.Compilation);
+        Check("real test failures classified as TestFailure, not infrastructure",
+            FailureClassifier.Classify(new Dictionary { ["restore"] = 0, ["build"] = 0, ["test"] = 1 }, 1, false, withFailures).Kind == FailureKind.TestFailure);
+        Check("nonzero test exit without failing trx is a test-host failure",
+            FailureClassifier.Classify(new Dictionary { ["restore"] = 0, ["build"] = 0, ["test"] = 1 }, 1, false, TestRunResult.Empty).Kind == FailureKind.TestHost);
+        Check("all passing classified as None",
+            FailureClassifier.Classify(new Dictionary { ["restore"] = 0, ["build"] = 0, ["test"] = 0 }, 0, false, passing).Kind == FailureKind.None);
+        Check("cancellation wins over everything",
+            FailureClassifier.Classify(new Dictionary { ["restore"] = 0 }, -1, true, passing).Kind == FailureKind.Cancelled);
+
+        Check("test failure maps to exit code 1", FailureClassifier.ToExitCode(FailureKind.TestFailure) == ExitCode.TestFailures);
+        Check("docker unavailable maps to distinct exit code", FailureClassifier.ToExitCode(FailureKind.DockerUnavailable) == ExitCode.DockerUnavailable);
+
+        var markers = FailureClassifier.ParsePhaseMarkers("noise\n##RT_PHASE_END:restore:0##\nmore\n##RT_PHASE_END:build:2##\n");
+        Check("phase markers parsed from output", markers["restore"] == 0 && markers["build"] == 2);
+    }
+
+    private static void CancellationAndCleanupTests()
+    {
+        Section("Cancellation and cleanup");
+
+        var name1 = ContainerPlanner.ContainerName("run-01");
+        var name2 = ContainerPlanner.ContainerName("run-02");
+        Check("container name is derivable and unique per run id", name1 != name2 && name1.StartsWith("dotnet-remote-testing-"));
+
+        var outcome = FailureClassifier.Classify(new Dictionary(), -1, cancelled: true, TestRunResult.Empty);
+        Check("cancelled run classified as Cancelled", outcome.Kind == FailureKind.Cancelled);
+        Check("cancelled maps to exit 14", FailureClassifier.ToExitCode(FailureKind.Cancelled) == ExitCode.Cancelled);
+
+        // The knowable container name is what makes deterministic cleanup possible even after cancellation.
+        var plan = ContainerPlanner.Build("img", name1, [], new TestCommandOptions());
+        Check("cleanup target (container name) is embedded in the run args", plan.DockerRunArgs.Contains(name1));
+    }
+
+    private static void Section(string name) => Console.WriteLine($"[{name}]");
+
+    private static void Check(string description, bool condition)
+    {
+        if (condition)
+        {
+            _passed++;
+            Console.WriteLine($"  PASS  {description}");
+        }
+        else
+        {
+            _failed++;
+            Console.WriteLine($"  FAIL  {description}");
+        }
+    }
+}
diff --git a/skills/dotnet-remote-testing/scripts/test-remote-testing.ps1 b/skills/dotnet-remote-testing/scripts/test-remote-testing.ps1
new file mode 100644
index 0000000..913600a
--- /dev/null
+++ b/skills/dotnet-remote-testing/scripts/test-remote-testing.ps1
@@ -0,0 +1,157 @@
+#!/usr/bin/env pwsh
+# Deterministic test harness for the dotnet-remote-testing runner.
+#
+# Runs the runner's built-in --self-test (pure-logic unit tests) and then exercises the command-line
+# surface (list / plan / resolution / failure classification) against fixtures in a temp workspace using
+# --offline and an injected release-index file. It never needs Docker or the network, and never writes
+# inside this repository (eval isolation).
+
+param(
+    [string]$RunnerPath = (Join-Path $PSScriptRoot 'remote-test.cs')
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+$PSNativeCommandUseErrorActionPreference = $false
+
+$script:pass = 0
+$script:fail = 0
+
+function Write-Result {
+    param([bool]$Ok, [string]$Name, [string]$Detail = '')
+    if ($Ok) {
+        $script:pass++
+        Write-Host "[PASS] $Name"
+    }
+    else {
+        $script:fail++
+        Write-Host "[FAIL] $Name" -ForegroundColor Red
+        if ($Detail) { Write-Host "       $Detail" -ForegroundColor Red }
+    }
+}
+
+function Invoke-Runner {
+    param([string[]]$RunnerArgs)
+    $prev = $ErrorActionPreference
+    $ErrorActionPreference = 'Continue'
+    try {
+        $out = & dotnet run --file $RunnerPath -- @RunnerArgs 2>&1 | Out-String
+    }
+    finally {
+        $ErrorActionPreference = $prev
+    }
+    return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $out }
+}
+
+function Get-Json {
+    param([string]$Text)
+    $start = $Text.IndexOf('{')
+    if ($start -lt 0) { throw "No JSON object in runner output: $Text" }
+    return ($Text.Substring($start) | ConvertFrom-Json)
+}
+
+if (-not (Test-Path $RunnerPath)) {
+    throw "Runner not found at $RunnerPath"
+}
+
+$workspace = Join-Path ([System.IO.Path]::GetTempPath()) ('dotnet-remote-testing-harness-' + [guid]::NewGuid().ToString('N'))
+New-Item -ItemType Directory -Path $workspace -Force | Out-Null
+
+# A representative release index: one active LTS, one maintenance STS, one preview, and one EOL channel.
+$indexPath = Join-Path $workspace 'releases-index.json'
+@'
+{
+  "releases-index": [
+    { "channel-version": "11.0", "latest-sdk": "11.0.100-preview.6.26359.118", "support-phase": "preview", "release-type": "sts" },
+    { "channel-version": "10.0", "latest-sdk": "10.0.302", "support-phase": "active", "release-type": "lts", "eol-date": "2028-11-14" },
+    { "channel-version": "9.0", "latest-sdk": "9.0.316", "support-phase": "maintenance", "release-type": "sts", "eol-date": "2026-11-10" },
+    { "channel-version": "7.0", "latest-sdk": "7.0.410", "support-phase": "eol", "release-type": "sts" }
+  ]
+}
+'@ | Set-Content -Path $indexPath -Encoding utf8
+
+try {
+    Write-Host '== 1. Built-in self-test (pure logic) =='
+    $selfTest = Invoke-Runner @('--self-test')
+    $selfOk = $selfTest.ExitCode -eq 0 -and $selfTest.Output -match 'Self-test: (\d+) passed, 0 failed'
+    Write-Result $selfOk 'runner --self-test passes' ("exit=$($selfTest.ExitCode)")
+    if ($selfTest.Output -match 'Self-test: (\d+) passed, (\d+) failed') {
+        Write-Host ("       {0} pure-logic assertions passed" -f $matches[1])
+    }
+
+    Write-Host ''
+    Write-Host '== 2. Zero-configuration list from injected release metadata (offline) =='
+    $emptyRoot = Join-Path $workspace 'empty'
+    New-Item -ItemType Directory -Path $emptyRoot -Force | Out-Null
+    $list = Invoke-Runner @('list', '--repo-root', $emptyRoot, '--offline', '--releases-index-file', $indexPath, '--json')
+    $listJson = Get-Json $list.Output
+    $names = @($listJson.environments | ForEach-Object { $_.name })
+    Write-Result ($names -contains 'dotnet-10-lts') 'derives LTS environment name' ($names -join ',')
+    Write-Result ($names -contains 'dotnet-9-sts') 'derives STS environment name'
+    Write-Result ($names -contains 'dotnet-11-preview') 'derives preview environment name'
+    Write-Result (-not ($names -contains 'dotnet-7-sts')) 'excludes EOL channel'
+    $img = ($listJson.environments | Where-Object { $_.name -eq 'dotnet-11-preview' }).image
+    Write-Result ($img -eq 'mcr.microsoft.com/dotnet/sdk:11.0.100-preview.6') 'preview image tag strips build metadata' $img
+    Write-Result (($listJson.environments | Where-Object { $_.name -eq 'dotnet-10-lts' }).image -eq 'mcr.microsoft.com/dotnet/sdk:10.0.302') 'stable image uses exact SDK tag'
+
+    Write-Host ''
+    Write-Host '== 3. Zero-configuration is not an error =='
+    Write-Result ($list.ExitCode -eq 0) 'absent testenvironments.json is not an error' ("exit=$($list.ExitCode)")
+
+    Write-Host ''
+    Write-Host '== 4. testenvironments.json is authoritative and reports unsupported types =='
+    $cfgRoot = Join-Path $workspace 'configured'
+    New-Item -ItemType Directory -Path $cfgRoot -Force | Out-Null
+    @'
+{
+  "version": "1",
+  "environments": [
+    { "name": "ci-docker", "type": "docker", "dockerImage": "mcr.microsoft.com/dotnet/sdk:10.0" },
+    { "name": "wsl-env", "type": "wsl", "wslDistribution": "Ubuntu" }
+  ]
+}
+'@ | Set-Content -Path (Join-Path $cfgRoot 'testenvironments.json') -Encoding utf8
+    $cfgList = Invoke-Runner @('list', '--repo-root', $cfgRoot, '--json')
+    $cfgJson = Get-Json $cfgList.Output
+    $cfgNames = @($cfgJson.environments | ForEach-Object { $_.name })
+    Write-Result ($cfgNames.Count -eq 1 -and $cfgNames[0] -eq 'ci-docker') 'lists only configured docker environments' ($cfgNames -join ',')
+    Write-Result (-not ($cfgNames -contains 'dotnet-10-lts')) 'does not supplement config with generated envs'
+    $unsupportedNames = @($cfgJson.unsupported | ForEach-Object { $_.name })
+    Write-Result ($unsupportedNames -contains 'wsl-env') 'reports WSL as unsupported'
+
+    Write-Host ''
+    Write-Host '== 5. Naming an unsupported environment is reported, not converted =='
+    $named = Invoke-Runner @('plan', '--repo-root', $cfgRoot, '-e', 'wsl-env', '--offline', '--json')
+    $namedJson = Get-Json $named.Output
+    Write-Result ($namedJson.failureKind -eq 'UnsupportedEnvironment') 'unsupported named env -> UnsupportedEnvironment' ("kind=$($namedJson.failureKind) exit=$($named.ExitCode)")
+
+    Write-Host ''
+    Write-Host '== 6. plan derives the image offline without pulling =='
+    $projDir = Join-Path $emptyRoot 'test\Foo'
+    New-Item -ItemType Directory -Path $projDir -Force | Out-Null
+    'net10.0' | Set-Content -Path (Join-Path $projDir 'Foo.csproj') -Encoding utf8
+    $plan = Invoke-Runner @('plan', '--repo-root', $emptyRoot, '-e', 'dotnet-10-lts', '--offline', '--releases-index-file', $indexPath, '--json')
+    $planJson = Get-Json $plan.Output
+    Write-Result ($planJson.image.reference -eq 'mcr.microsoft.com/dotnet/sdk:10.0.302') 'plan resolves the SDK image reference' $planJson.image.reference
+    Write-Result ($planJson.dockerRunArgs -contains '--rm' -and ($planJson.dockerRunArgs -notcontains '--privileged')) 'plan run args are --rm and not privileged'
+    Write-Result (@($planJson.dockerRunArgs) -notcontains '/var/run/docker.sock' -and -not ($plan.Output -match 'docker\.sock')) 'plan never mounts the docker socket'
+
+    Write-Host ''
+    Write-Host '== 7. Conflicting docker source is a configuration failure =='
+    $badRoot = Join-Path $workspace 'bad'
+    New-Item -ItemType Directory -Path $badRoot -Force | Out-Null
+    @'
+{ "version": "1", "environments": [ { "name": "x", "type": "docker", "dockerImage": "a", "dockerFile": "b" } ] }
+'@ | Set-Content -Path (Join-Path $badRoot 'testenvironments.json') -Encoding utf8
+    $bad = Invoke-Runner @('list', '--repo-root', $badRoot, '--json')
+    $badJson = Get-Json $bad.Output
+    $codes = @($badJson.configDiagnostics | ForEach-Object { $_.Code })
+    Write-Result ($codes -contains 'CONFLICTING_DOCKER_SOURCE') 'reports CONFLICTING_DOCKER_SOURCE' ($codes -join ',')
+}
+finally {
+    Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+Write-Host ''
+Write-Host ("Harness: {0} passed, {1} failed." -f $script:pass, $script:fail)
+if ($script:fail -gt 0) { exit 1 } else { exit 0 }
diff --git a/skills/dotnet-remote-testing/scripts/validate-skill.ps1 b/skills/dotnet-remote-testing/scripts/validate-skill.ps1
new file mode 100644
index 0000000..a0e6d46
--- /dev/null
+++ b/skills/dotnet-remote-testing/scripts/validate-skill.ps1
@@ -0,0 +1,48 @@
+#!/usr/bin/env pwsh
+# Structural + contract validation for the dotnet-remote-testing skill.
+# Confirms required files exist, SKILL.md keeps its non-negotiable contracts, and the deterministic
+# runner test harness (which includes the built-in --self-test) passes.
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$skillRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+
+$required = @(
+    'SKILL.md', 'FORMS.md', 'evals/evals.json',
+    'scripts/remote-test.cs', 'scripts/test-remote-testing.ps1', 'scripts/validate-skill.ps1',
+    'references/testenvironments-json.md', 'references/release-discovery.md', 'references/docker-execution.md'
+)
+foreach ($relative in $required) {
+    if (-not (Test-Path -LiteralPath (Join-Path $skillRoot $relative) -PathType Leaf)) {
+        throw "Missing required dotnet-remote-testing file: $relative"
+    }
+}
+
+$skill = [System.IO.File]::ReadAllText((Join-Path $skillRoot 'SKILL.md'))
+$contracts = @(
+    'testenvironments.json',
+    'mcr.microsoft.com/dotnet/sdk',
+    'remote-test.cs',
+    'Choose a test environment',
+    'Do not generate container plumbing',
+    'Do not hardcode .NET versions',
+    'never silently fall back to local',
+    'reproduce'
+)
+foreach ($needle in $contracts) {
+    if (-not $skill.Contains($needle, [System.StringComparison]::Ordinal)) {
+        throw "SKILL.md is missing required contract: $needle"
+    }
+}
+
+# Guard the governing principle: Docker complexity must not be the boundary of the capability.
+if (-not $skill.Contains('orchestration', [System.StringComparison]::Ordinal)) {
+    throw 'SKILL.md must describe the skill as the orchestration layer over a deterministic runner.'
+}
+
+& pwsh -NoProfile -File (Join-Path $PSScriptRoot 'test-remote-testing.ps1')
+if ($LASTEXITCODE -ne 0) {
+    throw "Runner test harness failed with exit code $LASTEXITCODE."
+}
+
+Write-Host 'dotnet-remote-testing skill validation: PASS'

From 7fe7e616a5f11c5f335ec7ccc985a23eea74bf6d Mon Sep 17 00:00:00 2001
From: "aicia[bot]" 
Date: Sun, 9 Aug 2026 23:39:56 +0200
Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=94=A8=20add=20skill=20benchmark=20in?=
 =?UTF-8?q?frastructure?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Introduces run-skill-benchmark.ps1 as the preferred local entry point for skill benchmarking. Manages a single persistent temp workspace, shares benchmark-scoped caches, stages fixtures once, enforces bounded parallelism and per-run timeouts, and prewarms expensive resolver work. Still delegates aggregation and static review generation to the installed Anthropic skill-creator copy.
---
 scripts/run-skill-benchmark.ps1           | 1498 +++++++++++++++++++++
 scripts/skill-benchmark/log-dotnet.ps1    |   99 ++
 scripts/skill-benchmark/mock-executor.ps1 |   87 ++
 scripts/skill-benchmark/mock-grader.ps1   |   68 +
 scripts/test-run-skill-benchmark.ps1      |  131 ++
 5 files changed, 1883 insertions(+)
 create mode 100644 scripts/run-skill-benchmark.ps1
 create mode 100644 scripts/skill-benchmark/log-dotnet.ps1
 create mode 100644 scripts/skill-benchmark/mock-executor.ps1
 create mode 100644 scripts/skill-benchmark/mock-grader.ps1
 create mode 100644 scripts/test-run-skill-benchmark.ps1

diff --git a/scripts/run-skill-benchmark.ps1 b/scripts/run-skill-benchmark.ps1
new file mode 100644
index 0000000..cbc265e
--- /dev/null
+++ b/scripts/run-skill-benchmark.ps1
@@ -0,0 +1,1498 @@
+param(
+    [Parameter(Mandatory = $true)]
+    [string]$SkillPath,
+
+    [string]$WorkspaceRoot,
+
+    [string]$BaselineSkillPath,
+
+    [int]$MaxParallel = 4,
+
+    [int]$MaxGradeParallel = 4,
+
+    [ValidateRange(30, 3600)]
+    [int]$RunTimeoutSeconds = 240,
+
+    [ValidateRange(30, 1800)]
+    [int]$GradeTimeoutSeconds = 120,
+
+    [string]$Model = 'gpt-5.4',
+
+    [string]$GraderModel,
+
+    [int]$BenchmarkCandidateLimit = 5,
+
+    [string]$ExecutorCommand,
+
+    [string]$GraderCommand,
+
+    [string[]]$EvalId,
+
+    [switch]$CompareWithLegacy,
+
+    [switch]$SkipSkillValidation,
+
+    [switch]$SkipReview
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+[Console]::InputEncoding = $utf8NoBom
+[Console]::OutputEncoding = $utf8NoBom
+$OutputEncoding = $utf8NoBom
+
+function Write-JsonFile {
+    param(
+        [Parameter(Mandatory = $true)] [string]$Path,
+        [Parameter(Mandatory = $true)] $Value
+    )
+
+    $directory = Split-Path -Path $Path -Parent
+    if (-not [string]::IsNullOrWhiteSpace($directory)) {
+        New-Item -ItemType Directory -Path $directory -Force | Out-Null
+    }
+    $Value | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $Path -Encoding utf8
+}
+
+function Write-TextFile {
+    param(
+        [Parameter(Mandatory = $true)] [string]$Path,
+        [AllowEmptyString()] [string]$Content
+    )
+
+    $directory = Split-Path -Path $Path -Parent
+    if (-not [string]::IsNullOrWhiteSpace($directory)) {
+        New-Item -ItemType Directory -Path $directory -Force | Out-Null
+    }
+    [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom)
+}
+
+function Get-RepoRoot {
+    return (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+}
+
+function Get-ResolvedPath {
+    param([Parameter(Mandatory = $true)] [string]$Path)
+    return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath((Resolve-Path -LiteralPath $Path).Path)
+}
+
+function Get-SkillMetadata {
+    param([Parameter(Mandatory = $true)] [string]$ResolvedSkillPath)
+
+    $skillMdPath = Join-Path $ResolvedSkillPath 'SKILL.md'
+    if (-not (Test-Path -LiteralPath $skillMdPath -PathType Leaf)) {
+        throw "Missing SKILL.md at '$skillMdPath'."
+    }
+
+    $content = [System.IO.File]::ReadAllText($skillMdPath, $utf8NoBom)
+    $match = [regex]::Match($content, '^\s*name:\s*(?[a-z0-9-]+)\s*$', [System.Text.RegularExpressions.RegexOptions]::Multiline)
+    if (-not $match.Success) {
+        throw "Unable to resolve skill name from '$skillMdPath'."
+    }
+
+    return [pscustomobject]@{
+        Name = $match.Groups['name'].Value
+        SkillMdPath = $skillMdPath
+        Content = $content
+    }
+}
+
+function Get-EvalDefinitions {
+    param(
+        [Parameter(Mandatory = $true)] [string]$ResolvedSkillPath,
+        [string[]]$SelectedEvalId
+    )
+
+    $evalPath = Join-Path $ResolvedSkillPath 'evals\evals.json'
+    if (-not (Test-Path -LiteralPath $evalPath -PathType Leaf)) {
+        throw "Missing eval file '$evalPath'."
+    }
+
+    $evals = Get-Content -LiteralPath $evalPath -Raw | ConvertFrom-Json
+    $items = @($evals.evals)
+    if ($SelectedEvalId -and $SelectedEvalId.Count -gt 0) {
+        $selected = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+        foreach ($id in $SelectedEvalId) { [void]$selected.Add([string]$id) }
+        $items = @($items | Where-Object { $selected.Contains([string]$_.id) })
+    }
+
+    return @($items | Sort-Object id)
+}
+
+function Get-Slug {
+    param([Parameter(Mandatory = $true)] [string]$Text)
+
+    $slug = ($Text.ToLowerInvariant() -replace '[^a-z0-9]+', '-') -replace '(^-+|-+$)', ''
+    if ([string]::IsNullOrWhiteSpace($slug)) { return 'eval' }
+    return $slug
+}
+
+function Resolve-SkillCreatorRoot {
+    $candidates = @(
+        (Join-Path $HOME '.agents\skills\skill-creator'),
+        (Join-Path $HOME '.claude\skills\skill-creator')
+    ) | Where-Object { Test-Path -LiteralPath $_ -PathType Container }
+    if (@($candidates).Count -eq 0) {
+        throw "Install Anthropic's skill-creator before running the benchmark viewer."
+    }
+    return (Resolve-Path -LiteralPath $candidates[0]).Path
+}
+
+function Get-RealDotnetPath {
+    $command = Get-Command dotnet -CommandType Application | Select-Object -First 1
+    if ($null -eq $command) {
+        throw 'dotnet was not found on PATH.'
+    }
+    return $command.Source
+}
+
+function Get-CopilotScriptPath {
+    $command = Get-Command copilot -ErrorAction Stop
+    return $command.Source
+}
+
+function Initialize-DotnetShim {
+    param(
+        [Parameter(Mandatory = $true)] [string]$Workspace,
+        [Parameter(Mandatory = $true)] [string]$RealDotnet
+    )
+
+    $shimRoot = Join-Path $Workspace '.benchmark\dotnet'
+    New-Item -ItemType Directory -Path $shimRoot -Force | Out-Null
+
+    $shimCommandPath = Join-Path $shimRoot 'dotnet.cmd'
+    $shimScriptPath = Join-Path $PSScriptRoot 'skill-benchmark\log-dotnet.ps1'
+    $shimContent = @"
+@echo off
+pwsh -NoProfile -File "$shimScriptPath" -RealDotnet "%SKILL_BENCHMARK_DOTNET_REAL%" -LogDirectory "%SKILL_BENCHMARK_DOTNET_LOG_DIR%" -StdoutPath "%SKILL_BENCHMARK_DOTNET_STDOUT%" -StderrPath "%SKILL_BENCHMARK_DOTNET_STDERR%" %*
+exit /b %ERRORLEVEL%
+"@
+    Write-TextFile -Path $shimCommandPath -Content $shimContent
+
+    return $shimRoot
+}
+
+function New-RunManifest {
+    param(
+        [Parameter(Mandatory = $true)] [string]$RepoRoot,
+        [Parameter(Mandatory = $true)] [string[]]$ExcludedPrefixes
+    )
+
+    $manifest = @{}
+    $files = Get-ChildItem -LiteralPath $RepoRoot -Recurse -File -Force | Where-Object {
+        $relative = [System.IO.Path]::GetRelativePath($RepoRoot, $_.FullName).Replace('\', '/')
+        foreach ($prefix in $ExcludedPrefixes) {
+            if ($relative.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
+        }
+        return $true
+    }
+
+    foreach ($file in $files) {
+        $relative = [System.IO.Path]::GetRelativePath($RepoRoot, $file.FullName).Replace('\', '/')
+        $manifest[$relative] = [pscustomobject]@{
+            path = $file.FullName
+            hash = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash
+        }
+    }
+    return $manifest
+}
+
+function Get-ChangedFiles {
+    param(
+        [Parameter(Mandatory = $true)] [string]$BaselineRoot,
+        [Parameter(Mandatory = $true)] [string]$CurrentRoot,
+        [Parameter(Mandatory = $true)] [hashtable]$BaselineManifest,
+        [Parameter(Mandatory = $true)] [string[]]$ExcludedPrefixes
+    )
+
+    $currentManifest = New-RunManifest -RepoRoot $CurrentRoot -ExcludedPrefixes $ExcludedPrefixes
+    $changes = [System.Collections.Generic.List[object]]::new()
+
+    foreach ($relative in ($BaselineManifest.Keys + $currentManifest.Keys | Sort-Object -Unique)) {
+        $before = if ($BaselineManifest.ContainsKey($relative)) { $BaselineManifest[$relative] } else { $null }
+        $after = if ($currentManifest.ContainsKey($relative)) { $currentManifest[$relative] } else { $null }
+        if ($null -eq $before) {
+            $changes.Add([pscustomobject]@{ path = $relative; change = 'added'; before = $null; after = $after.path })
+            continue
+        }
+        if ($null -eq $after) {
+            $changes.Add([pscustomobject]@{ path = $relative; change = 'deleted'; before = $before.path; after = $null })
+            continue
+        }
+        if ($before.hash -ne $after.hash) {
+            $changes.Add([pscustomobject]@{ path = $relative; change = 'modified'; before = $before.path; after = $after.path })
+        }
+    }
+
+    return @($changes | Sort-Object path)
+}
+
+function Invoke-GitNoPager {
+    param(
+        [Parameter(Mandatory = $true)] [string[]]$Arguments
+    )
+
+    $output = & git --no-pager @Arguments 2>&1
+    return [pscustomobject]@{
+        ExitCode = $LASTEXITCODE
+        Output = @($output)
+    }
+}
+
+function Write-ChangeArtifacts {
+    param(
+        [AllowEmptyCollection()] [object[]]$Changes,
+        [Parameter(Mandatory = $true)] [string]$OutputsPath
+    )
+
+    $summaryLines = [System.Collections.Generic.List[string]]::new()
+    $summaryLines.Add('# Changed Files')
+    $summaryLines.Add('')
+    if (@($Changes).Count -eq 0) {
+        $summaryLines.Add('No source changes were detected.')
+    } else {
+        foreach ($change in @($Changes)) {
+            $summaryLines.Add("- $($change.change): $($change.path)")
+        }
+    }
+    Write-TextFile -Path (Join-Path $OutputsPath 'changed-files.md') -Content ($summaryLines -join [Environment]::NewLine)
+
+    $diffParts = [System.Collections.Generic.List[string]]::new()
+    foreach ($change in @($Changes)) {
+        switch ($change.change) {
+            'modified' {
+                $diff = Invoke-GitNoPager -Arguments @('diff', '--no-index', '--', $change.before, $change.after)
+                $diffParts.Add(($diff.Output -join [Environment]::NewLine))
+            }
+            'added' {
+                $addedContent = Get-Content -LiteralPath $change.after -Raw
+                $addedText = @('+++ ' + $change.path, $addedContent) -join [Environment]::NewLine
+                $diffParts.Add($addedText)
+            }
+            'deleted' {
+                $deletedContent = Get-Content -LiteralPath $change.before -Raw
+                $deletedText = @('--- ' + $change.path, $deletedContent) -join [Environment]::NewLine
+                $diffParts.Add($deletedText)
+            }
+        }
+    }
+    Write-TextFile -Path (Join-Path $OutputsPath 'repo.diff') -Content (($diffParts -join [Environment]::NewLine + [Environment]::NewLine).Trim())
+
+    $snapshotsRoot = Join-Path $OutputsPath 'snapshots'
+    if (@($Changes).Count -gt 0) {
+        New-Item -ItemType Directory -Path $snapshotsRoot -Force | Out-Null
+        foreach ($change in @($Changes)) {
+            if ($null -eq $change.after) { continue }
+            $destination = Join-Path $snapshotsRoot ($change.path -replace '/', '\')
+            $destinationDir = Split-Path -Path $destination -Parent
+            New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
+            Copy-Item -LiteralPath $change.after -Destination $destination -Force
+        }
+    }
+}
+
+function Parse-CopilotEvents {
+    param([Parameter(Mandatory = $true)] [string]$JsonlPath)
+
+    if (-not (Test-Path -LiteralPath $JsonlPath -PathType Leaf)) { return @() }
+
+    $events = [System.Collections.Generic.List[object]]::new()
+    foreach ($line in Get-Content -LiteralPath $JsonlPath) {
+        if ([string]::IsNullOrWhiteSpace($line)) { continue }
+        try {
+            [void]$events.Add(($line | ConvertFrom-Json))
+        } catch {
+        }
+    }
+    return @($events)
+}
+
+function Get-PowerShellCommandDurations {
+    param(
+        [AllowEmptyCollection()] [object[]]$Events
+    )
+
+    $started = @{}
+    $summary = [ordered]@{
+        restoreSeconds = 0.0
+        buildSeconds = 0.0
+        testSeconds = 0.0
+        resolverSeconds = 0.0
+    }
+
+    foreach ($event in @($Events)) {
+        if ($event.type -eq 'tool.execution_start' -and [string]$event.data.toolName -eq 'powershell') {
+            $started[[string]$event.data.toolCallId] = $event
+            continue
+        }
+        if ($event.type -ne 'tool.execution_complete') {
+            continue
+        }
+
+        $toolCallId = [string]$event.data.toolCallId
+        if (-not $started.ContainsKey($toolCallId)) { continue }
+
+        $startEvent = $started[$toolCallId]
+        if ([string]$startEvent.data.toolName -ne 'powershell') { continue }
+        $durationSeconds = [math]::Round(([DateTimeOffset]::Parse([string]$event.timestamp) - [DateTimeOffset]::Parse([string]$startEvent.timestamp)).TotalSeconds, 3)
+        $command = [string]$startEvent.data.arguments.command
+
+        if ($command -match '(^|[;\s])dotnet\s+restore(\s|$)') {
+            $summary.restoreSeconds += $durationSeconds
+        }
+        if ($command -match '(^|[;\s])dotnet\s+build(\s|$)') {
+            $summary.buildSeconds += $durationSeconds
+        }
+        if ($command -match '(^|[;\s])dotnet\s+test(\s|$)') {
+            $summary.testSeconds += $durationSeconds
+        }
+        if ($command -match 'resolve-test-package-versions\.ps1') {
+            $summary.resolverSeconds += $durationSeconds
+        }
+    }
+
+    return [ordered]@{
+        restoreSeconds = [math]::Round([double]$summary.restoreSeconds, 3)
+        buildSeconds = [math]::Round([double]$summary.buildSeconds, 3)
+        testSeconds = [math]::Round([double]$summary.testSeconds, 3)
+        resolverSeconds = [math]::Round([double]$summary.resolverSeconds, 3)
+    }
+}
+
+function Get-CopilotMetrics {
+    param(
+        [AllowEmptyCollection()] [object[]]$Events,
+        [Parameter(Mandatory = $true)] [string]$TranscriptPath,
+        [Parameter(Mandatory = $true)] [string]$OutputsPath
+    )
+
+    $toolCalls = @{}
+    $errors = 0
+    foreach ($event in @($Events)) {
+        if ($event.type -eq 'tool.execution_start') {
+            $toolName = [string]$event.data.toolName
+            if (-not $toolCalls.ContainsKey($toolName)) { $toolCalls[$toolName] = 0 }
+            $toolCalls[$toolName]++
+        }
+        if ($event.type -eq 'tool.execution_complete' -and -not [bool]$event.data.success) {
+            $errors++
+        }
+    }
+
+    $transcriptChars = if (Test-Path -LiteralPath $TranscriptPath) { (Get-Content -LiteralPath $TranscriptPath -Raw).Length } else { 0 }
+    $outputChars = 0
+    if (Test-Path -LiteralPath $OutputsPath -PathType Container) {
+        foreach ($file in Get-ChildItem -LiteralPath $OutputsPath -Recurse -File -Force) {
+            $outputChars += $file.Length
+        }
+    }
+
+    $toolCallTotal = @($toolCalls.Keys | ForEach-Object { $toolCalls[$_] } | Measure-Object -Sum).Sum
+    if ($null -eq $toolCallTotal) { $toolCallTotal = 0 }
+
+    return [ordered]@{
+        tool_calls = $toolCalls
+        total_tool_calls = $toolCallTotal
+        total_steps = @($Events | Where-Object type -eq 'assistant.turn_end').Count
+        errors_encountered = $errors
+        output_chars = $outputChars
+        transcript_chars = $transcriptChars
+    }
+}
+
+function Kill-ProcessTree {
+    param([Parameter(Mandatory = $true)] [System.Diagnostics.Process]$Process)
+
+    if ($Process.HasExited) {
+        return [pscustomobject]@{
+            attempted = $false
+            completed = $true
+            exitCode = $Process.ExitCode
+        }
+    }
+
+    try {
+        $Process.Kill($true)
+        $Process.WaitForExit(5000) | Out-Null
+        return [pscustomobject]@{
+            attempted = $true
+            completed = $Process.HasExited
+            exitCode = if ($Process.HasExited) { $Process.ExitCode } else { $null }
+        }
+    } catch {
+        return [pscustomobject]@{
+            attempted = $true
+            completed = $false
+            error = $_.Exception.Message
+        }
+    }
+}
+
+function Write-TranscriptFallback {
+    param(
+        [Parameter(Mandatory = $true)] [string]$TranscriptPath,
+        [Parameter(Mandatory = $true)] [string]$Prompt,
+        [AllowEmptyCollection()] [object[]]$Events,
+        [string]$StderrText
+    )
+
+    if (Test-Path -LiteralPath $TranscriptPath -PathType Leaf) { return }
+
+    $assistantMessages = @($Events | Where-Object type -eq 'assistant.message' | ForEach-Object { [string]$_.data.content })
+    $content = @(
+        '# Copilot CLI Session (Fallback)'
+        ''
+        '## Eval Prompt'
+        ''
+        $Prompt
+        ''
+        '## Assistant Output'
+        ''
+        if (@($assistantMessages).Count -gt 0) { ($assistantMessages -join [Environment]::NewLine + [Environment]::NewLine) } else { '(No final assistant message was captured.)' }
+    )
+    if (-not [string]::IsNullOrWhiteSpace($StderrText)) {
+        $content += @(
+            '',
+            '## stderr',
+            '',
+            '```text',
+            $StderrText.Trim(),
+            '```'
+        )
+    }
+    Write-TextFile -Path $TranscriptPath -Content ($content -join [Environment]::NewLine)
+}
+
+function Get-CopilotFinalMessage {
+    param([AllowEmptyCollection()] [object[]]$Events)
+
+    $message = $Events | Where-Object type -eq 'assistant.message' | Select-Object -Last 1
+    if ($null -eq $message) { return $null }
+    return [string]$message.data.content
+}
+
+function Get-UsageResult {
+    param([AllowEmptyCollection()] [object[]]$Events)
+
+    $result = $Events | Where-Object type -eq 'result' | Select-Object -Last 1
+    if ($null -eq $result) { return $null }
+    return $result
+}
+
+function Summarize-DotnetLogs {
+    param(
+        [Parameter(Mandatory = $true)] [string]$LogDirectory,
+        [Parameter(Mandatory = $true)] [string]$OutputsPath
+    )
+
+    if (-not (Test-Path -LiteralPath $LogDirectory -PathType Container)) {
+        return [ordered]@{
+            totals = [ordered]@{
+                restoreSeconds = 0
+                buildSeconds = 0
+                testSeconds = 0
+            }
+            commands = @()
+        }
+    }
+
+    $entries = @(
+        Get-ChildItem -LiteralPath $LogDirectory -Filter 'dotnet-*.json' -File -Force |
+            Sort-Object Name |
+            ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json }
+    )
+
+    $restoreSeconds = 0.0
+    $buildSeconds = 0.0
+    $testSeconds = 0.0
+    $summaryLines = [System.Collections.Generic.List[string]]::new()
+    $summaryLines.Add('# dotnet command summary')
+    $summaryLines.Add('')
+    foreach ($entry in @($entries)) {
+        switch ([string]$entry.command) {
+            'restore' { $restoreSeconds += [double]$entry.durationSeconds }
+            'build' { $buildSeconds += [double]$entry.durationSeconds }
+            'test' { $testSeconds += [double]$entry.durationSeconds }
+        }
+        $summaryLines.Add("- $($entry.command) exit $($entry.exitCode) in $($entry.durationSeconds)s")
+    }
+    Write-TextFile -Path (Join-Path $OutputsPath 'dotnet-summary.md') -Content ($summaryLines -join [Environment]::NewLine)
+
+    return [ordered]@{
+        totals = [ordered]@{
+            restoreSeconds = [math]::Round($restoreSeconds, 3)
+            buildSeconds = [math]::Round($buildSeconds, 3)
+            testSeconds = [math]::Round($testSeconds, 3)
+        }
+        commands = @($entries)
+    }
+}
+
+function Summarize-ResolverTrace {
+    param(
+        [Parameter(Mandatory = $true)] [string]$TracePath,
+        [Parameter(Mandatory = $true)] [string]$OutputsPath
+    )
+
+    if (-not (Test-Path -LiteralPath $TracePath -PathType Leaf)) {
+        return [ordered]@{
+            durationSeconds = 0
+            cacheHits = 0
+            calls = 0
+        }
+    }
+
+    $events = [System.Collections.Generic.List[object]]::new()
+    foreach ($line in Get-Content -LiteralPath $TracePath) {
+        if ([string]::IsNullOrWhiteSpace($line)) { continue }
+        try { [void]$events.Add(($line | ConvertFrom-Json)) } catch { }
+    }
+
+    $summaryLines = [System.Collections.Generic.List[string]]::new()
+    $summaryLines.Add('# resolver summary')
+    $summaryLines.Add('')
+    foreach ($event in @($events)) {
+        $summaryLines.Add("- role $($event.role) tfms $($event.targetFrameworks -join ';') cacheHit $($event.cacheHit) duration $($event.durationSeconds)s")
+    }
+    Write-TextFile -Path (Join-Path $OutputsPath 'resolver-summary.md') -Content ($summaryLines -join [Environment]::NewLine)
+
+    return [ordered]@{
+        durationSeconds = [math]::Round((@($events | ForEach-Object { [double]$_.durationSeconds } | Measure-Object -Sum).Sum), 3)
+        cacheHits = @($events | Where-Object cacheHit).Count
+        calls = @($events).Count
+        events = @($events)
+    }
+}
+
+function Write-FallbackGrading {
+    param(
+        [Parameter(Mandatory = $true)] [string]$GradingPath,
+        [Parameter(Mandatory = $true)] [object[]]$Expectations,
+        [Parameter(Mandatory = $true)] [string]$Reason,
+        [Parameter(Mandatory = $true)] $Timing,
+        [Parameter(Mandatory = $true)] $Metrics
+    )
+
+    $entries = foreach ($expectation in @($Expectations)) {
+        [ordered]@{
+            text = [string]$expectation
+            passed = $false
+            evidence = $Reason
+        }
+    }
+    $total = @($entries).Count
+    Write-JsonFile -Path $GradingPath -Value ([ordered]@{
+        expectations = @($entries)
+        summary = [ordered]@{
+            passed = 0
+            failed = $total
+            total = $total
+            pass_rate = 0
+        }
+        execution_metrics = $Metrics
+        timing = $Timing
+        claims = @()
+        user_notes_summary = [ordered]@{
+            uncertainties = @()
+            needs_review = @()
+            workarounds = @($Reason)
+        }
+    })
+}
+
+function New-CopilotProcessArguments {
+    param(
+        [Parameter(Mandatory = $true)] [string]$Prompt,
+        [Parameter(Mandatory = $true)] [string]$WorkingDirectory,
+        [Parameter(Mandatory = $true)] [string]$ModelName,
+        [string]$SharePath,
+        [switch]$DisableSkillTool
+    )
+
+    $arguments = [System.Collections.Generic.List[string]]::new()
+    foreach ($argument in @(
+        '-p', $Prompt,
+        '--allow-all',
+        '--no-ask-user',
+        '--output-format', 'json',
+        '--no-custom-instructions',
+        '--disable-builtin-mcps',
+        '--disable-mcp-server', 'openart.ai',
+        '--disable-mcp-server', 'sonarqube',
+        '--disable-mcp-server', 'github-mcp-server',
+        '--model', $ModelName,
+        '-C', $WorkingDirectory
+    )) {
+        [void]$arguments.Add($argument)
+    }
+    if ($DisableSkillTool) {
+        [void]$arguments.Add('--excluded-tools')
+        [void]$arguments.Add('skill')
+    }
+    if (-not [string]::IsNullOrWhiteSpace($SharePath)) {
+        [void]$arguments.Add('--share')
+        [void]$arguments.Add($SharePath)
+    }
+    return @($arguments)
+}
+
+function New-CopilotWrapperScript {
+    param(
+        [Parameter(Mandatory = $true)] [string]$WrapperPath,
+        [Parameter(Mandatory = $true)] [string]$CopilotPowerShellPath,
+        [Parameter(Mandatory = $true)] [string]$Prompt,
+        [Parameter(Mandatory = $true)] [string]$WorkingDirectory,
+        [Parameter(Mandatory = $true)] [string]$ModelName,
+        [string]$SharePath,
+        [switch]$DisableSkillTool
+    )
+
+    $content = @(
+        '$prompt = @'''
+        $Prompt
+        '''@'
+        '$arguments = [System.Collections.Generic.List[string]]::new()'
+        '$arguments.Add(''-p'')'
+        '$arguments.Add($prompt)'
+        '$arguments.Add(''--allow-all'')'
+        '$arguments.Add(''--no-ask-user'')'
+        '$arguments.Add(''--output-format'')'
+        '$arguments.Add(''json'')'
+        '$arguments.Add(''--no-custom-instructions'')'
+        '$arguments.Add(''--disable-builtin-mcps'')'
+        '$arguments.Add(''--disable-mcp-server'')'
+        '$arguments.Add(''openart.ai'')'
+        '$arguments.Add(''--disable-mcp-server'')'
+        '$arguments.Add(''sonarqube'')'
+        '$arguments.Add(''--disable-mcp-server'')'
+        '$arguments.Add(''github-mcp-server'')'
+        ('$arguments.Add(''' + '--model' + ''')')
+        ('$arguments.Add(''' + $ModelName.Replace("'", "''") + ''')')
+        ('$arguments.Add(''' + '-C' + ''')')
+        ('$arguments.Add(''' + $WorkingDirectory.Replace("'", "''") + ''')')
+        if ($DisableSkillTool) {
+            '$arguments.Add(''--excluded-tools'')'
+            '$arguments.Add(''skill'')'
+        }
+        if (-not [string]::IsNullOrWhiteSpace($SharePath)) {
+            ('$arguments.Add(''' + '--share' + ''')')
+            ('$arguments.Add(''' + $SharePath.Replace("'", "''") + ''')')
+        }
+        ("& '{0}' @arguments" -f $CopilotPowerShellPath.Replace("'", "''"))
+        'exit $LASTEXITCODE'
+    ) -join [Environment]::NewLine
+
+    Write-TextFile -Path $WrapperPath -Content ($content + [Environment]::NewLine)
+}
+
+function Start-CommandProcess {
+    param(
+        [Parameter(Mandatory = $true)] [string]$FilePath,
+        [Parameter(Mandatory = $true)] [string[]]$Arguments,
+        [Parameter(Mandatory = $true)] [string]$WorkingDirectory,
+        [Parameter(Mandatory = $true)] [hashtable]$Environment,
+        [Parameter(Mandatory = $true)] [string]$StdoutPath,
+        [Parameter(Mandatory = $true)] [string]$StderrPath
+    )
+
+    $mergedEnvironment = @{}
+    foreach ($entry in Get-ChildItem Env:) {
+        $mergedEnvironment[$entry.Name] = $entry.Value
+    }
+    foreach ($key in $Environment.Keys) {
+        $mergedEnvironment[$key] = [string]$Environment[$key]
+    }
+
+    $startInfo = @{
+        FilePath = $FilePath
+        ArgumentList = @($Arguments)
+        WorkingDirectory = $WorkingDirectory
+        RedirectStandardOutput = $StdoutPath
+        RedirectStandardError = $StderrPath
+        PassThru = $true
+        NoNewWindow = $true
+        Environment = $mergedEnvironment
+    }
+
+    try {
+        $process = Start-Process @startInfo
+    } catch {
+        throw "Unable to start '$FilePath' in '$WorkingDirectory'. stdout='$StdoutPath' stderr='$StderrPath'. $($_.Exception.Message)"
+    }
+    return [pscustomobject]@{
+        Process = $process
+    }
+}
+
+function Invoke-SkillValidation {
+    param(
+        [Parameter(Mandatory = $true)] [string]$ResolvedSkillPath,
+        [Parameter(Mandatory = $true)] [string]$Workspace
+    )
+
+    $validateScript = Join-Path $ResolvedSkillPath 'scripts\validate-skill.ps1'
+    if (-not (Test-Path -LiteralPath $validateScript -PathType Leaf)) {
+        return [ordered]@{
+            ran = $false
+            exitCode = $null
+            durationSeconds = 0
+        }
+    }
+
+    $logPath = Join-Path $Workspace '.benchmark\skill-validation.log'
+    New-Item -ItemType Directory -Path (Split-Path -Path $logPath -Parent) -Force | Out-Null
+    $start = [DateTimeOffset]::UtcNow
+    $output = & pwsh -NoProfile -File $validateScript 2>&1
+    $exitCode = $LASTEXITCODE
+    Write-TextFile -Path $logPath -Content (($output -join [Environment]::NewLine) + [Environment]::NewLine)
+    return [ordered]@{
+        ran = $true
+        exitCode = $exitCode
+        durationSeconds = [math]::Round(([DateTimeOffset]::UtcNow - $start).TotalSeconds, 3)
+        logPath = $logPath
+    }
+}
+
+function Get-DotnetTestContexts {
+    param(
+        [Parameter(Mandatory = $true)] [object[]]$Evals,
+        [Parameter(Mandatory = $true)] [string]$ResolvedSkillPath
+    )
+
+    $inspectScript = Join-Path $ResolvedSkillPath 'scripts\inspect-dotnet-tests.ps1'
+    if (-not (Test-Path -LiteralPath $inspectScript -PathType Leaf)) { return @() }
+
+    $contexts = [System.Collections.Generic.List[object]]::new()
+    foreach ($eval in @($Evals)) {
+        $files = @($eval.files)
+        if (@($files).Count -eq 0) { continue }
+
+        $roots = @($files | ForEach-Object { ($_ -replace '/', '\').Split('\')[2] } | Sort-Object -Unique)
+        if (@($roots).Count -ne 1) { continue }
+        $fixtureRoot = Join-Path $ResolvedSkillPath (Join-Path 'evals\files' $roots[0])
+        $testProject = @($files | Where-Object { $_ -match 'test/.+\.csproj$' } | Select-Object -First 1)
+        if ([string]::IsNullOrWhiteSpace($testProject)) {
+            $testProject = @($files | Where-Object { $_ -match '\.csproj$' } | Select-Object -First 1)
+        }
+        if ([string]::IsNullOrWhiteSpace($testProject)) { continue }
+
+        $relativeProject = ($testProject -replace '^evals/files/[^/]+/', '') -replace '/', '\'
+        $result = & pwsh -NoProfile -File $inspectScript -RepoRoot $fixtureRoot -ProjectPath $relativeProject 2>&1
+        if ($LASTEXITCODE -ne 0) { continue }
+
+        try {
+            $json = ($result -join [Environment]::NewLine) | ConvertFrom-Json
+        } catch {
+            continue
+        }
+        $project = @($json.projects | Select-Object -First 1)
+        if ($null -eq $project) { continue }
+
+        $role = switch ([string]$project.role) {
+            'Ordinary unit test' { 'Unit' }
+            'ASP.NET Core functional test' { 'WebFunctional' }
+            'Console or worker functional test' { 'ApplicationFunctional' }
+            default { $null }
+        }
+        if ($null -eq $role) { continue }
+
+        $key = '{0}|{1}' -f $role, (@($project.frameworks) -join ';')
+        $contexts.Add([pscustomobject]@{
+            key = $key
+            role = $role
+            targetFrameworks = @($project.frameworks)
+        })
+    }
+
+    return @($contexts | Sort-Object key -Unique)
+}
+
+function Invoke-DotnetTestPrewarm {
+    param(
+        [Parameter(Mandatory = $true)] [object[]]$Contexts,
+        [Parameter(Mandatory = $true)] [string]$ResolvedSkillPath,
+        [Parameter(Mandatory = $true)] [string]$CacheDirectory,
+        [Parameter(Mandatory = $true)] [string]$TracePath,
+        [Parameter(Mandatory = $true)] [int]$CandidateLimit
+    )
+
+    if (@($Contexts).Count -eq 0) {
+        return [ordered]@{
+            ran = $false
+            durationSeconds = 0
+            contexts = @()
+        }
+    }
+
+    $resolver = Join-Path $ResolvedSkillPath 'scripts\resolve-test-package-versions.ps1'
+    $start = [DateTimeOffset]::UtcNow
+    $records = [System.Collections.Generic.List[object]]::new()
+    foreach ($context in @($Contexts)) {
+        $invocationStart = [DateTimeOffset]::UtcNow
+        $output = & pwsh -NoProfile -File $resolver -TargetFramework $context.targetFrameworks -Role $context.role -MaximumCandidates $CandidateLimit -CacheDirectory $CacheDirectory -TraceFile $TracePath 2>&1
+        $records.Add([ordered]@{
+            role = $context.role
+            targetFrameworks = @($context.targetFrameworks)
+            exitCode = $LASTEXITCODE
+            durationSeconds = [math]::Round(([DateTimeOffset]::UtcNow - $invocationStart).TotalSeconds, 3)
+            output = ($output -join [Environment]::NewLine)
+        })
+        if ($LASTEXITCODE -ne 0) {
+            throw "Dotnet-test prewarm failed for role '$($context.role)'."
+        }
+    }
+
+    return [ordered]@{
+        ran = $true
+        durationSeconds = [math]::Round(([DateTimeOffset]::UtcNow - $start).TotalSeconds, 3)
+        contexts = @($records)
+    }
+}
+
+function Get-CopilotGraderPrompt {
+    param(
+        [Parameter(Mandatory = $true)] [string]$SkillCreatorRoot,
+        [Parameter(Mandatory = $true)] [string]$TranscriptPath,
+        [Parameter(Mandatory = $true)] [string]$OutputsPath,
+        [Parameter(Mandatory = $true)] [string]$TimingPath,
+        [Parameter(Mandatory = $true)] [string[]]$Expectations
+    )
+
+    $graderInstructions = [System.IO.File]::ReadAllText((Join-Path $SkillCreatorRoot 'agents\grader.md'), $utf8NoBom)
+    $expectationsJson = ($Expectations | ConvertTo-Json)
+    return @"
+$graderInstructions
+
+Read these artifacts, then output only the grading JSON object:
+- transcript: $TranscriptPath
+- outputs directory: $OutputsPath
+- timing: $TimingPath
+
+Expectations:
+$expectationsJson
+
+Do not edit any files. Output JSON only.
+"@
+}
+
+function Start-BenchmarkRun {
+    param(
+        [Parameter(Mandatory = $true)] $RunPlan,
+        [Parameter(Mandatory = $true)] [string]$SkillName,
+        [Parameter(Mandatory = $true)] [string]$ResolvedSkillPath,
+        [AllowNull()] [string]$ResolvedBaselineSkillPath,
+        [Parameter(Mandatory = $true)] [string]$ModelName,
+        [Parameter(Mandatory = $true)] [string]$Workspace,
+        [Parameter(Mandatory = $true)] [string]$DotnetShimRoot,
+        [Parameter(Mandatory = $true)] [string]$RealDotnet,
+        [Parameter(Mandatory = $true)] [string]$CopilotScriptPath,
+        [Parameter(Mandatory = $true)] [string]$SharedNugetPackages,
+        [Parameter(Mandatory = $true)] [bool]$UseOptimizedDotnetMode,
+        [AllowNull()] [string]$ExecutorCommandPath
+    )
+
+    $runRoot = $RunPlan.runRoot
+    $repoRoot = Join-Path $runRoot 'repo'
+    $outputsPath = Join-Path $runRoot 'outputs'
+    New-Item -ItemType Directory -Path $repoRoot,$outputsPath,(Join-Path $runRoot '.benchmark') -Force | Out-Null
+
+    $excludedPrefixes = @('.agents/', '.claude/', '.benchmark/', '.git/', 'bin/', 'obj/')
+
+    if (Test-Path -LiteralPath $RunPlan.fixtureRoot -PathType Container) {
+        foreach ($item in Get-ChildItem -LiteralPath $RunPlan.fixtureRoot -Force) {
+            Copy-Item -LiteralPath $item.FullName -Destination $repoRoot -Recurse -Force
+        }
+    }
+
+    if ($RunPlan.configuration -eq 'with_skill') {
+        foreach ($skillDirectory in @('.agents\skills', '.claude\skills')) {
+            $destination = Join-Path $repoRoot (Join-Path $skillDirectory $SkillName)
+            New-Item -ItemType Directory -Path (Split-Path -Path $destination -Parent) -Force | Out-Null
+            Copy-Item -LiteralPath $ResolvedSkillPath -Destination $destination -Recurse -Force
+        }
+    } elseif (-not [string]::IsNullOrWhiteSpace($ResolvedBaselineSkillPath)) {
+        foreach ($skillDirectory in @('.agents\skills', '.claude\skills')) {
+            $destination = Join-Path $repoRoot (Join-Path $skillDirectory $SkillName)
+            New-Item -ItemType Directory -Path (Split-Path -Path $destination -Parent) -Force | Out-Null
+            Copy-Item -LiteralPath $ResolvedBaselineSkillPath -Destination $destination -Recurse -Force
+        }
+    }
+
+    $initialManifest = New-RunManifest -RepoRoot $repoRoot -ExcludedPrefixes $excludedPrefixes
+    Write-JsonFile -Path (Join-Path $runRoot '.benchmark\initial-manifest.json') -Value $initialManifest
+
+    $context = [ordered]@{
+        eval_id = [int]$RunPlan.eval.id
+        eval_name = $RunPlan.evalName
+        prompt = [string]$RunPlan.eval.prompt
+        expected_output = [string]$RunPlan.eval.expected_output
+        expectations = @($RunPlan.eval.expectations)
+        configuration = $RunPlan.configuration
+        run_root = $runRoot
+        repo_root = $repoRoot
+        outputs_root = $outputsPath
+        skill_name = $SkillName
+        model = $ModelName
+        profile = $RunPlan.profileName
+    }
+    $contextPath = Join-Path $runRoot '.benchmark\run-context.json'
+    Write-JsonFile -Path $contextPath -Value $context
+
+    $prompt = if ($RunPlan.configuration -eq 'with_skill') {
+@"
+Use the skill tool to invoke only the "$SkillName" skill before you begin. Do not invoke any other skill.
+Work only in the current directory.
+Do not ask the user questions; make reasonable assumptions and finish the task.
+The current directory already contains any attached fixture files for this eval.
+
+Task:
+$($RunPlan.eval.prompt)
+"@
+    } else {
+@"
+This is the baseline run without the skill tool.
+Work only in the current directory.
+Do not ask the user questions; make reasonable assumptions and finish the task.
+The current directory already contains any attached fixture files for this eval.
+
+Task:
+$($RunPlan.eval.prompt)
+"@
+    }
+
+    $transcriptPath = Join-Path $runRoot 'transcript.md'
+    $resultSummaryPath = Join-Path $runRoot 'result-summary.md'
+    $stdoutPath = Join-Path $runRoot '.benchmark\executor.stdout.jsonl'
+    $stderrPath = Join-Path $runRoot '.benchmark\executor.stderr.log'
+    $sharePath = $transcriptPath
+
+    $environment = @{
+        PYTHONUTF8 = '1'
+        SKILL_BENCHMARK_DOTNET_REAL = $RealDotnet
+        SKILL_BENCHMARK_DOTNET_LOG_DIR = (Join-Path $runRoot '.benchmark\dotnet')
+        SKILL_BENCHMARK_DOTNET_STDOUT = (Join-Path $runRoot '.benchmark\dotnet.stdout.log')
+        SKILL_BENCHMARK_DOTNET_STDERR = (Join-Path $runRoot '.benchmark\dotnet.stderr.log')
+        PATH = $DotnetShimRoot + [System.IO.Path]::PathSeparator + $env:PATH
+        NUGET_PACKAGES = $SharedNugetPackages
+        NUGET_HTTP_CACHE_PATH = (Join-Path $Workspace '.nuget\http-cache')
+    }
+
+    if ($UseOptimizedDotnetMode) {
+        $environment['DOTNET_TEST_RESOLVER_CACHE_DIR'] = (Join-Path $Workspace '.benchmark\resolver-cache')
+        $environment['DOTNET_TEST_RESOLVER_TRACE_FILE'] = (Join-Path $runRoot '.benchmark\resolver-trace.jsonl')
+        $environment['DOTNET_TEST_MAXIMUM_CANDIDATES'] = [string]$BenchmarkCandidateLimit
+    }
+
+    $handle = if ([string]::IsNullOrWhiteSpace($ExecutorCommandPath)) {
+        $wrapperPath = Join-Path $runRoot '.benchmark\invoke-copilot-executor.ps1'
+        New-CopilotWrapperScript -WrapperPath $wrapperPath -CopilotPowerShellPath $CopilotScriptPath -Prompt $prompt -WorkingDirectory $repoRoot -ModelName $ModelName -SharePath $sharePath -DisableSkillTool:($RunPlan.configuration -ne 'with_skill')
+        Start-CommandProcess -FilePath 'pwsh' -Arguments @('-NoProfile', '-File', $wrapperPath) -WorkingDirectory $repoRoot -Environment $environment -StdoutPath $stdoutPath -StderrPath $stderrPath
+    } else {
+        Start-CommandProcess -FilePath 'pwsh' -Arguments @('-NoProfile', '-File', $ExecutorCommandPath, '-ContextPath', $contextPath, '-RunRoot', $runRoot, '-TranscriptPath', $transcriptPath, '-ResultSummaryPath', $resultSummaryPath, '-OutputsPath', $outputsPath) -WorkingDirectory $repoRoot -Environment $environment -StdoutPath $stdoutPath -StderrPath $stderrPath
+    }
+
+    return [pscustomobject]@{
+        plan = $RunPlan
+        handle = $handle
+        context = $context
+        contextPath = $contextPath
+        repoRoot = $repoRoot
+        outputsPath = $outputsPath
+        transcriptPath = $transcriptPath
+        resultSummaryPath = $resultSummaryPath
+        stdoutPath = $stdoutPath
+        stderrPath = $stderrPath
+        startedAt = [DateTimeOffset]::UtcNow
+        initialManifest = $initialManifest
+        useOptimizedDotnetMode = $UseOptimizedDotnetMode
+    }
+}
+
+function Finalize-BenchmarkRun {
+    param(
+        [Parameter(Mandatory = $true)] $ActiveRun,
+        [Parameter(Mandatory = $true)] [bool]$TimedOut
+    )
+
+    $runRoot = $ActiveRun.plan.runRoot
+    $cleanup = if ($TimedOut) { Kill-ProcessTree -Process $ActiveRun.handle.Process } else { [ordered]@{ attempted = $false; completed = $true; exitCode = $ActiveRun.handle.Process.ExitCode } }
+    $ActiveRun.handle.Process.WaitForExit()
+
+    $stderrText = if (Test-Path -LiteralPath $ActiveRun.stderrPath) { (Get-Content -LiteralPath $ActiveRun.stderrPath -Raw) } else { '' }
+    $stdoutEvents = @(Parse-CopilotEvents -JsonlPath $ActiveRun.stdoutPath)
+    Write-TranscriptFallback -TranscriptPath $ActiveRun.transcriptPath -Prompt $ActiveRun.context.prompt -Events $stdoutEvents -StderrText $stderrText
+
+    $assistantResponsePath = Join-Path $ActiveRun.outputsPath 'assistant-response.md'
+    $finalMessage = Get-CopilotFinalMessage -Events $stdoutEvents
+    if ([string]::IsNullOrWhiteSpace($finalMessage) -and (Test-Path -LiteralPath $assistantResponsePath -PathType Leaf)) {
+        $finalMessage = (Get-Content -LiteralPath $assistantResponsePath -Raw)
+    }
+    if ([string]::IsNullOrWhiteSpace($finalMessage)) {
+        $finalMessage = if ($TimedOut) { 'The executor timed out before producing a final assistant message.' } elseif ($ActiveRun.handle.Process.ExitCode -ne 0) { "The executor exited with code $($ActiveRun.handle.Process.ExitCode)." } else { 'The executor completed without a final assistant message.' }
+    }
+    Write-TextFile -Path $assistantResponsePath -Content ($finalMessage.Trim() + [Environment]::NewLine)
+    if (-not (Test-Path -LiteralPath $ActiveRun.resultSummaryPath)) {
+        Write-TextFile -Path $ActiveRun.resultSummaryPath -Content ($finalMessage.Trim() + [Environment]::NewLine)
+    }
+
+    $changes = @(Get-ChangedFiles -BaselineRoot $ActiveRun.plan.fixtureRoot -CurrentRoot $ActiveRun.repoRoot -BaselineManifest $ActiveRun.initialManifest -ExcludedPrefixes @('.agents/', '.claude/', '.benchmark/', '.git/', 'bin/', 'obj/'))
+    Write-ChangeArtifacts -Changes $changes -OutputsPath $ActiveRun.outputsPath
+
+    $dotnetSummary = Summarize-DotnetLogs -LogDirectory (Join-Path $runRoot '.benchmark\dotnet') -OutputsPath $ActiveRun.outputsPath
+    $commandDurations = Get-PowerShellCommandDurations -Events $stdoutEvents
+    $resolverSummary = Summarize-ResolverTrace -TracePath (Join-Path $runRoot '.benchmark\resolver-trace.jsonl') -OutputsPath $ActiveRun.outputsPath
+    $metrics = Get-CopilotMetrics -Events $stdoutEvents -TranscriptPath $ActiveRun.transcriptPath -OutputsPath $ActiveRun.outputsPath
+    Write-JsonFile -Path (Join-Path $ActiveRun.outputsPath 'metrics.json') -Value $metrics
+
+    $resultEvent = Get-UsageResult -Events $stdoutEvents
+    $endedAt = [DateTimeOffset]::UtcNow
+    $cleanupError = if ($cleanup.PSObject.Properties.Name -contains 'error') { $cleanup.error } else { $null }
+    $timing = [ordered]@{
+        executor = [ordered]@{
+            startedAt = $ActiveRun.startedAt.ToString('O')
+            endedAt = $endedAt.ToString('O')
+            durationSeconds = [math]::Round(($endedAt - $ActiveRun.startedAt).TotalSeconds, 3)
+            exitCode = if ($ActiveRun.handle.Process.HasExited) { $ActiveRun.handle.Process.ExitCode } else { $null }
+            result = $resultEvent
+        }
+        cleanup = [ordered]@{
+            timedOut = $TimedOut
+            attempted = [bool]$cleanup.attempted
+            completed = [bool]$cleanup.completed
+            exitCode = $cleanup.exitCode
+            error = $cleanupError
+        }
+        dotnet = $dotnetSummary.totals
+        resolver = [ordered]@{
+            durationSeconds = if ([double]$commandDurations.resolverSeconds -gt 0) { $commandDurations.resolverSeconds } else { $resolverSummary.durationSeconds }
+            cacheHits = $resolverSummary.cacheHits
+            calls = $resolverSummary.calls
+        }
+        commandDurations = $commandDurations
+        total_duration_seconds = [math]::Round(($endedAt - $ActiveRun.startedAt).TotalSeconds, 3)
+        duration_ms = [int][math]::Round(($endedAt - $ActiveRun.startedAt).TotalMilliseconds)
+        total_tokens = 0
+    }
+    if ([double]$commandDurations.restoreSeconds -gt 0) { $timing.dotnet.restoreSeconds = $commandDurations.restoreSeconds }
+    if ([double]$commandDurations.buildSeconds -gt 0) { $timing.dotnet.buildSeconds = $commandDurations.buildSeconds }
+    if ([double]$commandDurations.testSeconds -gt 0) { $timing.dotnet.testSeconds = $commandDurations.testSeconds }
+    Write-JsonFile -Path (Join-Path $runRoot 'timing.json') -Value $timing
+
+    return [pscustomobject]@{
+        runRoot = $runRoot
+        transcriptPath = $ActiveRun.transcriptPath
+        outputsPath = $ActiveRun.outputsPath
+        timing = $timing
+        metrics = $metrics
+        context = $ActiveRun.context
+        stdoutPath = $ActiveRun.stdoutPath
+        completedAt = $endedAt
+    }
+}
+
+function Invoke-Profile {
+    param(
+        [Parameter(Mandatory = $true)] [string]$ProfileName,
+        [Parameter(Mandatory = $true)] [string]$ResolvedSkillPath,
+        [AllowNull()] [string]$ResolvedBaselineSkillPath,
+        [Parameter(Mandatory = $true)] [object[]]$Evals,
+        [Parameter(Mandatory = $true)] [object]$SkillMetadata,
+        [Parameter(Mandatory = $true)] [string]$Workspace,
+        [Parameter(Mandatory = $true)] [int]$ExecutorParallelism,
+        [Parameter(Mandatory = $true)] [int]$GraderParallelism,
+        [Parameter(Mandatory = $true)] [bool]$EnableOptimizations,
+        [Parameter(Mandatory = $true)] [string]$CopilotScriptPath,
+        [AllowNull()] [string]$ExecutorCommandPath,
+        [AllowNull()] [string]$GraderCommandPath,
+        [Parameter(Mandatory = $true)] [string]$SkillCreatorRootPath,
+        [Parameter(Mandatory = $true)] $ValidationResult,
+        [Parameter(Mandatory = $true)] $PrewarmResult,
+        [Parameter(Mandatory = $true)] [int]$RunTimeout,
+        [Parameter(Mandatory = $true)] [int]$GradeTimeout,
+        [Parameter(Mandatory = $true)] [string]$ModelName,
+        [Parameter(Mandatory = $true)] [string]$GraderModelName
+    )
+
+    $iterationRoot = Join-Path $Workspace ('iteration-' + $ProfileName)
+    if (Test-Path -LiteralPath $iterationRoot) {
+        Remove-Item -LiteralPath $iterationRoot -Recurse -Force
+    }
+    New-Item -ItemType Directory -Path $iterationRoot -Force | Out-Null
+
+    $profileStart = [DateTimeOffset]::UtcNow
+
+    $realDotnet = Get-RealDotnetPath
+    $sharedNugetPackages = Join-Path $Workspace '.nuget\packages'
+    New-Item -ItemType Directory -Path $sharedNugetPackages,(Join-Path $Workspace '.nuget\http-cache') -Force | Out-Null
+    $dotnetShimRoot = Initialize-DotnetShim -Workspace $Workspace -RealDotnet $realDotnet
+
+    $plans = [System.Collections.Generic.Queue[object]]::new()
+    foreach ($eval in @($Evals)) {
+        $evalSlug = Get-Slug -Text ([string]$eval.prompt)
+        if ($evalSlug.Length -gt 24) {
+            $evalSlug = $evalSlug.Substring(0, 24).Trim('-')
+        }
+        if ([string]::IsNullOrWhiteSpace($evalSlug)) {
+            $evalSlug = 'eval'
+        }
+        $evalName = 'eval-{0:D2}-{1}' -f [int]$eval.id, $evalSlug
+        $evalRoot = Join-Path $iterationRoot $evalName
+        $fixtureRoot = Join-Path $evalRoot 'fixtures'
+        New-Item -ItemType Directory -Path $fixtureRoot -Force | Out-Null
+        foreach ($file in @($eval.files)) {
+            $source = Join-Path $ResolvedSkillPath ($file -replace '/', '\')
+            $relative = ($file -replace '^evals/files/[^/]+/', '') -replace '/', '\'
+            $destination = Join-Path $fixtureRoot $relative
+            New-Item -ItemType Directory -Path (Split-Path -Path $destination -Parent) -Force | Out-Null
+            Copy-Item -LiteralPath $source -Destination $destination -Force
+        }
+
+        Write-JsonFile -Path (Join-Path $evalRoot 'eval_metadata.json') -Value ([ordered]@{
+            eval_id = [int]$eval.id
+            eval_name = $evalName
+            prompt = [string]$eval.prompt
+            assertions = @($eval.expectations)
+        })
+
+        foreach ($configuration in @('with_skill', 'without_skill')) {
+            $runRoot = Join-Path $evalRoot (Join-Path $configuration 'run-1')
+            New-Item -ItemType Directory -Path $runRoot -Force | Out-Null
+            $plans.Enqueue([pscustomobject]@{
+                eval = $eval
+                evalName = $evalName
+                evalRoot = $evalRoot
+                fixtureRoot = $fixtureRoot
+                configuration = $configuration
+                runRoot = $runRoot
+                profileName = $ProfileName
+            })
+        }
+    }
+
+    $activeRuns = [System.Collections.Generic.List[object]]::new()
+    $completedRuns = [System.Collections.Generic.List[object]]::new()
+    $maxConcurrentExecutors = 0
+
+    while ($plans.Count -gt 0 -or $activeRuns.Count -gt 0) {
+        while ($plans.Count -gt 0 -and $activeRuns.Count -lt $ExecutorParallelism) {
+            $plan = $plans.Dequeue()
+            $activeRuns.Add((Start-BenchmarkRun -RunPlan $plan -SkillName $SkillMetadata.Name -ResolvedSkillPath $ResolvedSkillPath -ResolvedBaselineSkillPath $ResolvedBaselineSkillPath -ModelName $ModelName -Workspace $Workspace -DotnetShimRoot $dotnetShimRoot -RealDotnet $realDotnet -CopilotScriptPath $CopilotScriptPath -SharedNugetPackages $sharedNugetPackages -UseOptimizedDotnetMode $EnableOptimizations -ExecutorCommandPath $ExecutorCommandPath))
+            if ($activeRuns.Count -gt $maxConcurrentExecutors) { $maxConcurrentExecutors = $activeRuns.Count }
+        }
+
+        foreach ($activeRun in @($activeRuns)) {
+            $elapsed = ([DateTimeOffset]::UtcNow - $activeRun.startedAt).TotalSeconds
+            if ($activeRun.handle.Process.HasExited) {
+                $activeRuns.Remove($activeRun) | Out-Null
+                $completedRuns.Add((Finalize-BenchmarkRun -ActiveRun $activeRun -TimedOut $false))
+                continue
+            }
+            if ($elapsed -ge $RunTimeout) {
+                $activeRuns.Remove($activeRun) | Out-Null
+                $completedRuns.Add((Finalize-BenchmarkRun -ActiveRun $activeRun -TimedOut $true))
+            }
+        }
+        if ($activeRuns.Count -gt 0) {
+            Start-Sleep -Milliseconds 250
+        }
+    }
+
+    $gradeQueue = [System.Collections.Generic.Queue[object]]::new()
+    foreach ($run in @($completedRuns)) { $gradeQueue.Enqueue($run) }
+    $activeGraders = [System.Collections.Generic.List[object]]::new()
+    $maxConcurrentGraders = 0
+
+    while ($gradeQueue.Count -gt 0 -or $activeGraders.Count -gt 0) {
+        while ($gradeQueue.Count -gt 0 -and $activeGraders.Count -lt $GraderParallelism) {
+            $run = $gradeQueue.Dequeue()
+            $gradingPath = Join-Path $run.runRoot 'grading.json'
+            $stdoutPath = Join-Path $run.runRoot '.benchmark\grader.stdout.jsonl'
+            $stderrPath = Join-Path $run.runRoot '.benchmark\grader.stderr.log'
+            if ([string]::IsNullOrWhiteSpace($GraderCommandPath)) {
+                $prompt = Get-CopilotGraderPrompt -SkillCreatorRoot $SkillCreatorRootPath -TranscriptPath $run.transcriptPath -OutputsPath $run.outputsPath -TimingPath (Join-Path $run.runRoot 'timing.json') -Expectations @($run.context.expectations)
+                $wrapperPath = Join-Path $run.runRoot '.benchmark\invoke-copilot-grader.ps1'
+                New-CopilotWrapperScript -WrapperPath $wrapperPath -CopilotPowerShellPath $CopilotScriptPath -Prompt $prompt -WorkingDirectory $run.runRoot -ModelName $GraderModelName -DisableSkillTool
+                $handle = Start-CommandProcess -FilePath 'pwsh' -Arguments @('-NoProfile', '-File', $wrapperPath) -WorkingDirectory $run.runRoot -Environment @{} -StdoutPath $stdoutPath -StderrPath $stderrPath
+                $activeGraders.Add([pscustomobject]@{
+                    run = $run
+                    gradingPath = $gradingPath
+                    startedAt = [DateTimeOffset]::UtcNow
+                    kind = 'copilot'
+                    handle = $handle
+                    stdoutPath = $stdoutPath
+                    stderrPath = $stderrPath
+                })
+            } else {
+                $handle = Start-CommandProcess -FilePath 'pwsh' -Arguments @('-NoProfile', '-File', $GraderCommandPath, '-ContextPath', (Join-Path $run.runRoot '.benchmark\run-context.json'), '-TranscriptPath', $run.transcriptPath, '-OutputsPath', $run.outputsPath, '-TimingPath', (Join-Path $run.runRoot 'timing.json'), '-GradingPath', $gradingPath) -WorkingDirectory $run.runRoot -Environment @{} -StdoutPath $stdoutPath -StderrPath $stderrPath
+                $activeGraders.Add([pscustomobject]@{
+                    run = $run
+                    gradingPath = $gradingPath
+                    startedAt = [DateTimeOffset]::UtcNow
+                    kind = 'command'
+                    handle = $handle
+                    stdoutPath = $stdoutPath
+                    stderrPath = $stderrPath
+                })
+            }
+            if ($activeGraders.Count -gt $maxConcurrentGraders) { $maxConcurrentGraders = $activeGraders.Count }
+        }
+
+        foreach ($grader in @($activeGraders)) {
+            $elapsed = ([DateTimeOffset]::UtcNow - $grader.startedAt).TotalSeconds
+            if ($grader.handle.Process.HasExited) {
+                $grader.handle.Process.WaitForExit()
+                if ($grader.kind -eq 'copilot') {
+                    try {
+                        if ($grader.handle.Process.ExitCode -ne 0) {
+                            throw "Copilot grader exited with code $($grader.handle.Process.ExitCode)."
+                        }
+                        $events = Parse-CopilotEvents -JsonlPath $grader.stdoutPath
+                        $jsonText = Get-CopilotFinalMessage -Events $events
+                        if ([string]::IsNullOrWhiteSpace($jsonText)) {
+                            throw 'Copilot grader did not return a JSON message.'
+                        }
+                        $parsed = $jsonText | ConvertFrom-Json
+                        Write-JsonFile -Path $grader.gradingPath -Value $parsed
+                    } catch {
+                        Write-FallbackGrading -GradingPath $grader.gradingPath -Expectations @($grader.run.context.expectations) -Reason $_.Exception.Message -Timing $grader.run.timing -Metrics $grader.run.metrics
+                    }
+                } elseif ($grader.handle.Process.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $grader.gradingPath -PathType Leaf)) {
+                    Write-FallbackGrading -GradingPath $grader.gradingPath -Expectations @($grader.run.context.expectations) -Reason "Custom grader exited with code $($grader.handle.Process.ExitCode)." -Timing $grader.run.timing -Metrics $grader.run.metrics
+                }
+                Update-TimingWithGrader -TimingPath (Join-Path $grader.run.runRoot 'timing.json') -GraderStartedAt $grader.startedAt -GraderEndedAt ([DateTimeOffset]::UtcNow)
+                $grader.run.timing = Get-Content -LiteralPath (Join-Path $grader.run.runRoot 'timing.json') -Raw | ConvertFrom-Json
+                $activeGraders.Remove($grader) | Out-Null
+                continue
+            }
+            if ($elapsed -ge $GradeTimeout) {
+                [void](Kill-ProcessTree -Process $grader.handle.Process)
+                Write-FallbackGrading -GradingPath $grader.gradingPath -Expectations @($grader.run.context.expectations) -Reason 'The grader timed out.' -Timing $grader.run.timing -Metrics $grader.run.metrics
+                Update-TimingWithGrader -TimingPath (Join-Path $grader.run.runRoot 'timing.json') -GraderStartedAt $grader.startedAt -GraderEndedAt ([DateTimeOffset]::UtcNow)
+                $grader.run.timing = Get-Content -LiteralPath (Join-Path $grader.run.runRoot 'timing.json') -Raw | ConvertFrom-Json
+                $activeGraders.Remove($grader) | Out-Null
+            }
+        }
+        if ($activeGraders.Count -gt 0) {
+            Start-Sleep -Milliseconds 200
+        }
+    }
+
+    $aggregateScript = Join-Path $SkillCreatorRootPath 'scripts\aggregate_benchmark.py'
+    $viewerScript = Join-Path $SkillCreatorRootPath 'eval-viewer\generate_review.py'
+    $previousPythonUtf8 = $env:PYTHONUTF8
+    $env:PYTHONUTF8 = '1'
+    $benchmarkOutput = & python $aggregateScript $iterationRoot --skill-name $SkillMetadata.Name --skill-path $ResolvedSkillPath 2>&1
+    if ($LASTEXITCODE -ne 0) {
+        if ($null -eq $previousPythonUtf8) { Remove-Item Env:PYTHONUTF8 -ErrorAction SilentlyContinue } else { $env:PYTHONUTF8 = $previousPythonUtf8 }
+        throw "Benchmark aggregation failed for profile '$ProfileName'.`n$($benchmarkOutput -join [Environment]::NewLine)"
+    }
+    Update-BenchmarkMetadata -BenchmarkPath (Join-Path $iterationRoot 'benchmark.json') -ExecutorModel $ModelName -AnalyzerModel $GraderModelName -RunsPerConfiguration 1
+
+    $reviewPath = Join-Path $Workspace ('review-' + $ProfileName + '.html')
+    if (-not $SkipReview) {
+        $viewerOutput = & python $viewerScript $iterationRoot --skill-name $SkillMetadata.Name --benchmark (Join-Path $iterationRoot 'benchmark.json') --static $reviewPath 2>&1
+        if ($null -eq $previousPythonUtf8) { Remove-Item Env:PYTHONUTF8 -ErrorAction SilentlyContinue } else { $env:PYTHONUTF8 = $previousPythonUtf8 }
+        if ($LASTEXITCODE -ne 0) {
+            throw "Static review generation failed for profile '$ProfileName'.`n$($viewerOutput -join [Environment]::NewLine)"
+        }
+    } else {
+        if ($null -eq $previousPythonUtf8) { Remove-Item Env:PYTHONUTF8 -ErrorAction SilentlyContinue } else { $env:PYTHONUTF8 = $previousPythonUtf8 }
+    }
+
+    $profileEnd = [DateTimeOffset]::UtcNow
+    $summary = [ordered]@{
+        profile = $ProfileName
+        skill = $SkillMetadata.Name
+        iterationRoot = $iterationRoot
+        startedAt = $profileStart.ToString('O')
+        endedAt = $profileEnd.ToString('O')
+        totalWallClockSeconds = [math]::Round(($profileEnd - $profileStart).TotalSeconds, 3)
+        validation = $ValidationResult
+        prewarm = $PrewarmResult
+        maxConcurrentExecutorsObserved = $maxConcurrentExecutors
+        maxConcurrentGradersObserved = $maxConcurrentGraders
+        runCount = @($completedRuns).Count
+        timedOutRuns = @($completedRuns | Where-Object { $_.timing.cleanup.timedOut }).Count
+        cleanupFailures = @($completedRuns | Where-Object { -not $_.timing.cleanup.completed }).Count
+        totalResolverSeconds = [math]::Round((@($completedRuns | ForEach-Object { [double]$_.timing.resolver.durationSeconds } | Measure-Object -Sum).Sum), 3)
+        totalRestoreSeconds = [math]::Round((@($completedRuns | ForEach-Object { [double]$_.timing.dotnet.restoreSeconds } | Measure-Object -Sum).Sum), 3)
+        totalBuildSeconds = [math]::Round((@($completedRuns | ForEach-Object { [double]$_.timing.dotnet.buildSeconds } | Measure-Object -Sum).Sum), 3)
+        totalTestSeconds = [math]::Round((@($completedRuns | ForEach-Object { [double]$_.timing.dotnet.testSeconds } | Measure-Object -Sum).Sum), 3)
+        runs = @($completedRuns | ForEach-Object {
+            [ordered]@{
+                evalId = $_.context.eval_id
+                configuration = $_.context.configuration
+                runRoot = $_.runRoot
+                totalDurationSeconds = $_.timing.total_duration_seconds
+                timedOut = $_.timing.cleanup.timedOut
+                cleanupCompleted = $_.timing.cleanup.completed
+                exitCode = $_.timing.executor.exitCode
+                resolverSeconds = $_.timing.resolver.durationSeconds
+                restoreSeconds = $_.timing.dotnet.restoreSeconds
+                buildSeconds = $_.timing.dotnet.buildSeconds
+                testSeconds = $_.timing.dotnet.testSeconds
+            }
+        })
+        benchmarkPath = (Join-Path $iterationRoot 'benchmark.json')
+        reviewPath = $reviewPath
+    }
+    Write-JsonFile -Path (Join-Path $iterationRoot 'runner-summary.json') -Value $summary
+
+    return $summary
+}
+
+function Write-ComparisonArtifacts {
+    param(
+        [Parameter(Mandatory = $true)] [string]$Workspace,
+        [Parameter(Mandatory = $true)] $ValidationResult,
+        [Parameter(Mandatory = $true)] $OptimizedPrewarmResult,
+        [Parameter(Mandatory = $true)] $Legacy,
+        [Parameter(Mandatory = $true)] $Optimized
+    )
+
+    $legacyWorkflowSeconds = [math]::Round([double]$ValidationResult.durationSeconds + [double]$Legacy.totalWallClockSeconds, 3)
+    $optimizedWorkflowSeconds = [math]::Round([double]$ValidationResult.durationSeconds + [double]$OptimizedPrewarmResult.durationSeconds + [double]$Optimized.totalWallClockSeconds, 3)
+    $saved = [math]::Round($legacyWorkflowSeconds - $optimizedWorkflowSeconds, 3)
+    $percent = if ($legacyWorkflowSeconds -gt 0) {
+        [math]::Round(($saved / $legacyWorkflowSeconds) * 100, 2)
+    } else {
+        0
+    }
+
+    $comparison = [ordered]@{
+        skill = $Legacy.skill
+        workspace = $Workspace
+        sharedValidation = $ValidationResult
+        optimizedPrewarm = $OptimizedPrewarmResult
+        legacy = $Legacy
+        optimized = $Optimized
+        delta = [ordered]@{
+            legacyWorkflowSeconds = $legacyWorkflowSeconds
+            optimizedWorkflowSeconds = $optimizedWorkflowSeconds
+            wallClockSecondsSaved = $saved
+            percentFaster = $percent
+            resolverSecondsSaved = [math]::Round([double]$Legacy.totalResolverSeconds - [double]$Optimized.totalResolverSeconds, 3)
+            restoreSecondsSaved = [math]::Round([double]$Legacy.totalRestoreSeconds - [double]$Optimized.totalRestoreSeconds, 3)
+            buildSecondsSaved = [math]::Round([double]$Legacy.totalBuildSeconds - [double]$Optimized.totalBuildSeconds, 3)
+            testSecondsSaved = [math]::Round([double]$Legacy.totalTestSeconds - [double]$Optimized.totalTestSeconds, 3)
+        }
+    }
+    Write-JsonFile -Path (Join-Path $Workspace 'comparison.json') -Value $comparison
+
+    $lines = @(
+        '# Skill Benchmark Comparison',
+        '',
+        '| Metric | Legacy | Optimized | Delta |',
+        '|--------|--------|-----------|-------|',
+        ('| Validation (shared) | {0}s | {0}s | {1:+0.###;-0.###;0}s |' -f $ValidationResult.durationSeconds, 0),
+        ('| Resolver prewarm | 0s | {0}s | {1:+0.###;-0.###;0}s |' -f $OptimizedPrewarmResult.durationSeconds, (-1 * [double]$OptimizedPrewarmResult.durationSeconds)),
+        ('| Workflow total | {0}s | {1}s | {2:+0.###;-0.###;0}s |' -f $legacyWorkflowSeconds, $optimizedWorkflowSeconds, $saved),
+        ('| Resolver time | {0}s | {1}s | {2:+0.###;-0.###;0}s |' -f $Legacy.totalResolverSeconds, $Optimized.totalResolverSeconds, $comparison.delta.resolverSecondsSaved),
+        ('| Restore time | {0}s | {1}s | {2:+0.###;-0.###;0}s |' -f $Legacy.totalRestoreSeconds, $Optimized.totalRestoreSeconds, $comparison.delta.restoreSecondsSaved),
+        ('| Build time | {0}s | {1}s | {2:+0.###;-0.###;0}s |' -f $Legacy.totalBuildSeconds, $Optimized.totalBuildSeconds, $comparison.delta.buildSecondsSaved),
+        ('| Test time | {0}s | {1}s | {2:+0.###;-0.###;0}s |' -f $Legacy.totalTestSeconds, $Optimized.totalTestSeconds, $comparison.delta.testSecondsSaved),
+        ('| Timed out runs | {0} | {1} | {2:+0;-0;0} |' -f $Legacy.timedOutRuns, $Optimized.timedOutRuns, ($Legacy.timedOutRuns - $Optimized.timedOutRuns)),
+        ('| Cleanup failures | {0} | {1} | {2:+0;-0;0} |' -f $Legacy.cleanupFailures, $Optimized.cleanupFailures, ($Legacy.cleanupFailures - $Optimized.cleanupFailures))
+    )
+    Write-TextFile -Path (Join-Path $Workspace 'comparison.md') -Content ($lines -join [Environment]::NewLine)
+}
+
+function Update-TimingWithGrader {
+    param(
+        [Parameter(Mandatory = $true)] [string]$TimingPath,
+        [Parameter(Mandatory = $true)] [DateTimeOffset]$GraderStartedAt,
+        [Parameter(Mandatory = $true)] [DateTimeOffset]$GraderEndedAt
+    )
+
+    if (-not (Test-Path -LiteralPath $TimingPath -PathType Leaf)) { return }
+
+    $timing = Get-Content -LiteralPath $TimingPath -Raw | ConvertFrom-Json
+    $graderDuration = [math]::Round(($GraderEndedAt - $GraderStartedAt).TotalSeconds, 3)
+
+    $timing | Add-Member -NotePropertyName grader -NotePropertyValue ([pscustomobject]@{}) -Force
+    $timing.grader | Add-Member -NotePropertyName startedAt -NotePropertyValue $GraderStartedAt.ToString('O') -Force
+    $timing.grader | Add-Member -NotePropertyName endedAt -NotePropertyValue $GraderEndedAt.ToString('O') -Force
+    $timing.grader | Add-Member -NotePropertyName durationSeconds -NotePropertyValue $graderDuration -Force
+
+    $totalDuration = [math]::Round([double]$timing.executor.durationSeconds + $graderDuration, 3)
+    $timing.total_duration_seconds = $totalDuration
+    $timing.duration_ms = [long][math]::Round($totalDuration * 1000)
+
+    Write-JsonFile -Path $TimingPath -Value $timing
+}
+
+function Update-BenchmarkMetadata {
+    param(
+        [Parameter(Mandatory = $true)] [string]$BenchmarkPath,
+        [Parameter(Mandatory = $true)] [string]$ExecutorModel,
+        [Parameter(Mandatory = $true)] [string]$AnalyzerModel,
+        [Parameter(Mandatory = $true)] [int]$RunsPerConfiguration
+    )
+
+    if (-not (Test-Path -LiteralPath $BenchmarkPath -PathType Leaf)) { return }
+
+    $benchmark = Get-Content -LiteralPath $BenchmarkPath -Raw | ConvertFrom-Json
+    if ($null -eq $benchmark.metadata) {
+        $benchmark | Add-Member -NotePropertyName metadata -NotePropertyValue ([pscustomobject]@{}) -Force
+    }
+    $benchmark.metadata | Add-Member -NotePropertyName executor_model -NotePropertyValue $ExecutorModel -Force
+    $benchmark.metadata | Add-Member -NotePropertyName analyzer_model -NotePropertyValue $AnalyzerModel -Force
+    $benchmark.metadata | Add-Member -NotePropertyName runs_per_configuration -NotePropertyValue $RunsPerConfiguration -Force
+    Write-JsonFile -Path $BenchmarkPath -Value $benchmark
+}
+
+$repoRoot = Get-RepoRoot
+$resolvedSkillPath = Get-ResolvedPath -Path $SkillPath
+$baselineSkillPath = if ([string]::IsNullOrWhiteSpace($BaselineSkillPath)) { $null } else { Get-ResolvedPath -Path $BaselineSkillPath }
+$skillMetadata = Get-SkillMetadata -ResolvedSkillPath $resolvedSkillPath
+$skillCreatorRoot = Resolve-SkillCreatorRoot
+$evals = Get-EvalDefinitions -ResolvedSkillPath $resolvedSkillPath -SelectedEvalId $EvalId
+if (@($evals).Count -eq 0) {
+    throw 'No evals matched the selected criteria.'
+}
+
+if ([string]::IsNullOrWhiteSpace($WorkspaceRoot)) {
+    $WorkspaceRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('sb-' + $skillMetadata.Name + '-' + [DateTimeOffset]::UtcNow.ToString('yyyyMMdd-HHmmss'))
+}
+$workspace = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($WorkspaceRoot)
+New-Item -ItemType Directory -Path $workspace -Force | Out-Null
+
+$copilotScriptPath = Get-CopilotScriptPath
+$executorPath = if ([string]::IsNullOrWhiteSpace($ExecutorCommand)) { $null } else { Get-ResolvedPath -Path $ExecutorCommand }
+$graderPath = if ([string]::IsNullOrWhiteSpace($GraderCommand)) { $null } else { Get-ResolvedPath -Path $GraderCommand }
+$shouldRunValidation = -not $SkipSkillValidation
+if ([string]::IsNullOrWhiteSpace($GraderModel)) {
+    $GraderModel = $Model
+}
+
+$validationResult = if ($shouldRunValidation) { Invoke-SkillValidation -ResolvedSkillPath $resolvedSkillPath -Workspace $workspace } else { [ordered]@{ ran = $false; exitCode = $null; durationSeconds = 0 } }
+if ($validationResult.ran -and $validationResult.exitCode -ne 0) {
+    throw 'Skill validation failed before benchmark execution.'
+}
+
+$optimizedPrewarm = [ordered]@{ ran = $false; durationSeconds = 0; contexts = @() }
+if ($skillMetadata.Name -eq 'dotnet-test') {
+    $contexts = Get-DotnetTestContexts -Evals $evals -ResolvedSkillPath $resolvedSkillPath
+    if (@($contexts).Count -gt 0) {
+        $optimizedPrewarm = Invoke-DotnetTestPrewarm -Contexts $contexts -ResolvedSkillPath $resolvedSkillPath -CacheDirectory (Join-Path $workspace '.benchmark\resolver-cache') -TracePath (Join-Path $workspace '.benchmark\resolver-prewarm.jsonl') -CandidateLimit $BenchmarkCandidateLimit
+    }
+}
+
+$optimized = Invoke-Profile -ProfileName 'optimized' -ResolvedSkillPath $resolvedSkillPath -ResolvedBaselineSkillPath $baselineSkillPath -Evals $evals -SkillMetadata $skillMetadata -Workspace $workspace -ExecutorParallelism $MaxParallel -GraderParallelism $MaxGradeParallel -EnableOptimizations $true -CopilotScriptPath $copilotScriptPath -ExecutorCommandPath $executorPath -GraderCommandPath $graderPath -SkillCreatorRootPath $skillCreatorRoot -ValidationResult $validationResult -PrewarmResult $optimizedPrewarm -RunTimeout $RunTimeoutSeconds -GradeTimeout $GradeTimeoutSeconds -ModelName $Model -GraderModelName $GraderModel
+
+if ($CompareWithLegacy) {
+    $legacy = Invoke-Profile -ProfileName 'legacy' -ResolvedSkillPath $resolvedSkillPath -ResolvedBaselineSkillPath $baselineSkillPath -Evals $evals -SkillMetadata $skillMetadata -Workspace $workspace -ExecutorParallelism 1 -GraderParallelism 1 -EnableOptimizations $false -CopilotScriptPath $copilotScriptPath -ExecutorCommandPath $executorPath -GraderCommandPath $graderPath -SkillCreatorRootPath $skillCreatorRoot -ValidationResult $validationResult -PrewarmResult ([ordered]@{ ran = $false; durationSeconds = 0; contexts = @() }) -RunTimeout $RunTimeoutSeconds -GradeTimeout $GradeTimeoutSeconds -ModelName $Model -GraderModelName $GraderModel
+    Write-ComparisonArtifacts -Workspace $workspace -ValidationResult $validationResult -OptimizedPrewarmResult $optimizedPrewarm -Legacy $legacy -Optimized $optimized
+}
+
+Write-TextFile -Path (Join-Path $workspace 'latest-profile.txt') -Content ('optimized' + [Environment]::NewLine)
+Write-Output ("Benchmark workspace: {0}" -f $workspace)
+Write-Output ("Optimized benchmark: {0}" -f $optimized.benchmarkPath)
+Write-Output ("Optimized review: {0}" -f $optimized.reviewPath)
+if ($CompareWithLegacy) {
+    Write-Output ("Legacy benchmark: {0}" -f $legacy.benchmarkPath)
+    Write-Output ("Legacy review: {0}" -f $legacy.reviewPath)
+    Write-Output ("Comparison summary: {0}" -f (Join-Path $workspace 'comparison.json'))
+}
diff --git a/scripts/skill-benchmark/log-dotnet.ps1 b/scripts/skill-benchmark/log-dotnet.ps1
new file mode 100644
index 0000000..88fd499
--- /dev/null
+++ b/scripts/skill-benchmark/log-dotnet.ps1
@@ -0,0 +1,99 @@
+param(
+    [Parameter(Mandatory = $true)]
+    [string]$RealDotnet,
+
+    [Parameter(Mandatory = $true)]
+    [string]$LogDirectory,
+
+    [Parameter(Mandatory = $true)]
+    [string]$StdoutPath,
+
+    [Parameter(Mandatory = $true)]
+    [string]$StderrPath,
+
+    [Parameter(ValueFromRemainingArguments = $true)]
+    [string[]]$Arguments
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+[Console]::InputEncoding = $utf8NoBom
+[Console]::OutputEncoding = $utf8NoBom
+$OutputEncoding = $utf8NoBom
+
+New-Item -ItemType Directory -Path $LogDirectory -Force | Out-Null
+
+$start = [DateTimeOffset]::UtcNow
+$stdoutLines = [System.Collections.Generic.List[string]]::new()
+$stderrLines = [System.Collections.Generic.List[string]]::new()
+
+$stdoutWriter = [System.IO.StreamWriter]::new($StdoutPath, $false, $utf8NoBom)
+$stderrWriter = [System.IO.StreamWriter]::new($StderrPath, $false, $utf8NoBom)
+
+try {
+    $psi = [System.Diagnostics.ProcessStartInfo]::new()
+    $psi.FileName = $RealDotnet
+    $psi.WorkingDirectory = (Get-Location).Path
+    $psi.UseShellExecute = $false
+    $psi.RedirectStandardOutput = $true
+    $psi.RedirectStandardError = $true
+    foreach ($argument in @($Arguments)) {
+        [void]$psi.ArgumentList.Add($argument)
+    }
+
+    $process = [System.Diagnostics.Process]::new()
+    $process.StartInfo = $psi
+    $process.EnableRaisingEvents = $true
+
+    $process.add_OutputDataReceived({
+        param($sender, $eventArgs)
+
+        if ($null -eq $eventArgs.Data) { return }
+        $stdoutLines.Add($eventArgs.Data)
+        $stdoutWriter.WriteLine($eventArgs.Data)
+        [Console]::Out.WriteLine($eventArgs.Data)
+    })
+
+    $process.add_ErrorDataReceived({
+        param($sender, $eventArgs)
+
+        if ($null -eq $eventArgs.Data) { return }
+        $stderrLines.Add($eventArgs.Data)
+        $stderrWriter.WriteLine($eventArgs.Data)
+        [Console]::Error.WriteLine($eventArgs.Data)
+    })
+
+    if (-not $process.Start()) {
+        throw "Unable to start '$RealDotnet'."
+    }
+
+    $process.BeginOutputReadLine()
+    $process.BeginErrorReadLine()
+    $process.WaitForExit()
+    $process.WaitForExit()
+
+    $end = [DateTimeOffset]::UtcNow
+    $duration = [math]::Round(($end - $start).TotalSeconds, 3)
+    $command = if (@($Arguments).Count -gt 0) { $Arguments[0] } else { '' }
+    $logPath = Join-Path $LogDirectory ('dotnet-' + $start.ToUnixTimeMilliseconds() + '.json')
+
+    [ordered]@{
+        startedAt = $start.ToString('O')
+        endedAt = $end.ToString('O')
+        durationSeconds = $duration
+        workingDirectory = (Get-Location).Path
+        realDotnet = $RealDotnet
+        arguments = @($Arguments)
+        command = $command
+        exitCode = $process.ExitCode
+        stdoutPath = $StdoutPath
+        stderrPath = $StderrPath
+    } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $logPath -Encoding utf8
+
+    exit $process.ExitCode
+} finally {
+    $stdoutWriter.Dispose()
+    $stderrWriter.Dispose()
+}
diff --git a/scripts/skill-benchmark/mock-executor.ps1 b/scripts/skill-benchmark/mock-executor.ps1
new file mode 100644
index 0000000..70c8c05
--- /dev/null
+++ b/scripts/skill-benchmark/mock-executor.ps1
@@ -0,0 +1,87 @@
+param(
+    [Parameter(Mandatory = $true)]
+    [string]$ContextPath,
+
+    [Parameter(Mandatory = $true)]
+    [string]$RunRoot,
+
+    [Parameter(Mandatory = $true)]
+    [string]$TranscriptPath,
+
+    [Parameter(Mandatory = $true)]
+    [string]$ResultSummaryPath,
+
+    [Parameter(Mandatory = $true)]
+    [string]$OutputsPath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+[Console]::InputEncoding = $utf8NoBom
+[Console]::OutputEncoding = $utf8NoBom
+$OutputEncoding = $utf8NoBom
+
+$context = Get-Content -LiteralPath $ContextPath -Raw | ConvertFrom-Json
+New-Item -ItemType Directory -Path $OutputsPath -Force | Out-Null
+
+$summary = ''
+$exitCode = 0
+
+switch ("$($context.eval_id):$($context.configuration)") {
+    '1:with_skill' {
+        Start-Sleep -Seconds 2
+        [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'assistant-response.md'), "mock executor completed with skill`n", $utf8NoBom)
+        [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'changed-files.md'), "## changed files`n- src\Mock.cs`n", $utf8NoBom)
+        [System.IO.File]::WriteAllText((Join-Path $RunRoot 'repo\src\Mock.cs'), "public static class Mock { public const string Mode = ""with_skill""; }`n", $utf8NoBom)
+        $summary = 'Mock with-skill execution completed.'
+    }
+    '1:without_skill' {
+        Start-Sleep -Seconds 2
+        [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'assistant-response.md'), "mock executor completed without skill`n", $utf8NoBom)
+        [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'changed-files.md'), "## changed files`n- src\Mock.cs`n", $utf8NoBom)
+        [System.IO.File]::WriteAllText((Join-Path $RunRoot 'repo\src\Mock.cs'), "public static class Mock { public const string Mode = ""without_skill""; }`n", $utf8NoBom)
+        $summary = 'Mock baseline execution completed.'
+    }
+    '2:with_skill' {
+        $childInfo = [System.Diagnostics.ProcessStartInfo]::new()
+        $childInfo.FileName = 'pwsh'
+        $childInfo.ArgumentList.Add('-NoProfile')
+        $childInfo.ArgumentList.Add('-Command')
+        $childInfo.ArgumentList.Add('Start-Sleep -Seconds 300')
+        $childInfo.UseShellExecute = $false
+        $child = [System.Diagnostics.Process]::Start($childInfo)
+        [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'child.pid'), "$($child.Id)`n", $utf8NoBom)
+        [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'assistant-response.md'), "mock executor hanging to test timeout`n", $utf8NoBom)
+        Start-Sleep -Seconds 300
+        $summary = 'This line should never be reached.'
+    }
+    '2:without_skill' {
+        Start-Sleep -Seconds 1
+        [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'assistant-response.md'), "mock executor failed without skill`n", $utf8NoBom)
+        [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'changed-files.md'), "## changed files`n- src\Broken.cs`n", $utf8NoBom)
+        [System.IO.File]::WriteAllText((Join-Path $RunRoot 'repo\src\Broken.cs'), "public static class Broken { }`n", $utf8NoBom)
+        $summary = 'Mock baseline execution failed.'
+        $exitCode = 9
+    }
+    default {
+        Start-Sleep -Seconds 1
+        [System.IO.File]::WriteAllText((Join-Path $OutputsPath 'assistant-response.md'), "mock executor default path`n", $utf8NoBom)
+        $summary = 'Mock execution completed.'
+    }
+}
+
+[System.IO.File]::WriteAllText($TranscriptPath, @"
+# Mock Transcript
+
+## Eval Prompt
+
+$($context.prompt)
+
+## Result
+
+$summary
+"@, $utf8NoBom)
+[System.IO.File]::WriteAllText($ResultSummaryPath, $summary + [Environment]::NewLine, $utf8NoBom)
+exit $exitCode
diff --git a/scripts/skill-benchmark/mock-grader.ps1 b/scripts/skill-benchmark/mock-grader.ps1
new file mode 100644
index 0000000..df87121
--- /dev/null
+++ b/scripts/skill-benchmark/mock-grader.ps1
@@ -0,0 +1,68 @@
+param(
+    [Parameter(Mandatory = $true)]
+    [string]$ContextPath,
+
+    [Parameter(Mandatory = $true)]
+    [string]$TranscriptPath,
+
+    [Parameter(Mandatory = $true)]
+    [string]$OutputsPath,
+
+    [Parameter(Mandatory = $true)]
+    [string]$TimingPath,
+
+    [Parameter(Mandatory = $true)]
+    [string]$GradingPath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+$context = Get-Content -LiteralPath $ContextPath -Raw | ConvertFrom-Json
+$timing = Get-Content -LiteralPath $TimingPath -Raw | ConvertFrom-Json
+$timedOut = [bool]$timing.cleanup.timedOut
+$exitCode = [int]$timing.executor.exitCode
+$failed = $timedOut -or $exitCode -ne 0
+
+$expectations = foreach ($expectation in @($context.expectations)) {
+    [ordered]@{
+        text = $expectation
+        passed = -not $failed
+        evidence = if ($timedOut) {
+            'The mock executor timed out and the runner produced fallback artifacts.'
+        } elseif ($exitCode -ne 0) {
+            "The mock executor exited with code $exitCode."
+        } else {
+            'The mock executor completed and the outputs were generated.'
+        }
+    }
+}
+
+$passed = @($expectations | Where-Object passed).Count
+$total = @($expectations).Count
+
+[ordered]@{
+    expectations = @($expectations)
+    summary = [ordered]@{
+        passed = $passed
+        failed = $total - $passed
+        total = $total
+        pass_rate = if ($total -eq 0) { 0 } else { [math]::Round($passed / $total, 4) }
+    }
+    execution_metrics = [ordered]@{
+        tool_calls = @{}
+        total_tool_calls = 0
+        total_steps = 1
+        errors_encountered = if ($failed) { 1 } else { 0 }
+        output_chars = 0
+        transcript_chars = (Get-Content -LiteralPath $TranscriptPath -Raw).Length
+    }
+    timing = $timing
+    claims = @()
+    user_notes_summary = [ordered]@{
+        uncertainties = @()
+        needs_review = @()
+        workarounds = @()
+    }
+} | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $GradingPath -Encoding utf8
diff --git a/scripts/test-run-skill-benchmark.ps1 b/scripts/test-run-skill-benchmark.ps1
new file mode 100644
index 0000000..cef883e
--- /dev/null
+++ b/scripts/test-run-skill-benchmark.ps1
@@ -0,0 +1,131 @@
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+
+function Write-Json {
+    param([string]$Path, $Value)
+    $directory = Split-Path -Path $Path -Parent
+    if (-not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null }
+    $Value | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $Path -Encoding utf8
+}
+
+function Write-Text {
+    param([string]$Path, [string]$Content)
+    $directory = Split-Path -Path $Path -Parent
+    if (-not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null }
+    [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom)
+}
+
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+$workspace = Join-Path ([System.IO.Path]::GetTempPath()) ('skill-benchmark-test-' + [Guid]::NewGuid().ToString('N'))
+$skillRoot = Join-Path $workspace 'mock-skill'
+
+try {
+    New-Item -ItemType Directory -Path $skillRoot -Force | Out-Null
+    Write-Text -Path (Join-Path $skillRoot 'SKILL.md') -Content @"
+---
+name: mock-benchmark-skill
+description: >
+  Mock benchmark skill for runner self-tests.
+---
+
+Answer mock tasks.
+"@
+    Write-Json -Path (Join-Path $skillRoot 'evals\evals.json') -Value ([ordered]@{
+        skill_name = 'mock-benchmark-skill'
+        evals = @(
+            [ordered]@{
+                id = 1
+                prompt = 'Mock success prompt.'
+                expected_output = 'A successful run.'
+                expectations = @('The mock run succeeds')
+                files = @('evals/files/eval-1/src/Seed.cs')
+            }
+            [ordered]@{
+                id = 2
+                prompt = 'Mock timeout prompt.'
+                expected_output = 'A failed or timed out run still produces artifacts.'
+                expectations = @('The mock run handles failure safely')
+                files = @('evals/files/eval-2/src/Seed.cs')
+            }
+        )
+    })
+    Write-Text -Path (Join-Path $skillRoot 'evals\files\eval-1\src\Seed.cs') -Content 'public static class Seed { }'
+    Write-Text -Path (Join-Path $skillRoot 'evals\files\eval-2\src\Seed.cs') -Content 'public static class Seed { }'
+
+    $benchmarkScript = Join-Path $repoRoot 'scripts\run-skill-benchmark.ps1'
+    $executor = Join-Path $repoRoot 'scripts\skill-benchmark\mock-executor.ps1'
+    $grader = Join-Path $repoRoot 'scripts\skill-benchmark\mock-grader.ps1'
+
+    & pwsh -NoProfile -File $benchmarkScript `
+        -SkillPath $skillRoot `
+        -WorkspaceRoot (Join-Path $workspace 'benchmark') `
+        -ExecutorCommand $executor `
+        -GraderCommand $grader `
+        -MaxParallel 2 `
+        -MaxGradeParallel 2 `
+        -RunTimeoutSeconds 30 `
+        -GradeTimeoutSeconds 30 `
+        -Model 'gpt-5.4-mini' `
+        -CompareWithLegacy
+
+    if ($LASTEXITCODE -ne 0) {
+        throw "Benchmark runner exited with code $LASTEXITCODE."
+    }
+
+    $summaryPath = Join-Path $workspace 'benchmark\iteration-optimized\runner-summary.json'
+    $benchmarkPath = Join-Path $workspace 'benchmark\iteration-optimized\benchmark.json'
+    $reviewPath = Join-Path $workspace 'benchmark\review-optimized.html'
+    $comparisonPath = Join-Path $workspace 'benchmark\comparison.json'
+
+    foreach ($required in @($summaryPath, $benchmarkPath, $reviewPath, $comparisonPath)) {
+        if (-not (Test-Path -LiteralPath $required)) {
+            throw "Missing required benchmark artifact: $required"
+        }
+    }
+
+    $summary = Get-Content -LiteralPath $summaryPath -Raw | ConvertFrom-Json
+    if ($summary.maxConcurrentExecutorsObserved -gt 2) {
+        throw "Bounded concurrency failed. Expected at most 2 active executors, found $($summary.maxConcurrentExecutorsObserved)."
+    }
+    if ($summary.timedOutRuns -ne 1) {
+        throw "Expected one timed out run, found $($summary.timedOutRuns)."
+    }
+    if ($summary.cleanupFailures -ne 0) {
+        throw "Expected zero cleanup failures, found $($summary.cleanupFailures)."
+    }
+
+    $timeoutRunRoot = Join-Path $workspace 'benchmark\iteration-optimized\eval-02-mock-timeout-prompt\with_skill\run-1'
+    $childPidPath = Join-Path $timeoutRunRoot 'outputs\child.pid'
+    if (-not (Test-Path -LiteralPath $childPidPath)) {
+        throw 'Mock timeout run did not write child.pid.'
+    }
+    $childPid = [int]((Get-Content -LiteralPath $childPidPath -Raw).Trim())
+    if (Get-Process -Id $childPid -ErrorAction SilentlyContinue) {
+        throw "Timed-out child process $childPid is still running."
+    }
+
+    foreach ($runDir in Get-ChildItem -LiteralPath (Join-Path $workspace 'benchmark\iteration-optimized') -Directory | Where-Object { $_.Name -like 'eval-*' } | ForEach-Object { Get-ChildItem -LiteralPath $_.FullName -Directory -Recurse | Where-Object { $_.Name -eq 'run-1' } }) {
+        foreach ($artifact in @('transcript.md', 'result-summary.md', 'grading.json', 'timing.json')) {
+            if (-not (Test-Path -LiteralPath (Join-Path $runDir.FullName $artifact))) {
+                throw "Missing $artifact in $($runDir.FullName)."
+            }
+        }
+    }
+
+    $benchmark = Get-Content -LiteralPath $benchmarkPath -Raw | ConvertFrom-Json
+    $failedRun = @($benchmark.runs | Where-Object { $_.configuration -eq 'without_skill' -and $_.eval_id -eq 2 })[0]
+    if ($null -eq $failedRun) {
+        throw 'Missing failed run in benchmark aggregation.'
+    }
+    if ($failedRun.result.pass_rate -ne 0) {
+        throw "Expected failed run pass rate 0, found $($failedRun.result.pass_rate)."
+    }
+
+    Write-Output 'run-skill-benchmark.ps1 self-test: PASS'
+} finally {
+    if (Test-Path -LiteralPath $workspace) {
+        Remove-Item -LiteralPath $workspace -Recurse -Force
+    }
+}

From 86fd12fc11d93e07def6128f905e914d783ebc66 Mon Sep 17 00:00:00 2001
From: "aicia[bot]" 
Date: Sun, 9 Aug 2026 23:40:08 +0200
Subject: [PATCH 3/8] =?UTF-8?q?=E2=9C=A8=20add=20caching=20and=20tracing?=
 =?UTF-8?q?=20to=20dotnet-test=20resolver?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Resolves NuGet package versions deterministically and now adds two new capabilities: SHA256-based caching that reuses previous resolution results within a benchmark scope, and structured tracing that logs role, frameworks, packages, candidate limits, cache hit status, and execution duration. Caching is controlled by DOTNET_TEST_RESOLVER_CACHE_DIR; tracing by DOTNET_TEST_RESOLVER_TRACE_FILE; maximum candidates by DOTNET_TEST_MAXIMUM_CANDIDATES. Test harness enhanced to verify cache behavior and validate timing instrumentation.
---
 skills/dotnet-test/SKILL.md                   |   2 +
 .../scripts/resolve-test-package-versions.ps1 | 115 +++++++++++++++++-
 .../test-resolve-test-package-versions.ps1    | 113 +++++++++++++++--
 3 files changed, 214 insertions(+), 16 deletions(-)

diff --git a/skills/dotnet-test/SKILL.md b/skills/dotnet-test/SKILL.md
index a2d45a8..327e9d1 100644
--- a/skills/dotnet-test/SKILL.md
+++ b/skills/dotnet-test/SKILL.md
@@ -60,6 +60,8 @@ pwsh -NoProfile -File "/scripts/resolve-test-package-versions.ps1" -
 
 The resolver queries NuGet stable versions, tries newer candidates first, and verifies each candidate against the selected package set through isolated compatibility-project restores; it emits only a set whose combined package restore passes. If it fails, report the package, target frameworks, and restore evidence instead of guessing.
 
+When a benchmark or smoke harness provides `DOTNET_TEST_MAXIMUM_CANDIDATES`, `DOTNET_TEST_RESOLVER_CACHE_DIR`, or `DOTNET_TEST_RESOLVER_TRACE_FILE`, honor that measured scope instead of widening the live search again. Use a small explicit candidate limit for ordinary eval smoke runs; fallback and combined-package behavior stay covered by `scripts/test-resolve-test-package-versions.ps1`.
+
 Preserve package ownership:
 
 - Central Package Management: update or add `PackageVersion` in the owning `Directory.Packages.props`; keep project `PackageReference` items versionless.
diff --git a/skills/dotnet-test/scripts/resolve-test-package-versions.ps1 b/skills/dotnet-test/scripts/resolve-test-package-versions.ps1
index 1ca70de..db4c6c8 100644
--- a/skills/dotnet-test/scripts/resolve-test-package-versions.ps1
+++ b/skills/dotnet-test/scripts/resolve-test-package-versions.ps1
@@ -8,8 +8,11 @@ param(
 
     [string[]]$PackageId,
 
-    [ValidateRange(1, 100)]
-    [int]$MaximumCandidates = 30
+    [int]$MaximumCandidates,
+
+    [string]$CacheDirectory = $env:DOTNET_TEST_RESOLVER_CACHE_DIR,
+
+    [string]$TraceFile = $env:DOTNET_TEST_RESOLVER_TRACE_FILE
 )
 
 Set-StrictMode -Version Latest
@@ -18,6 +21,67 @@ $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
 [Console]::OutputEncoding = $utf8NoBom
 $OutputEncoding = $utf8NoBom
 
+if (-not $PSBoundParameters.ContainsKey('MaximumCandidates')) {
+    if (-not [string]::IsNullOrWhiteSpace($env:DOTNET_TEST_MAXIMUM_CANDIDATES)) {
+        try {
+            $MaximumCandidates = [int]$env:DOTNET_TEST_MAXIMUM_CANDIDATES
+        } catch {
+            throw "DOTNET_TEST_MAXIMUM_CANDIDATES must be an integer. Found '$($env:DOTNET_TEST_MAXIMUM_CANDIDATES)'."
+        }
+    } else {
+        $MaximumCandidates = 30
+    }
+}
+
+if ($MaximumCandidates -lt 1 -or $MaximumCandidates -gt 100) {
+    throw "MaximumCandidates must be between 1 and 100. Found '$MaximumCandidates'."
+}
+
+function Get-ResolverCacheKey {
+    param(
+        [Parameter(Mandatory = $true)] [string]$RoleName,
+        [Parameter(Mandatory = $true)] [string[]]$Frameworks,
+        [Parameter(Mandatory = $true)] [string[]]$Packages,
+        [Parameter(Mandatory = $true)] [int]$CandidateLimit
+    )
+
+    $seed = [ordered]@{
+        role = $RoleName
+        targetFrameworks = @($Frameworks | Sort-Object)
+        packageIds = @($Packages | Sort-Object)
+        maximumCandidates = $CandidateLimit
+    } | ConvertTo-Json -Compress
+    return [System.BitConverter]::ToString(([System.Security.Cryptography.SHA256]::HashData($utf8NoBom.GetBytes($seed)))).Replace('-', '').ToLowerInvariant()
+}
+
+function Write-ResolverTrace {
+    param(
+        [string]$TraceFilePath,
+        [string]$RoleName,
+        [string[]]$Frameworks,
+        [string[]]$Packages,
+        [int]$CandidateLimit,
+        [bool]$CacheHit,
+        [double]$DurationSeconds
+    )
+
+    if ([string]::IsNullOrWhiteSpace($TraceFilePath)) { return }
+    $directory = Split-Path -Path $TraceFilePath -Parent
+    if (-not [string]::IsNullOrWhiteSpace($directory)) {
+        New-Item -ItemType Directory -Path $directory -Force | Out-Null
+    }
+
+    ([ordered]@{
+        role = $RoleName
+        targetFrameworks = @($Frameworks)
+        packageIds = @($Packages)
+        maximumCandidates = $CandidateLimit
+        cacheHit = $CacheHit
+        durationSeconds = [math]::Round($DurationSeconds, 3)
+        timestamp = [DateTimeOffset]::UtcNow.ToString('O')
+    } | ConvertTo-Json -Compress) + [Environment]::NewLine | Add-Content -LiteralPath $TraceFilePath -Encoding utf8
+}
+
 function Get-VersionKey {
     param([string]$Version)
     $parts = $Version.Split('.')
@@ -76,6 +140,28 @@ $packageIds = if ($PackageId -and $PackageId.Count -gt 0) {
     @('Microsoft.NET.Test.Sdk', 'xunit.v3', 'xunit.v3.runner.console', 'xunit.runner.visualstudio', $codebeltPackage)
 }
 
+$cacheKey = if ([string]::IsNullOrWhiteSpace($CacheDirectory)) { $null } else { Get-ResolverCacheKey -RoleName $Role -Frameworks $TargetFramework -Packages $packageIds -CandidateLimit $MaximumCandidates }
+$cachePath = if ($null -eq $cacheKey) { $null } else { Join-Path $CacheDirectory ($cacheKey + '.json') }
+$startedAt = [DateTimeOffset]::UtcNow
+
+if ($null -ne $cachePath -and (Test-Path -LiteralPath $cachePath -PathType Leaf)) {
+    $cached = Get-Content -LiteralPath $cachePath -Raw | ConvertFrom-Json
+    $durationSeconds = [math]::Round(([DateTimeOffset]::UtcNow - $startedAt).TotalSeconds, 3)
+    if ($null -eq $cached.cache) {
+        $cached | Add-Member -NotePropertyName cache -NotePropertyValue ([pscustomobject]@{}) -Force
+    }
+    if ($null -eq $cached.timing) {
+        $cached | Add-Member -NotePropertyName timing -NotePropertyValue ([pscustomobject]@{}) -Force
+    }
+    $cached.cache | Add-Member -NotePropertyName enabled -NotePropertyValue $true -Force
+    $cached.cache | Add-Member -NotePropertyName hit -NotePropertyValue $true -Force
+    $cached.cache | Add-Member -NotePropertyName key -NotePropertyValue $cacheKey -Force
+    $cached.timing | Add-Member -NotePropertyName durationSeconds -NotePropertyValue $durationSeconds -Force
+    Write-ResolverTrace -TraceFilePath $TraceFile -RoleName $Role -Frameworks $TargetFramework -Packages $packageIds -CandidateLimit $MaximumCandidates -CacheHit $true -DurationSeconds $durationSeconds
+    $cached | ConvertTo-Json -Depth 8
+    return
+}
+
 $serviceIndex = Invoke-RestMethod -Uri 'https://api.nuget.org/v3/index.json'
 $packageBaseAddress = $serviceIndex.resources |
     Where-Object { $_.'@type' -eq 'PackageBaseAddress/3.0.0' } |
@@ -156,11 +242,32 @@ try {
         })
     }
 
-    [ordered]@{
+    $durationSeconds = [math]::Round(([DateTimeOffset]::UtcNow - $startedAt).TotalSeconds, 3)
+    $result = [ordered]@{
         role = $Role
         targetFrameworks = @($TargetFramework)
+        maximumCandidates = $MaximumCandidates
+        cache = [ordered]@{
+            enabled = $null -ne $cachePath
+            hit = $false
+            key = $cacheKey
+        }
+        timing = [ordered]@{
+            durationSeconds = $durationSeconds
+        }
         packages = @($resolved | Sort-Object packageId)
-    } | ConvertTo-Json -Depth 5
+    }
+
+    if ($null -ne $cachePath) {
+        $directory = Split-Path -Path $cachePath -Parent
+        if (-not [string]::IsNullOrWhiteSpace($directory)) {
+            New-Item -ItemType Directory -Path $directory -Force | Out-Null
+        }
+        $result | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $cachePath -Encoding utf8
+    }
+
+    Write-ResolverTrace -TraceFilePath $TraceFile -RoleName $Role -Frameworks $TargetFramework -Packages $packageIds -CandidateLimit $MaximumCandidates -CacheHit $false -DurationSeconds $durationSeconds
+    $result | ConvertTo-Json -Depth 8
 } finally {
     if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force }
 }
diff --git a/skills/dotnet-test/scripts/test-resolve-test-package-versions.ps1 b/skills/dotnet-test/scripts/test-resolve-test-package-versions.ps1
index e69b011..922c998 100644
--- a/skills/dotnet-test/scripts/test-resolve-test-package-versions.ps1
+++ b/skills/dotnet-test/scripts/test-resolve-test-package-versions.ps1
@@ -11,6 +11,7 @@ $packageBaseAddress = 'https://mock.nuget/flatcontainer/'
 function Reset-ResolverMock {
     $global:DotnetTestResolverVersions = @{}
     $global:DotnetTestResolverRestoreRequests = [System.Collections.Generic.List[object]]::new()
+    $global:DotnetTestResolverHttpRequests = [System.Collections.Generic.List[string]]::new()
     $global:DotnetTestResolverFailureMode = 'Success'
     $global:DotnetTestResolverFailurePackageId = $null
     $global:DotnetTestResolverFailureVersion = $null
@@ -33,6 +34,8 @@ function Set-TestPackageVersions {
 function Invoke-RestMethod {
     param([Parameter(Mandatory = $true)][string]$Uri)
 
+    $global:DotnetTestResolverHttpRequests.Add($Uri)
+
     if ($Uri -eq $serviceIndexUri) {
         return [pscustomobject]@{
             resources = @([pscustomobject]@{
@@ -101,27 +104,45 @@ function Invoke-TestResolver {
         [Parameter(Mandatory = $true)]
         [string[]]$PackageId,
 
-        [Parameter(Mandatory = $true)]
-        [int]$MaximumCandidates
+        [int]$MaximumCandidates,
+
+        [string]$CacheDirectory,
+
+        [switch]$UseDefaultCandidateLimit
     )
 
     $output = @()
     $caught = $false
     try {
-        $output = @(& $resolver -TargetFramework net10.0 -Role Unit -PackageId $PackageId -MaximumCandidates $MaximumCandidates 2>&1)
+        if ($UseDefaultCandidateLimit) {
+            if ([string]::IsNullOrWhiteSpace($CacheDirectory)) {
+                $output = @(& $resolver -TargetFramework net10.0 -Role Unit -PackageId $PackageId 2>&1)
+            } else {
+                $output = @(& $resolver -TargetFramework net10.0 -Role Unit -PackageId $PackageId -CacheDirectory $CacheDirectory 2>&1)
+            }
+        } elseif ([string]::IsNullOrWhiteSpace($CacheDirectory)) {
+            $output = @(& $resolver -TargetFramework net10.0 -Role Unit -PackageId $PackageId -MaximumCandidates $MaximumCandidates 2>&1)
+        } else {
+            $output = @(& $resolver -TargetFramework net10.0 -Role Unit -PackageId $PackageId -MaximumCandidates $MaximumCandidates -CacheDirectory $CacheDirectory 2>&1)
+        }
     } catch {
         $caught = $true
         $output += $_
     }
 
     $text = ($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine
-    $exitCode = [int]$global:LASTEXITCODE
+    $exitCode = if ($caught -and [int]$global:LASTEXITCODE -eq 0) { 1 } else { [int]$global:LASTEXITCODE }
     $json = $null
-    if (-not $caught -and $exitCode -eq 0) {
+    $jsonText = $null
+    $jsonStart = $text.IndexOf('{')
+    if ($jsonStart -ge 0) {
+        $jsonText = $text.Substring($jsonStart)
         try {
-            $json = $text | ConvertFrom-Json
+            $json = $jsonText | ConvertFrom-Json
         } catch {
-            throw "Resolver returned invalid JSON: $text"
+            if (-not $caught -and $exitCode -eq 0) {
+                throw "Resolver returned invalid JSON: $text"
+            }
         }
     }
 
@@ -132,6 +153,19 @@ function Invoke-TestResolver {
     }
 }
 
+function Get-ResolverJsonObject {
+    param(
+        [Parameter(Mandatory = $true)]
+        [string]$Text
+    )
+
+    $jsonStart = $Text.IndexOf('{')
+    if ($jsonStart -lt 0) {
+        throw "Resolver output did not contain JSON.`n$Text"
+    }
+    return ($Text.Substring($jsonStart) | ConvertFrom-Json)
+}
+
 function Assert-Equal {
     param(
         [Parameter(Mandatory = $true)]$Actual,
@@ -165,13 +199,24 @@ function Assert-ContainsText {
     }
 }
 
+function Assert-False {
+    param(
+        [Parameter(Mandatory = $true)][bool]$Condition,
+        [Parameter(Mandatory = $true)][string]$Because
+    )
+
+    if ($Condition) { throw "Assertion failed: $Because." }
+}
+
 try {
     Reset-ResolverMock
 
     Set-TestPackageVersions -Id 'Stable.Package' -Versions @('11.0.0-rc.1', '10.0.0', '9.99.0', '9.98.0')
     $stable = Invoke-TestResolver -PackageId 'Stable.Package' -MaximumCandidates 2
+    $stableResult = Get-ResolverJsonObject -Text $stable.text
+    $stablePackages = @($stableResult.packages)
     Assert-Equal -Actual $stable.exitCode -Expected 0 -Because 'stable candidate resolution should succeed'
-    Assert-Equal -Actual $stable.json.packages[0].version -Expected '10.0.0' -Because 'stable versions must be numerically ordered and prereleases excluded'
+    Assert-Equal -Actual $stablePackages[0].version -Expected '10.0.0' -Because 'stable versions must be numerically ordered and prereleases excluded'
 
     Reset-ResolverMock
     Set-TestPackageVersions -Id 'Fallback.Package' -Versions @('3.0.0', '2.0.0')
@@ -179,8 +224,10 @@ try {
     $global:DotnetTestResolverFailurePackageId = 'Fallback.Package'
     $global:DotnetTestResolverFailureVersion = '3.0.0'
     $fallback = Invoke-TestResolver -PackageId 'Fallback.Package' -MaximumCandidates 2
+    $fallbackResult = Get-ResolverJsonObject -Text $fallback.text
+    $fallbackPackages = @($fallbackResult.packages)
     Assert-Equal -Actual $fallback.exitCode -Expected 0 -Because 'an older restorable candidate should be selected'
-    Assert-Equal -Actual $fallback.json.packages[0].version -Expected '2.0.0' -Because 'candidate fallback should continue after a restore failure'
+    Assert-Equal -Actual $fallbackPackages[0].version -Expected '2.0.0' -Because 'candidate fallback should continue after a restore failure'
     Assert-Equal -Actual $global:DotnetTestResolverRestoreRequests.Count -Expected 2 -Because 'the failed newest candidate and fallback candidate should both be tested'
 
     Reset-ResolverMock
@@ -189,11 +236,13 @@ try {
     $global:DotnetTestResolverFailureMode = 'FailCombination'
     $global:DotnetTestResolverFailureCombination = 'Package.A=2.0.0;Package.B=2.0.0'
     $combined = Invoke-TestResolver -PackageId @('Package.A', 'Package.B') -MaximumCandidates 2
+    $combinedResult = Get-ResolverJsonObject -Text $combined.text
+    $combinedPackages = @($combinedResult.packages)
     Assert-Equal -Actual $combined.exitCode -Expected 0 -Because 'the resolver should recover from an incompatible combined package set'
-    Assert-Equal -Actual (($combined.json.packages | Where-Object packageId -eq 'Package.A').version) -Expected '2.0.0' -Because 'the compatible first package candidate should be retained'
-    Assert-Equal -Actual (($combined.json.packages | Where-Object packageId -eq 'Package.B').version) -Expected '1.0.0' -Because 'the incompatible newest second package candidate should fall back'
+    Assert-Equal -Actual (($combinedPackages | Where-Object packageId -eq 'Package.A').version) -Expected '2.0.0' -Because 'the compatible first package candidate should be retained'
+    Assert-Equal -Actual (($combinedPackages | Where-Object packageId -eq 'Package.B').version) -Expected '1.0.0' -Because 'the incompatible newest second package candidate should fall back'
     Assert-True -Condition (@($global:DotnetTestResolverRestoreRequests | Where-Object { $_.references.Count -eq 2 }).Count -gt 0) -Because 'compatibility must be tested with the combined package set'
-    Assert-Equal -Actual ($combined.json.packages | Where-Object packageId -eq 'Package.A').compatibility -Expected 'combined restore passed' -Because 'the output must describe the compatibility actually validated'
+    Assert-Equal -Actual ($combinedPackages | Where-Object packageId -eq 'Package.A').compatibility -Expected 'combined restore passed' -Because 'the output must describe the compatibility actually validated'
 
     Reset-ResolverMock
     Set-TestPackageVersions -Id 'Failure.Package' -Versions @('2.0.0', '1.0.0')
@@ -203,11 +252,51 @@ try {
     Assert-ContainsText -Text $failure.text -Expected "No stable 'Failure.Package' version" -Because 'failure output must identify the package and candidate scope'
     Assert-ContainsText -Text $failure.text -Expected 'NU_TEST_RESTORE_FAILURE' -Because 'failure output must preserve restore evidence'
 
+    Reset-ResolverMock
+    Set-TestPackageVersions -Id 'Cached.Package' -Versions @('5.0.0', '4.0.0')
+    $cacheRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('dotnet-test-resolver-cache-' + [Guid]::NewGuid().ToString('N'))
+    try {
+        $first = Invoke-TestResolver -PackageId 'Cached.Package' -MaximumCandidates 1 -CacheDirectory $cacheRoot
+        $firstResult = Get-ResolverJsonObject -Text $first.text
+        $firstPackages = @($firstResult.packages)
+        Assert-Equal -Actual $first.exitCode -Expected 0 -Because 'cache seed should succeed'
+        Assert-False -Condition ([bool]$firstResult.cache.hit) -Because 'first cached resolution should be live'
+        Assert-Equal -Actual $firstPackages[0].version -Expected '5.0.0' -Because 'cache seed should still expose the resolved package set'
+        $restoreCount = $global:DotnetTestResolverRestoreRequests.Count
+        $httpCount = $global:DotnetTestResolverHttpRequests.Count
+
+        $second = Invoke-TestResolver -PackageId 'Cached.Package' -MaximumCandidates 1 -CacheDirectory $cacheRoot
+        $secondResult = Get-ResolverJsonObject -Text $second.text
+        $secondPackages = @($secondResult.packages)
+        Assert-Equal -Actual $second.exitCode -Expected 0 -Because 'cache reuse should succeed'
+        Assert-True -Condition ([bool]$secondResult.cache.hit) -Because 'second cached resolution should be served from cache'
+        Assert-Equal -Actual $secondPackages[0].version -Expected '5.0.0' -Because 'cache reuse should preserve the cached package data'
+        Assert-Equal -Actual $global:DotnetTestResolverRestoreRequests.Count -Expected $restoreCount -Because 'cache reuse should skip compatibility restores'
+        Assert-Equal -Actual $global:DotnetTestResolverHttpRequests.Count -Expected $httpCount -Because 'cache reuse should skip NuGet HTTP lookups'
+    } finally {
+        if (Test-Path -LiteralPath $cacheRoot) { Remove-Item -LiteralPath $cacheRoot -Recurse -Force }
+    }
+
+    Reset-ResolverMock
+    Set-TestPackageVersions -Id 'Limited.Package' -Versions @('4.0.0', '3.0.0')
+    $global:DotnetTestResolverFailureMode = 'FailPackageVersion'
+    $global:DotnetTestResolverFailurePackageId = 'Limited.Package'
+    $global:DotnetTestResolverFailureVersion = '4.0.0'
+    $env:DOTNET_TEST_MAXIMUM_CANDIDATES = '1'
+    try {
+        $limited = Invoke-TestResolver -PackageId 'Limited.Package' -UseDefaultCandidateLimit
+        Assert-True -Condition ($limited.exitCode -ne 0) -Because 'the env candidate limit should constrain default resolver breadth'
+        Assert-ContainsText -Text $limited.text -Expected "newest 1 candidates" -Because 'failure output should reflect the env-driven candidate limit'
+    } finally {
+        Remove-Item Env:DOTNET_TEST_MAXIMUM_CANDIDATES -ErrorAction SilentlyContinue
+    }
+
     Write-Output 'resolve-test-package-versions.ps1 regression: PASS'
 } finally {
     foreach ($name in @(
         'DotnetTestResolverVersions',
         'DotnetTestResolverRestoreRequests',
+        'DotnetTestResolverHttpRequests',
         'DotnetTestResolverFailureMode',
         'DotnetTestResolverFailurePackageId',
         'DotnetTestResolverFailureVersion',

From 6b171ae992a8d7a74165d0b158571379094010ec Mon Sep 17 00:00:00 2001
From: "aicia[bot]" 
Date: Sun, 9 Aug 2026 23:40:19 +0200
Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=92=AC=20update=20repository=20docume?=
 =?UTF-8?q?ntation?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Update AGENTS.md with guidance to prefer the repo-owned run-skill-benchmark.ps1 runner for local skill benchmarking. Update CONTRIBUTING.md with entry point documentation and examples. Update README.md to add dotnet-remote-testing to the skills list, document the benchmark runner as the preferred local workflow, and explain structured result parsing and failure classification.
---
 AGENTS.md       |  1 +
 CONTRIBUTING.md | 10 +++++++++-
 README.md       | 33 +++++++++++++++++++++++++++++++--
 3 files changed, 41 insertions(+), 3 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 25c46b3..371e175 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -32,6 +32,7 @@ Every repo-managed skill must include its own `evals/evals.json` file at `skills
 - Run evals from a temp workspace such as `$env:TEMP/-workspace/`, never from inside this repository
 - When creating or modifying a repo-managed skill, run the full per-skill test from that temp workspace before the work is considered complete. Full test means both `with_skill` and `without_skill` comparison executions, grading both runs, aggregating `benchmark.json`, and opening the review viewer. A reasoning-only smoke test does not count as full test.
 - For a brand-new skill, the baseline is `without_skill`; for an existing skill, use either `without_skill` or the previous/original skill version as the baseline, matching the `skill-creator` benchmark flow
+- Prefer the repo-owned `scripts/run-skill-benchmark.ps1` runner for local measured benchmarks. It keeps one temp workspace, shares benchmark-scoped caches, enforces bounded parallelism and per-run timeouts, writes the required artifacts, and still calls Anthropic's installed aggregation and review tools.
 - Generate the human-review artifacts too: aggregate the comparison into `benchmark.json` and launch `eval-viewer/generate_review.py` from the installed Anthropic `skill-creator` copy (typically under `~/.agents/skills/skill-creator/` or `~/.claude/skills/skill-creator/`) so the user can inspect `Outputs` and `Benchmark` before sign-off
 - Deterministic scaffold/template skills must keep local deterministic validators as well; evals supplement validators, they do not replace them
 
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 55ce7cb..3f985ad 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -101,6 +101,14 @@ When creating or modifying a repo-managed skill, the eval workflow must include
 - Aggregate the results into `benchmark.json`
 - Launch `eval-viewer/generate_review.py` from that installed `skill-creator` copy so a human can review both `Outputs` and `Benchmark`
 
+The preferred local entry point is the repo-owned runner:
+
+```powershell
+pwsh -NoProfile -File .\scripts\run-skill-benchmark.ps1 -SkillPath .\skills\ -CompareWithLegacy
+```
+
+That runner keeps one temp workspace, stages shared fixtures once, shares a benchmark-scoped cache, prewarms expensive resolver work where available, enforces bounded parallelism plus per-run timeouts, and still delegates aggregation and static review generation to the installed Anthropic `skill-creator` copy.
+
 This repo treats that paired `with_skill` / `without_skill` comparison as part of the required devex for skill work. The benchmark artifacts live in the temp workspace; do not commit them to this repository unless the change explicitly calls for checked-in examples.
 
 For scaffold/template skills, keep deterministic validators alongside evals. In this repo, `evals/evals.json` is mandatory, and validators like `scripts/validate-skill-templates.ps1` are additional protection.
@@ -120,7 +128,7 @@ Use the repo validation harness before submitting scaffold or template changes:
 pwsh -NoProfile -File ./scripts/validate-skill-templates.ps1
 ```
 
-Run the validator locally first for the fastest feedback loop. GitHub Actions also runs the same script on pull requests, but CI is the backstop, not the primary authoring loop.
+Run the validator locally first for the fastest feedback loop. GitHub Actions also runs the same script on pull requests, but CI is the backstop, not the primary authoring loop.
 
 To compare a change against the initial imported version, run the same harness against a git ref:
 
diff --git a/README.md b/README.md
index 8db5a38..7f8c35d 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ One repo-wide convention matters especially for scaffolding skills: prefer dynam
 
 Another repo rule is intentionally strict: every repo-managed skill ships with its own `evals/evals.json`, and those evals are run per skill from a temp workspace instead of from inside this repository.
 
-Another part of that workflow is now mandatory too: when a repo-managed skill is created or modified, the author must run the full per-skill test from a temp workspace. Full test means both `with_skill` and `without_skill` comparison executions, grading both runs, aggregating the results into `benchmark.json`, and opening `eval-viewer/generate_review.py` from the installed Anthropic `skill-creator` copy, typically under `~/.agents/skills/skill-creator/` or `~/.claude/skills/skill-creator/`, so a human can review both the `Outputs` and `Benchmark` views before sign-off. For new skills the baseline is `without_skill`; for existing skills it can be `without_skill` or the previous/original skill version, matching the `skill-creator` benchmark flow. A reasoning-only smoke test does not count. When the available runner supports sub-agents or equivalent background tasks, the measured benchmark should fan out paired executor runs in parallel and parallelize independent grading work too, rather than running evals serially by habit.
+Another part of that workflow is now mandatory too: when a repo-managed skill is created or modified, the author must run the full per-skill test from a temp workspace. Full test means both `with_skill` and `without_skill` comparison executions, grading both runs, aggregating the results into `benchmark.json`, and opening `eval-viewer/generate_review.py` from the installed Anthropic `skill-creator` copy, typically under `~/.agents/skills/skill-creator/` or `~/.claude/skills/skill-creator/`, so a human can review both the `Outputs` and `Benchmark` views before sign-off. For new skills the baseline is `without_skill`; for existing skills it can be `without_skill` or the previous/original skill version, matching the `skill-creator` benchmark flow. A reasoning-only smoke test does not count. The preferred local entry point is now `scripts/run-skill-benchmark.ps1`, which keeps one temp workspace, shares benchmark-scoped caches, prewarms expensive package resolution once per benchmark, enforces bounded parallelism and timeouts, and still hands aggregation plus static review generation to Anthropic's installed tooling.
 
 One more consistency rule matters for form-driven skills: native input fields are treated as a host feature, not something a model can rely on. Skills in this repo must stay usable with or without UI widgets, and must fall back to the same deterministic one-field-at-a-time flow when the host only supports plain chat.
 
@@ -38,6 +38,14 @@ DocFX diagnostics favor repairable specificity: overwrite-layout errors now call
 
 Validation follows the same philosophy: run `pwsh -NoProfile -File ./scripts/validate-skill-templates.ps1` locally for the fast feedback loop, and use `pwsh -NoProfile -File ./scripts/validate-skill-templates.ps1 -Full` when the slower DocFX regression suites are part of the gate. GitHub Actions runs full mode on pull requests as the safety net. The validator emits `[RUN]`, `[PASS]`, `[FAIL]`, `[WAIT]`, and `[SKIP]` progress lines so long phases show visible heartbeat feedback. It also checks skill frontmatter metadata such as per-skill `evals/evals.json` files, optional eval fixture paths declared through `files`, and the 1024-character YAML description limit; it does not replace the paired benchmark review workflow.
 
+For measured skill benchmarks, use the repo runner from a temp workspace:
+
+```powershell
+pwsh -NoProfile -File .\scripts\run-skill-benchmark.ps1 -SkillPath .\skills\ -CompareWithLegacy
+```
+
+It writes `transcript.md`, `result-summary.md`, `grading.json`, `benchmark.json`, and a static review HTML under the temp workspace while preserving the installed Anthropic aggregation/viewer flow.
+
 ## Install a skill
 
 Install any skill directly from this repository with a single command:
@@ -83,6 +91,7 @@ npx skills add https://github.com/codebeltnet/agentic --skill dotnet-change-impa
 npx skills add https://github.com/codebeltnet/agentic --skill dotnet-docfx-digest
 npx skills add https://github.com/codebeltnet/agentic --skill dotnet-test
 npx skills add https://github.com/codebeltnet/agentic --skill dotnet-benchmark
+npx skills add https://github.com/codebeltnet/agentic --skill dotnet-remote-testing
 npx skills add https://github.com/codebeltnet/agentic --skill agent-smith
 # npx skills add https://github.com/codebeltnet/agentic --skill another-skill
 ```
@@ -122,7 +131,8 @@ npx skills add https://github.com/codebeltnet/agentic --skill agent-smith
 | [dotnet-docfx-digest](skills/dotnet-docfx-digest/SKILL.md) | Create and maintain developer-friendly DocFX documentation for .NET public APIs, including repo-wide no-input audits that inspect source, tests, DocFX config, DocFX `build.content` and `build.overwrite` Markdown inputs, namespace pages, and availability includes before asking for clarification, while treating bare direct skill invocations as autonomous repo-wide runs rather than human-driven checkpoint sessions. Enforces the workflow with two bundled .NET 10 file-based scripts resolved from the loaded skill directory, falling back to the repo-managed source path only when present: `scripts/agents.cs` writes an idempotent, marker-bounded DocFX maintenance block into the repository `AGENTS.md`; `scripts/docfx.cs` is **fast and build-free by default** — it validates Markdown, prose, DocFX overwrite layout, namespace overview pages, `Extension Members` tables, decorated receiver signatures such as `IDecorator`, generic method displays such as `As`, purpose-first summaries, and required per-type/extension examples without invoking `dotnet`, `msbuild`, `docfx`, or `gh`, discovering the public API from existing DocFX YAML metadata or a conservative source scan and ending every run with a `[processes] dotnet=0 msbuild=0 docfx=0 gh=0` summary plus per-phase timings. Compilation and network access are strictly opt-in: `--validate-samples` compiles each C# sample in an isolated project while batching all sample projects into one temporary `.slnx` graph build with bounded MSBuild parallelism and scoped references, `--build-api-model` (alias `--strict-api-discovery`) does reflection-backed discovery from compiled metadata via `MetadataLoadContext` through a single scoped `.slnx` graph build, `--verify-docfx-build` runs the DocFX CLI in a temp copy, and `--search-examples` runs `gh` code search. Final verification adapts to available processors and memory, overlaps isolated DocFX work on high-capacity machines, uses a 30-minute child timeout, and emits 10-second `stderr` heartbeats with active phase, workload, runner count, PID, elapsed time, last-output age, and current child output while preserving machine-readable JSON on `stdout`. Honors a single DocFX metadata `TargetFramework` when `--framework` is omitted, collapses C# 14 extension-block compiler containers such as `$...` back to the authored outer static class in both fast DocFX-YAML discovery and build-backed reflection discovery, validates namespace fly-ins that explain the problem solved/when to use/where to start plus example fly-ins before every C# fence, the Codebelt namespace-and-type-folder overwrite layout (`.docfx/api/namespaces/**/*.md` and `.docfx/api/types/**/*.md` under `build.overwrite` only), keeps `--changed-only` validation scoped to affected docs and APIs while still including brand-new untracked overwrite Markdown, uses the root Codebelt `.snk` when present and falls back to `-p:SkipSignAssembly=true` for keyless strong-name build verification, drains child stdout and stderr concurrently to avoid verbose-build deadlocks, writes deterministic `--assessment-queue` Markdown work queues for noisy audits, preserves working URL references unless a verified HTTP 404 justifies removal, treats unexpected new repo-root or DocFX-workspace files that are not known `dotnet-docfx-digest` deliverables as blocking cleanup diagnostics, keeps assessment/manifests/captured output/helper scripts in temp or session storage instead of the target repository, requires a namespace-first pass across the active queue before net-new type/example authoring during full audits, keeps deeper `EXTENSION_METHOD_MISSING` and `EXTENSION_METHOD_SIGNATURE_MISSING` follow-on diagnostics in that same namespace-layer table-repair phase when they appear after `EXTENSION_SECTION_MISSING` drops, preserves existing BOM and line-ending state while flagging actual mojibake instead of creating encoding-only diffs, and leaves generated DocFX YAML metadata untouched unless `--clean-generated-metadata` is explicitly requested (which runs only after the API model is built, never deleting metadata the run relied on). Documents public API only, uses bundled reference docs for overwrite rules, workflow details, and script behavior, keeps authored API overwrite Markdown under `.docfx/api/namespaces/` and `.docfx/api/types/`, moves legacy authored `.docfx/api/*.md` overwrite files there instead of widening the glob to `api/**/*.md`, teaches namespace and API prose to orient newcomers around purpose instead of inventorying contents, prefers inline or small sibling-batch prose repairs over slow per-page worker fan-out, makes examples start from package-ID usage evidence before type/member-only searches and requires each example to introduce the consumer task before the code, allows multi-type Microsoft Learn-style scenario samples when they better explain the consumer workflow, keeps extension-method examples on readable declaring-class type pages under `.docfx/api/types/` instead of synthetic method-UID filenames or namespace pages that mix extra `uid:` / `example:` blocks into the overview, flags weak skip-compile reasons, requires deterministic `.docfx/skip-compile-allowlist.json` entries for any pre-existing approved skip waivers, treats newly introduced or unallowlisted skip markers as fail-level diagnostics that do not suppress compilation, establishes reflection-backed packets with `--build-api-model --project-manifest` before full-run authoring, forces mid-audit continuations to name that manifest or the sequential assessment/namespace-first fallback explicitly, requires those continuations to restate the fast `docfx.cs --json` rerun cadence, the exact final `docfx.cs --build-api-model --validate-samples --verify-docfx-build --json` gate, and the clean JSON completion contract instead of generic “verify later” prose, treats batch size only as rerun cadence rather than permission to stop, runs a completion repair loop that treats every diagnostic as active work regardless of age or volume, treats newly surfaced follow-on diagnostics as the next repair queue instead of a stop point, reruns packet discovery with `--build-api-model --project-manifest` when fast source-scan packets are unnamed or zero-project, falls back to sequential namespace-first or assessment work queue order when packet discovery is still unusable, treats `EXAMPLE_MISSING`, `EXAMPLE_LEAD_MISSING`, `EXAMPLE_ADVANCED_LEAD_MISSING`, `FAMILY_ANCHOR_EXAMPLE_MISSING`, `SAMPLE_STRUCTURE_INVALID`, `FAIL_NEW_SKIP_MARKER_INTRODUCED`, `SAMPLE_SKIP_NOT_ALLOWLISTED`, and `INTERIM_ARTIFACT_IN_WORKTREE` queues as core work rather than checkpoints or quality backlog, drives large example and lead queues through a concrete fast-path micro-loop (next item or next 3-5 items → rerun → continue), suppresses progress-table/checkpoint output until the completion contract is clean or a real external blocker is reported, treats premature completion-shaped handoffs as execution-protocol failures while the queue is still dirty, reserves the final `--build-api-model --validate-samples --verify-docfx-build` verification for the real end of the queue, exposes `summary.fullVerificationRan`, `summary.canClaimCompletion`, `summary.remainingWorkItems`, `summary.remainingDiagnosticsByCode`, `summary.newlyIntroducedSkipMarkers`, and `summary.interimArtifacts` as machine-readable final gates, reruns the fast `docfx.cs --json` after edits until the queue is empty, then runs the build-backed verification before completion, preserves manual edits and authored Markdown during cleanup, skips recursive generated-output cleanup when a target directory contains documentation or source files, and returns deterministic exit codes plus `--json` reports (including process counts, phase timings, warning counts, and skip-marker accounting) so CI can gate on real failures instead of AI claims. |
 | [dotnet-test](skills/dotnet-test/SKILL.md) | Bootstraps and refactors xUnit projects to Codebelt conventions. It deterministically inspects project roles, target frameworks, xUnit generation, package ownership, inheritance, application entry points—including Bootstrapper `MinimalConsoleProgram`, `MinimalWorkerProgram`, and `MinimalWebProgram` hosts—and every selected `WebApplicationFactory` usage; classifies ordinary unit, ASP.NET Core functional, and console/worker functional tests; modernizes xUnit v2 projects to xUnit v3 plus Microsoft Testing Platform without moving package ownership or changing frameworks; and resolves current stable compatible packages through NuGet-backed isolated compatibility-project restores, including the selected combined package set. Focused web tests use `WebApplicationTestFactory` with an explicit entrypoint-owned `ManagedWebApplicationFixture`, directly or through a narrow `Test`-derived harness; shared web fixtures use `WebApplicationTest` with `ManagedWebApplicationFixture`; focused console/worker tests use `ApplicationTestFactory` with `ManagedApplicationFixture`; and shared non-web fixtures use `ApplicationTest` with `ManagedApplicationFixture`. Deprecated blocking fixtures are migration inputs only and are never emitted because they are scheduled for removal. Functional migrations fail closed unless the chosen Codebelt pattern and managed fixture are present, the legacy or blocking fixture is absent, and test code does not reconstruct the production composition root with its own `WebApplication`, `TestServer`, or `HostBuilder`. Migrations preserve entrypoint-owned startup, host configuration, lazy start, clients, services, configuration, sync/async disposal, isolation, and existing test names, while fresh bootstraps add source-grounded behavior tests. Non-web tests stay in-process and require a resolvable Generic Host; test-only scope reports the exact production adaptation instead of silently rewriting startup or launching a process. |
 | [dotnet-benchmark](skills/dotnet-benchmark/SKILL.md) | Discovers, prioritizes, and authors trustworthy BenchmarkDotNet experiments for a .NET type following codebelt conventions and using the `Codebelt.Extensions.BenchmarkDotNet.Console` runner. It inspects implementation code, call sites, tests, existing benchmarks, and available profiles instead of benchmarking every public member; ranks likely high-impact operations; selects representative typical, boundary, scaling, and adverse cases; and rejects external-I/O or service-level questions that need profiling, macrobenchmarks, or load tests. It creates fair current-versus-candidate comparisons only when observable work is equivalent, uses baseline-free single-operation characterization when no honest comparator exists, prevents unrelated construction/formatting/equality/hash ratios, requires exact per-case correctness oracles plus a semantic preflight for truthful workload labels, hard-gates interpretation on a complete valid BenchmarkDotNet summary, preserves workload invariants such as selectivity and hit/miss ratios as sizes scale, distinguishes deferred pipeline creation from terminal/materialization work, and performs Release build, discovery listing, and dry execution before any explicit full run. Explicit `yolo` mode auto-accepts routine repo-derived defaults and the proposed plan, then proceeds through build/list/dry validation without confirmation churn; only a separate explicit human instruction can start a full performance run. Its runner preflight recognizes the standard Slim/runtime setup and explains when `SkipBenchmarksWithReports = true` plus a matching `reports/tuning/` artifact deliberately filters a benchmark, preventing needless class renames, disassembly, or tool thrash; after the first valid full result it stops unless deeper diagnostics could change a real engineering decision. Harness setup remains adaptive: it detects `.slnx`/`.sln`, CPM, existing `tuning/` projects, and a reusable `tooling/` runner, onboards only missing pieces, resolves package versions dynamically, and keeps the benchmark class in the SUT namespace. |
-| [agent-smith](skills/agent-smith/SKILL.md) | Apply a rigorous, consistent, evidence-driven software-craftsmanship standard across a whole engineering task. Invoke explicitly as `/agent-smith ` or let it auto-trigger for design, architecture, implementation, refactoring, code review, public API review, compatibility and Semantic Versioning analysis, testing, benchmarking, performance, skill authoring, documentation, security and DevSecOps, CI/CD, delivery, repository governance, and engineering assessment. Skill-authoring mode grounds instructions in real execution, requires an explicit bounded-concurrency assessment so independent data retrieval and eval work do not remain sequential by habit, favors reusable C#/.NET scripts and validators against the dynamically resolved latest supported LTS when local constraints do not decide, and follows the Agent Skills guidance for progressive disclosure, description optimization, candidate-versus-baseline evaluation, aggregation, and human review. Its optional .NET EditorConfig conformance mode handles targeted IDE/CA diagnostic remediation and full informational-or-higher `dotnet format` conformance without treating a clean build as proof of policy compliance: user-defined diagnostic IDs remain task-supplied data; target, path, and severity scope remains authoritative; informational workflows explicitly preserve `--severity info` because the formatter defaults to `warn`; targeted IDE and analyzer checks use category-specific formatter subcommands; every formatter invocation is read-only via `--verify-no-changes`; `--no-restore` is never treated as a conformance fallback; fixes are deliberate source edits; repeated multi-target findings are de-duplicated by physical file, diagnostic, and span; and the bundled `repair-roslyn-multiproject-artifacts.ps1` detects conflict artifacts independently of diagnostic ID, preflights directory repairs without partial writes, repairs only proven structural patterns, and refuses unrecognized shapes. Completion requires the same scoped formatter gate plus an artifact scan before affected builds and relevant tests. Technology-neutral work remains unaffected. Performs the requested work (not just a review), loads only relevant `references/`, respects repository conventions, scales process depth without lowering the standard, and reports evidence and risk honestly in concise feedback that may sacrifice grammar but never required evidence. Governing principle: consistency is key. |
+| [dotnet-remote-testing](skills/dotnet-remote-testing/SKILL.md) | Run .NET tests inside a resolved remote Docker environment and return concise, structured results — Visual Studio's Remote Testing experience (choose an environment → run tests → see results) with the container plumbing hidden behind a deterministic runner (`scripts/remote-test.cs`) the skill orchestrates instead of composing ad-hoc `docker run` commands. It honors Microsoft's existing `testenvironments.json` version-1 contract (`name`, `localRoot`, `dockerImage`, `dockerFile`, with the either/or Docker-source rule), treats that file as authoritative when present, and reports WSL/SSH/unknown types as unsupported rather than converting or silently ignoring them. When no `testenvironments.json` exists it provides a zero-configuration experience built exclusively on official `mcr.microsoft.com/dotnet/sdk` images, discovering the currently supported LTS and STS channels plus the current preview from Microsoft's live `releases-index.json` using `support-phase`/`release-type` (never hardcoded version numbers or even/odd assumptions) and caching that metadata outside the repository for offline reuse. It prefers an exact `latest-sdk` image tag (stripping preview build metadata), validates the tag against Microsoft's registry, and pins each execution to the resolved immutable digest so results are reproducible across environment, image, digest, SDK, and architecture. Execution stages the source into an isolated workspace so container builds never leave Linux `bin`/`obj` in the working tree, mounts a persistent NuGet cache outside the repo, runs restore → build → test with structured TRX collection, classifies failures into distinct kinds (configuration, unsupported environment, Docker unavailable, image resolution, SDK incompatibility, staging, restore, compilation, test-host, test failure, result-processing, cleanup, cancellation, release-metadata) so infrastructure problems are never reported as failing unit tests, and always cleans up transient Docker resources. It never generates a `Dockerfile`, dev container, compose file, or editor configuration (an existing configured `dockerFile` is honored, never created), never runs privileged containers or mounts the Docker socket, and never silently falls back to running tests on the host. Docker is the only transport for now, designed so WSL/SSH can be added later without disturbing the deterministic Docker path, which is covered by a comprehensive built-in `--self-test` plus a PowerShell harness. |
+| [agent-smith](skills/agent-smith/SKILL.md) | Apply a rigorous, consistent, evidence-driven software-craftsmanship standard across a whole engineering task. Invoke explicitly as `/agent-smith ` or let it auto-trigger for design, architecture, implementation, refactoring, code review, public API review, compatibility and Semantic Versioning analysis, testing, benchmarking, performance, skill authoring, documentation, security and DevSecOps, CI/CD, delivery, repository governance, and engineering assessment. Skill-authoring mode grounds instructions in real execution, requires an explicit bounded-concurrency assessment so independent data retrieval and eval work do not remain sequential by habit, favors reusable C#/.NET scripts and validators against the dynamically resolved latest supported LTS when local constraints do not decide, and follows the Agent Skills guidance for progressive disclosure, description optimization, candidate-versus-baseline evaluation, aggregation, and human review. Its optional .NET EditorConfig conformance mode handles targeted IDE/CA diagnostic remediation and full informational-or-higher `dotnet format` conformance without treating a clean build as proof of policy compliance: user-defined diagnostic IDs remain task-supplied data; target, path, and severity scope remains authoritative; informational workflows explicitly preserve `--severity info` because the formatter defaults to `warn`; targeted IDE and analyzer checks use category-specific formatter subcommands; every formatter invocation is read-only via `--verify-no-changes`; `--no-restore` is never treated as a conformance fallback; fixes are deliberate source edits; repeated multi-target findings are de-duplicated by physical file, diagnostic, and span; and the bundled `repair-roslyn-multiproject-artifacts.ps1` detects conflict artifacts independently of diagnostic ID, preflights directory repairs without partial writes, repairs only proven structural patterns, and refuses unrecognized shapes. Completion requires the same scoped formatter gate plus an artifact scan before affected builds and relevant tests. Technology-neutral work remains unaffected. Performs the requested work (not just a review), loads only relevant `references/`, respects repository conventions, scales process depth without lowering the standard, and reports evidence and risk honestly in concise feedback that may sacrifice grammar but never required evidence. Governing principle: consistency is key. | Invoke explicitly as `/agent-smith ` or let it auto-trigger for design, architecture, implementation, refactoring, code review, public API review, compatibility and Semantic Versioning analysis, testing, benchmarking, performance, skill authoring, documentation, security and DevSecOps, CI/CD, delivery, repository governance, and engineering assessment. Skill-authoring mode grounds instructions in real execution, requires an explicit bounded-concurrency assessment so independent data retrieval and eval work do not remain sequential by habit, favors reusable C#/.NET scripts and validators against the dynamically resolved latest supported LTS when local constraints do not decide, and follows the Agent Skills guidance for progressive disclosure, description optimization, candidate-versus-baseline evaluation, aggregation, and human review. Its optional .NET EditorConfig conformance mode handles targeted IDE/CA diagnostic remediation and full informational-or-higher `dotnet format` conformance without treating a clean build as proof of policy compliance: user-defined diagnostic IDs remain task-supplied data; target, path, and severity scope remains authoritative; informational workflows explicitly preserve `--severity info` because the formatter defaults to `warn`; targeted IDE and analyzer checks use category-specific formatter subcommands; every formatter invocation is read-only via `--verify-no-changes`; `--no-restore` is never treated as a conformance fallback; fixes are deliberate source edits; repeated multi-target findings are de-duplicated by physical file, diagnostic, and span; and the bundled `repair-roslyn-multiproject-artifacts.ps1` detects conflict artifacts independently of diagnostic ID, preflights directory repairs without partial writes, repairs only proven structural patterns, and refuses unrecognized shapes. Completion requires the same scoped formatter gate plus an artifact scan before affected builds and relevant tests. Technology-neutral work remains unaffected. Performs the requested work (not just a review), loads only relevant `references/`, respects repository conventions, scales process depth without lowering the standard, and reports evidence and risk honestly in concise feedback that may sacrifice grammar but never required evidence. Governing principle: consistency is key. |
 
 ### Copyable Install Commands
 
@@ -230,6 +240,11 @@ npx skills add https://github.com/codebeltnet/agentic --skill dotnet-benchmark
 ```bash
 npx skills add https://github.com/codebeltnet/agentic --skill dotnet-test
 ```
+`dotnet-remote-testing`
+
+```bash
+npx skills add https://github.com/codebeltnet/agentic --skill dotnet-remote-testing
+```
 `agent-smith`
 
 ```bash
@@ -658,6 +673,20 @@ Setting up a benchmark "properly" is only half the problem. A benchmark can comp
 - **Proportional escalation** — after the first valid full result, it asks whether the issue is reproducible, material, and likely to change a real engineering decision before suggesting disassembly, EventPipe/ETW, repeated reruns, or alternative implementations
 - **Honest Slim reporting** — reports the active `BenchmarkWorkspaceOptions.Slim` job accurately, including when its one-warmup developer-oriented shape limits runtime- or JIT-sensitive conclusions, instead of silently swapping the runner configuration
 - **Report-aware runner preflight** — inspects the canonical `BenchmarkWorkspaceOptions.Slim` runtime jobs, `SkipBenchmarksWithReports`, and matching `reports/tuning/` artifacts before touching benchmark code, so an intentional existing-report skip is explained instead of triggering disassembly, renaming, or speculative rewrites
+### Why dotnet-remote-testing?
+
+Cross-platform .NET developers usually get Linux test feedback the slow way: push to CI and wait. Visual Studio's experimental Remote Testing promised something better — run those tests locally in a container — but left the bulk of provisioning to the developer, so "run my tests in .NET 10" too easily turns into writing Dockerfiles, wiring mounts, and debugging container plumbing. The right shape is: choose an environment, run tests, see results. Everything in between is infrastructure and belongs behind the abstraction.
+
+**dotnet-remote-testing** keeps that principle by splitting the work in two: the skill is the orchestration layer that understands intent and which environment the developer means, and the bundled deterministic runner (`scripts/remote-test.cs`) is the execution layer that resolves configuration, discovers releases, resolves images, stages source, caches packages, runs the tests, collects results, and cleans up. The AI never composes ad-hoc Docker commands, and Docker complexity is never exposed just because Docker is the current transport.
+
+- **Microsoft's contract, not a new one** — honors the existing `testenvironments.json` version-1 schema (`name`, `localRoot`, `dockerImage`, `dockerFile`, either/or Docker source), treats it as authoritative when present, and never modifies it unless asked
+- **Zero-configuration by default** — with no `testenvironments.json`, it derives environments from Microsoft's live `releases-index.json` (supported LTS/STS channels plus the current preview) using `support-phase`/`release-type`, so no files are added to the repo and `.NET 10`/`.NET 11` are never hardcoded
+- **Official images, pinned to a digest** — auto-generated environments use only `mcr.microsoft.com/dotnet/sdk`, prefer the exact `latest-sdk` tag (preview build metadata stripped), validate the tag against Microsoft's registry, and resolve an immutable digest so a run is reproducible across environment, image, digest, SDK, and architecture
+- **Tests run in Docker, the host stays clean** — source is staged into an isolated workspace so container builds never leave Linux `bin`/`obj` in the working tree, a persistent NuGet cache lives outside the repo, and it never silently falls back to running tests locally
+- **Honest failure classification** — configuration, unsupported environment, Docker-unavailable, image-resolution, SDK-incompatibility, staging, restore, compilation, test-host, test-failure, result-processing, cleanup, cancellation, and release-metadata failures are distinct, so a container problem is never reported as a failing unit test
+- **No plumbing added, ever** — never generates a `Dockerfile`, dev container, compose file, or editor configuration (an existing configured `dockerFile` is honored, never created), never runs privileged containers or mounts the Docker socket, and always cleans up transient Docker resources — reporting exact identifiers if any remain
+- **Target-framework aware** — inspects the projects and `global.json`, refuses to pick an SDK that cannot build the requested target framework, and reports incompatibilities instead of editing the repository to force them
+- **Deterministic and tested** — the runner ships a comprehensive built-in `--self-test` plus a PowerShell harness covering configuration discovery, release parsing, environment selection, unsupported handling, image resolution, command planning, result parsing, failure classification, cancellation, and cleanup
 ### Why agent-smith?
 
 **agent-smith** applies one coherent engineering standard — *consistency is key* — across a whole task instead of bolting a review onto the end. Invoke it explicitly as `/agent-smith `; it also auto-triggers for engineering work such as architecture, implementation, code review, public API and compatibility analysis, testing, performance, skill authoring, security and DevSecOps, CI/CD, delivery, and governance.

From a8cc51ec3b980eb2f0bd8122d027aa3c00a65760 Mon Sep 17 00:00:00 2001
From: "aicia[bot]" 
Date: Mon, 10 Aug 2026 01:11:27 +0200
Subject: [PATCH 5/8] =?UTF-8?q?=E2=9C=A8=20add=20offline=20cache=20support?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The remote test runner can now discover and use cached Microsoft release metadata when the network is unavailable or when offline mode is explicitly requested. Release metadata is cached outside the repo for offline reuse, and the form now exposes the computed project target as a recommended selectable option alongside a custom path entry. A new eval case validates offline discovery with a cached releases-index.cache.json fixture.
---
 skills/dotnet-remote-testing/FORMS.md         |  3 ++
 skills/dotnet-remote-testing/evals/evals.json | 14 +++++++
 .../cache/releases-index.cache.json           | 18 +++++++++
 .../scripts/remote-test.cs                    | 38 ++++++++++++++++++-
 .../scripts/validate-skill.ps1                |  8 ++++
 5 files changed, 80 insertions(+), 1 deletion(-)
 create mode 100644 skills/dotnet-remote-testing/evals/files/offline-cache/cache/releases-index.cache.json

diff --git a/skills/dotnet-remote-testing/FORMS.md b/skills/dotnet-remote-testing/FORMS.md
index e8e7213..8fea89d 100644
--- a/skills/dotnet-remote-testing/FORMS.md
+++ b/skills/dotnet-remote-testing/FORMS.md
@@ -27,6 +27,9 @@ Collect only the fields that are still unresolved after inspecting the request a
 
 - **type:** text
 - **prompt:** Which project or solution should be tested (path relative to the source root)?
+- **choices:**
+  - The exact target returned by `remote-test.cs plan` (Recommended)
+  - A custom project or solution path
 - **default:** Auto-resolved (root solution, single solution, or single project)
 - **required:** false
 - **show_when:** `test_scope` is `A specific project`
diff --git a/skills/dotnet-remote-testing/evals/evals.json b/skills/dotnet-remote-testing/evals/evals.json
index 55f3ae7..9302d53 100644
--- a/skills/dotnet-remote-testing/evals/evals.json
+++ b/skills/dotnet-remote-testing/evals/evals.json
@@ -89,6 +89,20 @@
         "evals/files/zero-config/test/Sample.Tests/Sample.Tests.csproj",
         "evals/files/zero-config/test/Sample.Tests/CalculatorTests.cs"
       ]
+    },
+    {
+      "id": 6,
+      "prompt": "This repo has no testenvironments.json and I am offline. List the remote test environments using cache/releases-index.cache.json with --offline and --cache-root cache.",
+      "expected_output": "The skill invokes the bundled runner in offline mode with the supplied cache root, parses the cached Microsoft release metadata, and lists the generated official SDK environments instead of reporting that discovery produced no environments.",
+      "expectations": [
+        "Recognizes that an absent testenvironments.json is still the zero-configuration path",
+        "Invokes scripts/remote-test.cs list with --offline and --cache-root cache",
+        "Parses the cached releasesIndex array and returns the generated environments from it",
+        "Does not attempt a network fetch, invent release metadata, or add container plumbing"
+      ],
+      "files": [
+        "evals/files/offline-cache/cache/releases-index.cache.json"
+      ]
     }
   ]
 }
diff --git a/skills/dotnet-remote-testing/evals/files/offline-cache/cache/releases-index.cache.json b/skills/dotnet-remote-testing/evals/files/offline-cache/cache/releases-index.cache.json
new file mode 100644
index 0000000..d89c223
--- /dev/null
+++ b/skills/dotnet-remote-testing/evals/files/offline-cache/cache/releases-index.cache.json
@@ -0,0 +1,18 @@
+{
+  "retrievedAt": "2026-08-10T00:00:00.0000000+00:00",
+  "source": "https://raw.githubusercontent.com/dotnet/core/refs/heads/main/release-notes/releases-index.json",
+  "releasesIndex": [
+    {
+      "channel-version": "11.0",
+      "latest-sdk": "11.0.100-preview.6.26359.118",
+      "support-phase": "preview",
+      "release-type": "sts"
+    },
+    {
+      "channel-version": "10.0",
+      "latest-sdk": "10.0.302",
+      "support-phase": "active",
+      "release-type": "lts"
+    }
+  ]
+}
diff --git a/skills/dotnet-remote-testing/scripts/remote-test.cs b/skills/dotnet-remote-testing/scripts/remote-test.cs
index ccc424d..c6a4154 100644
--- a/skills/dotnet-remote-testing/scripts/remote-test.cs
+++ b/skills/dotnet-remote-testing/scripts/remote-test.cs
@@ -1600,7 +1600,15 @@ private static ReleaseMetadataResult LoadFromCache(string cacheFile, string pref
             var retrievedAt = root.TryGetProperty("retrievedAt", out var r) && r.ValueKind == JsonValueKind.String
                 ? DateTimeOffset.Parse(r.GetString()!, CultureInfo.InvariantCulture)
                 : File.GetLastWriteTimeUtc(cacheFile);
-            var rawJson = root.GetProperty("releasesIndex").GetRawText();
+            var releasesIndex = root.GetProperty("releasesIndex");
+            if (releasesIndex.ValueKind != JsonValueKind.Array)
+            {
+                throw new JsonException("Cached releasesIndex must be an array.");
+            }
+
+            // The cache stores only the releases-index array, while ReleaseIndexReader consumes the
+            // original object-shaped releases-index.json contract.
+            var rawJson = $$"""{"releases-index":{{releasesIndex.GetRawText()}}}""";
             var channels = ReleaseIndexReader.Parse(rawJson);
             return new ReleaseMetadataResult(new ReleaseMetadata
             {
@@ -2648,6 +2656,34 @@ private static void ReleaseMetadataTests()
             ["dotnet-10-lts", "dotnet-9-sts", "dotnet-8-lts", "dotnet-11-preview"]));
         Check("generated images use mcr sdk repo", generated.All(e => e.DockerImage!.StartsWith("mcr.microsoft.com/dotnet/sdk:")));
         Check("preview env labeled Preview", generated.First(e => e.Name == "dotnet-11-preview").ReleaseType == "Preview");
+
+        var cacheRoot = Path.Combine(Path.GetTempPath(), "dotnet-remote-testing-self-test-" + Guid.NewGuid().ToString("N"));
+        try
+        {
+            Directory.CreateDirectory(cacheRoot);
+            using var indexDocument = JsonDocument.Parse(SampleReleaseIndex);
+            var cachePayload = new
+            {
+                retrievedAt = "2026-08-10T00:00:00.0000000+00:00",
+                source = RemoteTestProgram.ReleasesIndexUrl,
+                releasesIndex = indexDocument.RootElement.GetProperty("releases-index"),
+            };
+            File.WriteAllText(
+                Path.Combine(cacheRoot, "releases-index.cache.json"),
+                JsonSerializer.Serialize(cachePayload, RemoteTestProgram.JsonOut));
+
+            var cached = ReleaseMetadataStore.LoadAsync(
+                Options.Parse(["list", "--offline", "--cache-root", cacheRoot]),
+                CancellationToken.None).GetAwaiter().GetResult();
+            Check("cached release array is parsed as release metadata",
+                cached.Error is null && cached.Metadata is not null && cached.Metadata.IsStale && cached.Metadata.Channels.Count == 6);
+        }
+        finally
+        {
+            try { Directory.Delete(cacheRoot, recursive: true); }
+            catch (IOException) { }
+            catch (UnauthorizedAccessException) { }
+        }
     }
 
     private static void SdkAndImageTagTests()
diff --git a/skills/dotnet-remote-testing/scripts/validate-skill.ps1 b/skills/dotnet-remote-testing/scripts/validate-skill.ps1
index a0e6d46..d5ccb43 100644
--- a/skills/dotnet-remote-testing/scripts/validate-skill.ps1
+++ b/skills/dotnet-remote-testing/scripts/validate-skill.ps1
@@ -40,6 +40,14 @@ if (-not $skill.Contains('orchestration', [System.StringComparison]::Ordinal)) {
     throw 'SKILL.md must describe the skill as the orchestration layer over a deterministic runner.'
 }
 
+$forms = [System.IO.File]::ReadAllText((Join-Path $skillRoot 'FORMS.md'))
+$projectField = [regex]::Match($forms, '(?ms)^### project\s*(?.*?)(?=^### |\z)')
+if (-not $projectField.Success -or
+    -not $projectField.Groups['body'].Value.Contains('- **choices:**', [System.StringComparison]::Ordinal) -or
+    -not $projectField.Groups['body'].Value.Contains('exact target returned by `remote-test.cs plan`', [System.StringComparison]::Ordinal)) {
+    throw 'FORMS.md must expose the computed project target as a selectable choice.'
+}
+
 & pwsh -NoProfile -File (Join-Path $PSScriptRoot 'test-remote-testing.ps1')
 if ($LASTEXITCODE -ne 0) {
     throw "Runner test harness failed with exit code $LASTEXITCODE."

From 159ab4ee8d78cbbc3ba15f283ae6e56a8588da29 Mon Sep 17 00:00:00 2001
From: "aicia[bot]" 
Date: Mon, 10 Aug 2026 01:11:39 +0200
Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=94=A8=20add=20unix=20dotnet=20shim?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The benchmark runner now generates a POSIX shell script shim alongside the Windows batch file shim to support cross-platform dotnet execution. The Unix shim is marked executable on non-Windows platforms, and the test harness validates both the script's delegation to the logging wrapper and its executable bit on Unix systems.
---
 scripts/run-skill-benchmark.ps1      | 19 +++++++++++++++++--
 scripts/test-run-skill-benchmark.ps1 | 21 +++++++++++++++++++++
 2 files changed, 38 insertions(+), 2 deletions(-)

diff --git a/scripts/run-skill-benchmark.ps1 b/scripts/run-skill-benchmark.ps1
index cbc265e..d212083 100644
--- a/scripts/run-skill-benchmark.ps1
+++ b/scripts/run-skill-benchmark.ps1
@@ -159,11 +159,11 @@ function Initialize-DotnetShim {
         [Parameter(Mandatory = $true)] [string]$RealDotnet
     )
 
-    $shimRoot = Join-Path $Workspace '.benchmark\dotnet'
+    $shimRoot = Join-Path (Join-Path $Workspace '.benchmark') 'dotnet'
     New-Item -ItemType Directory -Path $shimRoot -Force | Out-Null
 
     $shimCommandPath = Join-Path $shimRoot 'dotnet.cmd'
-    $shimScriptPath = Join-Path $PSScriptRoot 'skill-benchmark\log-dotnet.ps1'
+    $shimScriptPath = Join-Path (Join-Path $PSScriptRoot 'skill-benchmark') 'log-dotnet.ps1'
     $shimContent = @"
 @echo off
 pwsh -NoProfile -File "$shimScriptPath" -RealDotnet "%SKILL_BENCHMARK_DOTNET_REAL%" -LogDirectory "%SKILL_BENCHMARK_DOTNET_LOG_DIR%" -StdoutPath "%SKILL_BENCHMARK_DOTNET_STDOUT%" -StderrPath "%SKILL_BENCHMARK_DOTNET_STDERR%" %*
@@ -171,6 +171,21 @@ exit /b %ERRORLEVEL%
 "@
     Write-TextFile -Path $shimCommandPath -Content $shimContent
 
+    $unixShimPath = Join-Path $shimRoot 'dotnet'
+    $posixShimScriptPath = "'" + $shimScriptPath.Replace("'", "'\''", [System.StringComparison]::Ordinal) + "'"
+    $unixShimContent = @'
+#!/usr/bin/env sh
+exec pwsh -NoProfile -File __SHIM_SCRIPT_PATH__ -RealDotnet "$SKILL_BENCHMARK_DOTNET_REAL" -LogDirectory "$SKILL_BENCHMARK_DOTNET_LOG_DIR" -StdoutPath "$SKILL_BENCHMARK_DOTNET_STDOUT" -StderrPath "$SKILL_BENCHMARK_DOTNET_STDERR" "$@"
+'@.Replace('__SHIM_SCRIPT_PATH__', $posixShimScriptPath).Replace("`r`n", "`n")
+    Write-TextFile -Path $unixShimPath -Content $unixShimContent
+
+    if (-not [System.OperatingSystem]::IsWindows()) {
+        & chmod +x -- $unixShimPath
+        if ($LASTEXITCODE -ne 0) {
+            throw "Unable to mark the Unix dotnet shim executable: $unixShimPath"
+        }
+    }
+
     return $shimRoot
 }
 
diff --git a/scripts/test-run-skill-benchmark.ps1 b/scripts/test-run-skill-benchmark.ps1
index cef883e..b6dc8af 100644
--- a/scripts/test-run-skill-benchmark.ps1
+++ b/scripts/test-run-skill-benchmark.ps1
@@ -96,6 +96,27 @@ Answer mock tasks.
         throw "Expected zero cleanup failures, found $($summary.cleanupFailures)."
     }
 
+    $benchmarkRoot = Join-Path $workspace 'benchmark'
+    $shimRoot = Join-Path (Join-Path $benchmarkRoot '.benchmark') 'dotnet'
+    $windowsShimPath = Join-Path $shimRoot 'dotnet.cmd'
+    $unixShimPath = Join-Path $shimRoot 'dotnet'
+    if (-not (Test-Path -LiteralPath $windowsShimPath -PathType Leaf)) {
+        throw "Missing Windows dotnet shim: $windowsShimPath"
+    }
+    if (-not (Test-Path -LiteralPath $unixShimPath -PathType Leaf)) {
+        throw "Missing Unix dotnet shim: $unixShimPath"
+    }
+    $unixShim = Get-Content -LiteralPath $unixShimPath -Raw
+    if ($unixShim -notmatch '(?m)^#!/usr/bin/env sh\s*$' -or $unixShim -notmatch 'exec pwsh -NoProfile -File') {
+        throw 'Unix dotnet shim does not delegate to the logging wrapper.'
+    }
+    if (-not $IsWindows) {
+        $unixMode = (Get-Item -LiteralPath $unixShimPath).UnixFileMode
+        if (($unixMode -band [System.IO.UnixFileMode]::UserExecute) -eq 0) {
+            throw 'Unix dotnet shim is not executable.'
+        }
+    }
+
     $timeoutRunRoot = Join-Path $workspace 'benchmark\iteration-optimized\eval-02-mock-timeout-prompt\with_skill\run-1'
     $childPidPath = Join-Path $timeoutRunRoot 'outputs\child.pid'
     if (-not (Test-Path -LiteralPath $childPidPath)) {

From 41e85c5b416ee99c373a0c89c07a23c80cb1ba62 Mon Sep 17 00:00:00 2001
From: "aicia[bot]" 
Date: Mon, 10 Aug 2026 01:11:50 +0200
Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=92=AC=20document=20offline=20cache?=
 =?UTF-8?q?=20discovery?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Updated the dotnet-remote-testing feature description in README.md to highlight offline-safe discovery: successful release metadata is cached outside the repo for offline reuse, and the form now exposes the exact runner-computed target as a recommended option.
---
 README.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/README.md b/README.md
index 7f8c35d..51e7384 100644
--- a/README.md
+++ b/README.md
@@ -681,6 +681,7 @@ Cross-platform .NET developers usually get Linux test feedback the slow way: pus
 
 - **Microsoft's contract, not a new one** — honors the existing `testenvironments.json` version-1 schema (`name`, `localRoot`, `dockerImage`, `dockerFile`, either/or Docker source), treats it as authoritative when present, and never modifies it unless asked
 - **Zero-configuration by default** — with no `testenvironments.json`, it derives environments from Microsoft's live `releases-index.json` (supported LTS/STS channels plus the current preview) using `support-phase`/`release-type`, so no files are added to the repo and `.NET 10`/`.NET 11` are never hardcoded
+- **Offline-safe discovery and explicit scoping** — successful release metadata is cached outside the repo for offline reuse, and when a project choice is needed the form exposes the exact runner-computed target as the recommended option alongside a custom path
 - **Official images, pinned to a digest** — auto-generated environments use only `mcr.microsoft.com/dotnet/sdk`, prefer the exact `latest-sdk` tag (preview build metadata stripped), validate the tag against Microsoft's registry, and resolve an immutable digest so a run is reproducible across environment, image, digest, SDK, and architecture
 - **Tests run in Docker, the host stays clean** — source is staged into an isolated workspace so container builds never leave Linux `bin`/`obj` in the working tree, a persistent NuGet cache lives outside the repo, and it never silently falls back to running tests locally
 - **Honest failure classification** — configuration, unsupported environment, Docker-unavailable, image-resolution, SDK-incompatibility, staging, restore, compilation, test-host, test-failure, result-processing, cleanup, cancellation, and release-metadata failures are distinct, so a container problem is never reported as a failing unit test

From cb33e1ef4e194dd3a8ee5be0953995060399de3c Mon Sep 17 00:00:00 2001
From: "aicia[bot]" 
Date: Mon, 10 Aug 2026 01:34:10 +0200
Subject: [PATCH 8/8] updated changelog

---
 CHANGELOG.md | 20 +++++++++++++++-----
 1 file changed, 15 insertions(+), 5 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6dae437..72ed47d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,9 +4,9 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
-## [0.9.0] - 2026-08-09
+## [0.9.0] - 2026-08-10
 
-This is a minor release introducing the `dotnet-test` skill for lifecycle-aware xUnit test migration and refactoring across ordinary unit tests, ASP.NET Core functional tests, and console/worker service tests. The release includes comprehensive test-role classification, managed-fixture patterns preserving composition-root control, xUnit v3 modernization guidance, deterministic package-version resolution, and integration-test bootstrapper hosts. Enhanced release-entity classification in `git-keep-a-changelog` and `git-nuget-release-notes` now distinguishes new-capability introductions from pre-existing refinements, preventing mis-categorized changelog entries when unreleased features are refined before first release. Repository validation tooling is strengthened with skill-content validation and resolver script enforcement.
+This is a minor release introducing the `dotnet-test` skill for lifecycle-aware xUnit test migration and the `dotnet-remote-testing` skill for deterministic remote testing in Docker containers, alongside foundational skill-benchmarking infrastructure with caching and workspace management. The release includes comprehensive test-role classification and managed-fixture patterns for `dotnet-test`, offline-safe release discovery for remote testing, deterministic caching and structured tracing in the `dotnet-test` resolver, and cross-platform execution support. Enhanced release-entity classification in `git-keep-a-changelog` and `git-nuget-release-notes` now distinguishes new-capability introductions from pre-existing refinements, preventing mis-categorized changelog entries when unreleased features are refined before first release. Repository validation tooling is strengthened with skill-content validation and resolver script enforcement.
 
 ### Added
 
@@ -19,7 +19,16 @@ This is a minor release introducing the `dotnet-test` skill for lifecycle-aware
 - `dotnet-test` package-compatibility resolver script `resolve-test-package-versions.ps1` validating combined package restore across selected NuGet candidates for multiple target frameworks, preventing incompatible package combinations in managed-fixture test projects,
 - Test coverage for `dotnet-test` package-compatibility resolver via `test-resolve-test-package-versions.ps1` validating resolver behavior, compatibility detection, and framework coverage,
 - `resolve-release-entity.ps1` script for `git-keep-a-changelog` enabling deterministic classification of base-to-HEAD change outcomes (`Added`, `Removed`, `Changed`, or `Unchanged`) at release boundaries, supporting per-entity classification separate from intermediate commit verbs,
-- Test coverage for `git-keep-a-changelog` release-entity classification via `test-resolve-release-entity.ps1` validating classification outcomes and boundary handling.
+- Test coverage for `git-keep-a-changelog` release-entity classification via `test-resolve-release-entity.ps1` validating classification outcomes and boundary handling,
+- `dotnet-remote-testing` skill enabling deterministic remote testing of .NET projects in Docker containers using Microsoft's official SDK images, with support for `testenvironments.json` configuration or zero-config discovery from official release metadata,
+- Docker-based remote test orchestration infrastructure including container setup, NuGet cache management, test execution, and result parsing transparently behind a reusable runner script,
+- Offline-safe release metadata caching for remote testing: successful release metadata is cached outside the repository for offline reuse, and the form exposes the exact runner-computed target as a recommended option,
+- Cross-platform dotnet execution support in the benchmark runner including a POSIX shell script shim alongside Windows batch files for non-Windows platforms,
+- `run-skill-benchmark.ps1` as the preferred local entry point for skill benchmarking, managing a single persistent temp workspace, sharing benchmark-scoped caches, staging fixtures once, enforcing bounded parallelism and per-run timeouts, and prewarming expensive resolver work,
+- Comprehensive `dotnet-remote-testing` skill documentation with step-by-step workflow guidance covering container selection, test environment configuration, offline discovery, and result parsing,
+- Structured test-environment configuration via `testenvironments.json` support with configuration templates and validation for multiple target frameworks,
+- `dotnet-remote-testing` eval scenarios covering zero-config discovery, configured environments, offline cache behavior, and unsupported-environment handling,
+- Reference documentation for `dotnet-remote-testing` including Docker execution details, release discovery mechanics, and `testenvironments.json` schema and examples.
 
 ### Changed
 
@@ -29,8 +38,9 @@ This is a minor release introducing the `dotnet-test` skill for lifecycle-aware
 - Improved `git-keep-a-changelog` bad-output-characteristics section with explicit warnings about placing pre-release refinements under `Changed` or `Fixed` instead of preserving them under the initial `Added` outcome,
 - Enhanced `git-nuget-release-notes` SKILL.md with improved release-entity classification guidance aligned with `git-keep-a-changelog` enhancements, including per-package classification and cumulative-package-set reduction patterns,
 - Updated repository validation to enforce `resolve-release-entity.ps1` presence in git-keep-a-changelog and validate adoption of entity-classification patterns in release-notes skills,
-- README.md skill inventory and descriptions updated to reflect `dotnet-test` capability, enhanced release-entity classification in `git-keep-a-changelog` and `git-nuget-release-notes`, and improved validation tooling,
-- Enhanced `scripts/validate-skill-templates.ps1` with deterministic skill-content validation, release-entity classifier enforcement, git-keep-a-changelog trigger validation, and resolver-script presence checks.
+- README.md skill inventory and descriptions updated to reflect `dotnet-test` and `dotnet-remote-testing` capabilities, enhanced release-entity classification in `git-keep-a-changelog` and `git-nuget-release-notes`, and improved validation tooling,
+- Enhanced `scripts/validate-skill-templates.ps1` with deterministic skill-content validation, release-entity classifier enforcement, git-keep-a-changelog trigger validation, and resolver-script presence checks,
+- Enhanced README.md documentation of the benchmark runner as the preferred local workflow, explaining structured result parsing and failure classification.
 
 ## [0.8.2] - 2026-08-07