Repository context. Gather first
Collect these with individual Bash calls, one command per call, never combined into a single invocation:
- Working tree status (empty = clean),
git status --porcelain | head -20 - Current branch,
git branch --show-current
The pipe is the bound and belongs in the command. A read-time cap ("read only the first 20 entries") bounds nothing: the Bash tool returns the command's complete output into context before there is anything to decide about.
Treat a failure (not a repository, git unavailable) as an unknown value and carry on. Keep these as separate body Bash calls rather than pre-compute lines: the harness runs a skill's whole pre-compute block as one shell invocation, and a worktree-isolated session refuses a compound command that contains git.
Purpose
Detects affected ecosystems from changed files and runs each one's build → test → lint. Serves two roles:
- Task skill.
/toolchain:checkruns build verification for changed files./toolchain:check dotnettargets one ecosystem - Reference skill. Its sibling
/toolchain:lint, theverificationplugin's/verification:confirm(when installed), and verification agents compose this for detection and command resolution instead of baking their own tables
The command surface is resolved, not hardcoded. Both /toolchain:check and /toolchain:lint resolve each ecosystem's build/test/lint commands through the shared four-rung ladder in ${CLAUDE_PLUGIN_ROOT}/reference/resolution-ladder.md: the consuming repo's tracked .claude/ecosystems/<ecosystem>.yaml is authoritative when present; the plugin's bundled portable defaults at ${CLAUDE_PLUGIN_ROOT}/reference/ecosystems/ are the rung-4 fallback. The consumer's file always wins.
Arguments
$ARGUMENTS, optional ecosystem filter. If provided, run only that ecosystem. If omitted, auto-detect from changed files.
Available ecosystem filters are the ecosystems /toolchain:check covers: dotnet, python, typescript, bash, powershell, markdown, go (resolved per the ladder). Common aliases: ts/node → typescript, shell → bash, ps/pwsh → powershell, md → markdown, golang → go. Literal all runs every covered ecosystem. The lint-only yaml and cross-cutting surfaces are not run by /toolchain:check. Use /toolchain:lint for those.
Ecosystem detection
Each ecosystem declares a list of globs that classify changed files into that ecosystem (resolved per the ladder. Consumer .claude/ecosystems/<ecosystem>.yaml when present, else the bundled default). The skill matches git status --porcelain output against each covered ecosystem's globs to determine which ecosystems are affected. /toolchain:check covers dotnet, python, typescript, bash, powershell, markdown, go; the lint-only yaml and cross-cutting surfaces are /toolchain:lint's (in particular cross-cutting's ** glob is never matched here).
For ecosystem-specific gotchas, reference files, and primary-source detail, read the corresponding context file:
- context/dotnet.md. .NET build, test, format
- context/sarif.md. Roslyn SARIF output, jq parser patterns, AI consumption
- context/python.md. Python lint, format, test
- context/typescript.md. TypeScript compile, test, lint
- context/bash.md. ShellCheck, shfmt
- context/powershell.md. PSScriptAnalyzer
- context/go.md. Go build, test, lint, module discovery
When invoked as a task (/toolchain:check), detect from git status --porcelain. When referenced by another skill, use the file list that skill provides.
Workflow (when invoked as /toolchain:check)
0. Resolve repo root
REPO_ROOT=$(git rev-parse --show-toplevel)
All commands use absolute paths. Never cd and lose context.
1. Detect ecosystems
If $ARGUMENTS specifies an ecosystem, use it. If all, run every covered ecosystem. Otherwise, classify changed files from git status --porcelain against each covered ecosystem's globs (resolved per the ladder; /toolchain:check covers dotnet, python, typescript, bash, powershell, markdown, go). Skip any ecosystem whose resolved enabled is false (a consumer opt-out). Excluded even under all.
If the working tree is clean, fall back to the branch diff so checkpoint-committed work still gets classified (the common pre-PR case: every green block was already committed). Resolve the default branch by detection, not assumption, never a hardcoded main/master, and assign it before use:
REMOTE="" DEFAULT_BRANCH=""
TRACKED=$(git config "branch.$(git branch --show-current | tr -d '\r').remote" 2>/dev/null | tr -d '\r')
[[ "$TRACKED" == "." ]] && TRACKED=""
CANDIDATES=$( { [[ -n "$TRACKED" ]] && echo "$TRACKED"; git remote | grep -qx origin && echo origin; git remote; } | awk 'NF && !seen[\$0]++' )
while IFS= read -r CANDIDATE; do
BRANCH=$(git symbolic-ref --short "refs/remotes/$CANDIDATE/HEAD" 2>/dev/null)
BRANCH=${BRANCH#"$CANDIDATE/"}
BRANCH=${BRANCH:-$(git ls-remote --symref --end-of-options "$CANDIDATE" HEAD 2>/dev/null | awk '/^ref:/{sub(/refs\/heads\//,"",\$2); print \$2; exit}')}
if [[ -n "$BRANCH" ]] && git rev-parse --verify --quiet "refs/remotes/$CANDIDATE/$BRANCH" >/dev/null; then
REMOTE=$CANDIDATE DEFAULT_BRANCH=$BRANCH
break
fi
done <<< "$CANDIDATES"
if [[ -n "$REMOTE" ]]; then
git diff --name-only "$(git merge-base "refs/remotes/$REMOTE/$DEFAULT_BRANCH" HEAD)..HEAD"
else
echo "branch diff unavailable (could not detect default branch)"
fi
The loop probes candidate remotes in priority order, the remote the current branch tracks (branch.<name>.remote) first, then origin if present, then the rest, and selects the first one whose default branch resolves to a locally available tracking ref, never a hardcoded remote name. This handles an unpushed feature branch (no tracking remote) in a repo cloned with a different remote name (e.g. git clone -o vendor), and skips a remote that was added but never fetched (its default branch has no local refs/remotes/<remote>/<branch> to diff against) in favor of a later remote that does, the candidate is accepted only when git rev-parse confirms the tracking ref exists locally. Each candidate's default branch comes from that remote's own HEAD (not the current branch's upstream, which on a pushed feature branch points at the feature branch itself and would make merge-base equal HEAD, yielding an empty diff), falling back to a git ls-remote --symref query when the local HEAD symref is absent. merge-base is taken against the fully-qualified remote-tracking ref refs/remotes/$REMOTE/$DEFAULT_BRANCH, which resolves without a local branch of that name and, like the rev-parse verify. Cannot be misparsed as an option when the remote name begins with a dash (git clone --origin=-x); the git ls-remote probe terminates option parsing with --end-of-options for the same reason. If no candidate yields a locally available default branch, skip the branch-diff path rather than guessing. A caller passing an explicit changed-file list (e.g. /verification:confirm) overrides both detection paths.
If neither path yields changes and no $ARGUMENTS: report "No changes found (working tree clean, no branch diff vs the default branch). Use /toolchain:check all to verify the full repo, or /toolchain:check <ecosystem> for a specific ecosystem." and exit.
Conversation-aware targeting: when the conversation has been working with specific files/projects, scope the build to what was touched. Don't rebuild the whole scope for a single-project change. The ecosystem config's anchor field provides the default scoping anchor for ecosystems with a canonical entry point; substitute a narrower project file when changes are confined to one project. For .NET specifically: use the specific .csproj when changes are in one project, use the solution file when changes span multiple projects or touch shared files (.props, .targets, solution file).
1.5 Resolve each ecosystem's command surface
For each affected ecosystem, resolve its command surface (globs, build-cmd, test-cmd, check-cmd, fix-cmd, code-fix-cmd, anchor, project-discovery, install-hint, gates, notes) through the four-rung ladder in ${CLAUDE_PLUGIN_ROOT}/reference/resolution-ladder.md:
- Consumer
.claude/ecosystems/<ecosystem>.yaml(+.local.yamloverlay,~/.claude/ecosystems/user-global, additive per key) → authoritative. - Absent → infer from the repo's build files and offer to persist via
/toolchain:setup. - Cannot infer → ask; offer to persist.
- Otherwise → the bundled default at
${CLAUDE_PLUGIN_ROOT}/reference/ecosystems/<ecosystem>.yaml.
A malformed consumer file warns and degrades to rung 2, never a hard stop.
2. Run checks
For each affected ecosystem, use the resolved build-cmd, test-cmd, and check-cmd. Null commands are skipped (no build step / no test framework / no lint).
Substitute placeholders from the ecosystem config:
<solution-or-project-file>← resolved per the ecosystem'sanchordescription<project-dir>← walked per-project root (driven byproject-discoverypatterns)<files>← the changed-files list for that ecosystem
Run build → test → lint in order per ecosystem. Stop that ecosystem on first failure but continue to next ecosystem.
Tool presence: before each ecosystem runs, verify the tool is on PATH. If missing, report skip with the ecosystem's install-hint from the ecosystem config, never report FAIL for a missing tool. What "the tool" means here is the one the ecosystem's commands are invoked through (python's uv, not the ruff and pyright behind it), the probe is per ecosystem, not per sub-tool. A sub-tool bundled inside an opaque compound check-cmd is not probed and cannot be: its absence is discoverable only at execution time, where it surfaces as a real non-zero exit and the ecosystem reports FAIL. Do not extend this rule into a per-sub-tool probe to convert that into a skip. That contradicts the atomicity rule below, and each affected ecosystem documents the consequence in its own context/<ecosystem>.md.
Opt-in gate (lint phase only): before running an ecosystem's check-cmd, evaluate its resolved opt-in condition (if present) against the repo. Build and test always run regardless of opt-in. Only the lint phase is gated, since compiling and testing don't depend on style configuration.
This binary gate applies cleanly when opt-in describes ONE condition governing the whole check-cmd (e.g. dotnet, python, go): unmet → report the ecosystem's Lint column as skip (opt-in unmet: <condition, one short phrase>), visible, not silently omitted, and do not run check-cmd. Met → run check-cmd normally.
When opt-in instead describes MULTIPLE independent per-tool conditions bundled into one opaque command string (e.g. bash's "shellcheck always applies to shell files; shfmt only when .editorconfig declares shell style", where check-cmd is shellcheck ... && shfmt -d <files>), this gate does NOT apply. check-cmd is a single opaque string (per the ecosystem-commands contract) with no way to run one sub-tool's portion without the other. Run check-cmd whole and report its real output; do not attempt a partial skip. The known atomicity limitation this leaves open is in Gotchas below.
An opt-in-unmet skip (single-condition case) counts toward the table's total ecosystem count but never toward the FAIL count, the same precedent as a missing-tool skip. This is ecosystem-generic (reads the resolved opt-in key), not dotnet-specific. It applies to every current and future single-condition opt-in-bearing ecosystem /toolchain:check covers. CI-parity gates (below) are unaffected. They run independent of check-cmd.
CI-parity gates (resolved gates array). After an affected ecosystem's build → test → lint, iterate its resolved gates array (§1.5. Bundled default or consumer file, per the ladder). Gates cover the CI-parity checks plain build / test / lint don't catch: lockfile drift, generated-artifact freshness, schema regeneration. For each gate:
- Fire condition.
trigger-globsnarrows a change-driven run. Under auto-detection (§1), run the gate only when ≥1 changed file matches, matched against the full changed-files set (not the ecosystem-scoped subset, a gate's trigger files need not classify into the ecosystem's ownglobs); no match → the gate does not fire. Iftrigger-globsis omitted, run whenever the ecosystem runs. - Explicit scope overrides the narrowing, when
$ARGUMENTSnames a scope (/toolchain:check allor/toolchain:check <ecosystem>), every gate of a selected ecosystem fires regardless oftrigger-globs. The user asked to verify that scope, not to narrow by what changed, and the ecosystem's ownbuild-cmd/test-cmd/check-cmdalready run in full there. Leaving gates change-narrowed would makecheck allon a clean tree, the exact command §1 tells the user to run for full-repo verification, pass a committed-but-untidygo.mod. This is also the only way to force a gate without manufacturing a matching change. - Reachability, a gate is subordinate to its ecosystem's run (per the ecosystem-commands schema:
trigger-globs"run the gate only when a changed file matches (matched against the full changed-file set); omit to run whenever the ecosystem runs"), sotrigger-globsnarrows within a run and never selects an ecosystem. Under auto-targeting the ecosystem must first be affected by its ownglobs(§1); a gate whosetrigger-globsalone match a changed file is reached via/toolchain:check <ecosystem>or/toolchain:check all. To make a cross-ecosystem trigger select its ecosystem under auto-targeting, add the trigger pattern to that ecosystem's ownglobs. - Independent of the build/test/lint short-circuit, a fired gate runs even when this ecosystem's build, test, or lint already failed and stopped (line above). Gates mirror CI checks that are independent of build success (a lockfile or
go mod tidygate is meaningful whether or not the build compiled), so a failed earlier phase never suppresses them. - Run
gate.cmd(an opaque shell string. Substitute the same placeholders as other commands:<files>, resolved anchor, etc.) with absolute paths. Execution location is governed by the resolvedrun-from(default"ecosystem"when the key is omitted):"ecosystem"runs from the same execution location the ecosystem's own build/test/lint use (§2 placeholders, and Gotchas' "Multiple projects in same ecosystem"), once per resolved<project-dir>for aproject-discoveryecosystem, from theanchor's directory for ananchorecosystem, and from$REPO_ROOTonly when neither is defined;"repo-root"forces a single run from$REPO_ROOTregardless of the ecosystem'sproject-discoveryoranchor. The"ecosystem"default matters for the bundledgo.yamlgo-mod-tidy-driftgate:go mod tidy -diffis inherently per-module, so aproject-discovery: ["go.mod"]monorepo must run it from eachgo.modroot, a$REPO_ROOT-only run falsely fails when the sole module is nested (go.mod file not found) and never checks drift in nested modules when a root module also exists.run-from: repo-rootexists for the opposite shape: a repo-wide gate (protobuf generation, schema freshness) declared under aproject-discoveryecosystem, which would otherwise inherit the per-project scope and run redundantly or fail in project roots lacking its config. Declarerun-from: repo-rooton that gate instead of moving it to an ecosystem withoutproject-discovery. The fire condition above stays repo-wide (trigger-globsvs the full changed-files set decides whether the gate runs) regardless ofrun-from; only the execution location changes. When a gatecmduses<files>under"ecosystem"scope, it expands to that project's scoped changed-files subset, exactly as for the ecosystem's other commands (§2); under"repo-root"scope it expands to the full changed-files set for that ecosystem, since there is no single project root to scope to.<project-dir>is not defined under"repo-root"scope, a single run has no one project root to bind it to, and picking one arbitrarily or iterating them would defeat the single-run guarantee this key exists to provide. A gatecmdthat uses<project-dir>while declaringrun-from: repo-rootis a configuration error: report it as aFAILnaming the gate and the unresolvable placeholder rather than guessing an expansion. Such a gate is per-project by construction and belongs on the"ecosystem"default. - Tool presence, as with
check-cmd, if the gate's tool is missing fromPATH, reportskip(reuse the ecosystem'sinstall-hint), neverFAIL. - Version floor, a tool that is present but too old for the gate's invocation is an environment capability gap, not project drift, so it reports
skip (unsupported: <tool and missing capability, one short phrase>)with theinstall-hintrather than a falseFAIL. The bundledgo.yamlgo-mod-tidy-driftgate has one:go mod tidy -diffneeds Go 1.23+, so a Go 1.22 toolchain must skip rather than fail every*.go/go.mod/go.sumchange. A rejected invocation is not by itself evidence of a version floor, a typo in a consumer'sgate.cmd(misspelled flag, wrong subcommand) is rejected identically, and skipping it would leave a malformed gate silently unenforced. So the skip requires the mismatch to be positively established, either by the tool naming its own minimum in the error, or by a minimum documented for that gate (the gate'sremediation, the ecosystem'snotes, orcontext/<ecosystem>.md) that the tool's reported version, queried directly, e.g.go version, falls below. Unexplained rejection →FAIL, with the rejection text shown so the typo is visible. Every other non-zero exit (a malformed manifest, a network failure, real drift) is likewise aFAIL. - Outcome. Report
pass/FAILby name. OnFAIL, surfacegate.remediation. A fired gate that fails is a real failure and counts toward the run's FAIL verdict (unlike opt-in/missing-tool skips). A gate that runs more than once ("ecosystem"scope underproject-discovery, once per project root) reports one aggregated outcome line per gate name, not one line per root:FAILif any invocation failed,passonly if every invocation passed. On an aggregatedFAIL, show each failing invocation's output below the table labeled by its execution root, so a multi-root failure is traceable to the specific root that failed.run-from: repo-rootruns exactly once, so this aggregation never applies to it.
Gates resolve through the ladder like every other key: a bundled default may ship one (e.g. go.yaml's go-mod-tidy-drift), and a consumer declares its own in its tracked .claude/ecosystems/<ecosystem>.yaml gates array (e.g. the nuget-lockfile-drift shape in the contract's examples, https://github.com/melodic-software/claude-code-plugins/blob/main/docs/conventions/ecosystem-commands/examples/dotnet.yaml).
Convention-documented gates still run. The gates array is the declaration form this skill can resolve, report by name, and layer per the ladder, but it is not the only place a consuming project states its CI-parity checks. When the project documents extra local checks in its own conventions (its CLAUDE.md, .claude/rules/, or a commands reference) rather than in a gates array, run those too, by the same rules above: fire on their stated trigger files, run after build → test → lint and independent of that short-circuit, report by name with the project's own remediation, and count a failure toward the verdict. A project that documented its gates in prose keeps them; declaring them in .claude/ecosystems/<ecosystem>.yaml is the preferred form because it makes them structured, layerable, and machine-checkable, not a precondition for running them.
For ecosystem-specific gotchas (xUnit --nologo trap, dotnet test --project, etc.), read the corresponding context/<ecosystem>.md file.
3. Report results
## Build Results
| Ecosystem | Build | Test | Lint | Status |
|------------|-------|------|------|--------|
| dotnet | pass | pass | pass | PASS |
| python | — | pass | FAIL | FAIL |
Gates: go-mod-tidy-drift — FAIL (run go mod tidy and commit the updated go.mod/go.sum)
Overall: FAIL (1 of 2 ecosystems failed, 1 gate failed)
Use pass, FAIL, skip (tool missing), skip (opt-in unmet: ...) (config condition not met), skip (unsupported: ...) (gates only, the installed tool's version is below the gate's documented floor), or — (not applicable. For ecosystems where the corresponding command is null in the ecosystem config). Show failing command output below the table.
If any CI-parity gates fired, summarize each by name + outcome below the per-ecosystem block, with the remediation pointer on failure. A fired gate that failed flips Overall to FAIL and is counted in it, including when every ecosystem's build/test/lint cell passed (e.g. Overall: FAIL (0 of 2 ecosystems failed, 1 gate failed)). The Overall line names both counts whenever a gate fires, pass or fail, a fired-and-passed gate still reports its count (e.g. Overall: PASS (2 of 2 ecosystems passed, 1 gate passed)), so the report is unambiguous about whether a gate ran.
A gate declared under a project-discovery ecosystem without run-from: repo-root runs once per discovered project root (§2 "Run"); per the aggregation rule (§2 "Outcome"), that still reports one Gates: line per gate name, with each failing root's output shown underneath, labeled by its execution root:
Gates: go-mod-tidy-drift — FAIL (run go mod tidy and commit the updated go.mod/go.sum)
[services/auth] go: updates to go.sum needed, disabled by -mod=readonly
[services/billing] go.mod: missing go.sum entry for module golang.org/x/text v0.14.0
Only the roots that actually failed are listed, a root whose invocation passed contributes nothing below the line. A gate declaring run-from: repo-root never produces this per-root breakdown (it runs exactly once), so its FAIL line stands alone as in the first example above.
For other skills referencing /toolchain:check
When composing /toolchain:check from another skill (like /verification:confirm or /toolchain:lint):
- To get command tables: resolve per
${CLAUDE_PLUGIN_ROOT}/reference/resolution-ladder.md. Consumer.claude/ecosystems/<ecosystem>.yamlwins, bundled defaults at${CLAUDE_PLUGIN_ROOT}/reference/ecosystems/are the fallback, or the relevantcontext/<ecosystem>.mdfor gotchas and prose detail - To run full verification: invoke
/toolchain:checkor/toolchain:check <ecosystem>via the Skill tool - To run lint-only checks: invoke
/toolchain:lintor/toolchain:lint <ecosystem>via the Skill tool (it resolves through the same ladder and additionally owns theyamlandcross-cuttingsurfaces) - To embed commands in agent prompts: resolve per the ladder AND read the corresponding
context/<ecosystem>.mdfor gotchas
Gotchas (cross-ecosystem)
- CWD drift, the #1 source of false failures. Always use absolute paths
- Missing tools. Report as
skipwith reason, not as failure (e.g.,uvnot installed). The probe is per ecosystem: it covers the tool the ecosystem's commands are invoked through, not every sub-tool a compound command reaches (see the atomicity bullet below) - Opt-in unmet. Report as
skip (opt-in unmet: ...)with the condition, not as failure and not silently omitted (e.g., dotnet with no C#-relevant.editorconfig) - Multi-tool
check-cmdatomicity, when a multi-tool ecosystem'scheck-cmdbundles a gated sub-tool and an unconditional sub-tool in one shell string (e.g. bash'sshellcheck ... && shfmt -d <files>), the opt-in gate cannot suppress just the gated sub-tool's contribution. Both run whenever the unconditional sub-tool's condition holds, per the ecosystem-commands contract's own "opaque shell string" rule. The same opacity reaches the missing-tool rule above: a sub-tool the ecosystem's own probe never covers (python'spyrightbehinduv) is absent only at execution time, so its absence surfaces as a real non-zero exit and the Lint cell reportsFAIL, notskip; report what the runner did, never a skip it did not perform. Each affected ecosystem'scontext/<ecosystem>.mdstates the consequence - Multiple projects in same ecosystem. Ecosystems with an
anchoruse that as the scoping anchor; ecosystems withproject-discoverypatterns walk each discovered project root