Skip to content

ci: grade the rocm-doctor skill with skillscope - #356

Draft
volen-silo wants to merge 8 commits into
test/rocm-doctor-skill-contractfrom
feat/skillscope-skill-evals
Draft

ci: grade the rocm-doctor skill with skillscope#356
volen-silo wants to merge 8 commits into
test/rocm-doctor-skill-contractfrom
feat/skillscope-skill-evals

Conversation

@volen-silo

Copy link
Copy Markdown
Collaborator

Stacked on #149 — based on test/rocm-doctor-skill-contract, so this diff
is just the one commit. Draft until #149 lands; it will be rebased onto main
after that.

What

#149 makes the rocm-doctor skill's prose testable against the binary it drives.
This adds the other half: the parts an agent runtime reads before it gets that
far.

A name that disagrees with its folder, a description that never got written,
frontmatter that is not valid YAML — each one stops the skill loading or stops it
being found, and every one of them fails silently. What you see is an agent that
simply never uses the skill. A relative link into a renamed file fails the same
way: the agent follows it, finds nothing, and improvises.

amd/skillscope is AMD's harness for this.

Changes

skill-evals job — runs only skillscope's structural command: no agent, no
API key, and no network beyond the install, so it can gate a merge without ever
being wrong for a reason of its own. It checks the frontmatter, the
skill-card.md sections, every internal markdown reference, and the dataset's
coverage bar. Gated on a new skills paths filter, and modelled on the existing
license-headers job.

evals/evals.json — four prompts the skill must answer and two near misses it
must decline, covering the scope gate, the consent rule and the Phase 0 probe.
Seeded from the dataset amd/skills already grades this skill with, plus one
case that pins the correction #149 makes: a vague report where nothing clears the
threshold must be routed upstream on has_match, not diagnosed from the
sub-threshold entries still sitting in matched.

Federation does not carry a skill's evals/ folder in either direction, so this
dataset is ours and the catalog keeps its own.

Scope

routing and behavioral are not run. Both need an authenticated claude
CLI and an ANTHROPIC_API_KEY, and this repo has no such secret. They are the
half of the question that asks whether the skill actually fires — the failure
most skills have — so this job is not a substitute for them. The dataset is
written and structurally checked so switching them on is a workflow change and
nothing more.

skills/rocm-cli-assistant/ is deliberately out of scope. Despite the path it is
not a published skill: apps/rocm/src/main.rs embeds it verbatim into the chat
assistant's system prompt with include_str!, so giving it the YAML frontmatter
this check requires would inject that frontmatter into a live prompt. It would
also need a dataset of its own, for something no agent runtime ever routes to.

Verification

Run against the exact SHA the workflow pins (6dde8e8a, v0.1.0), with the
exact CI arguments:

  • skillscope structural --skills-dir 'skills/rocm-doctor' --skill-files skill-card.md --skill-sections Description,Owner,License — green, exit 0
  • --external — 9 outbound URLs answered. CI omits it on purpose: a rate-limited
    host is a fact about the run, not a broken link.

Checked that it is not a vacuous green — each of these exits 1 with a specific
message, and the restored state exits 0:

broken deliberately reported
name: rocm-doctorr name disagrees with the folder
all negative cases removed 0 of a required 2 skill_should_trigger: false
[reference.md](refrence.md) points at a file that does not exist

Also confirmed the action's launcher accepts a commit-SHA pin
(github.action_refresolve_version.py, whose ref pattern takes a 40-hex
SHA), and that /.skillscope/ is ignored.

Exit codes 1, 4 and 5 were asserted nowhere, and no test reached a runner
that writes to disk. The two that looked like coverage did not: the dry-run
test picks fix-2, whose Linux runner takes no FixOptions and never prompts,
and the e2e preview scenario picks print-only fix-1, which returns before
any runner runs.

Split consent_without_prompt out of confirm, and let pin_device_in_rc_file
take the rc path plus an injected consent verdict, so the mutation path is
reachable from a test without touching $HOME or stdin. Behaviour is
unchanged. Ten tests then cover the consent gate, both mutation outcomes,
and the dry-run short-circuit.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
check_9_igpu_dgpu_collision reported auto_applicable: false on Linux while
fix::RECIPES -- what `rocm fix` dispatches on -- says true. It was the only
OS-split checker whose branches diverged, and an agent branches on that
field, so it was told fix-9 was print-only while `rocm fix` would happily
apply it.

route_when_no_match also matched on lemonade / ollama / lm-studio, and
upstream_tracker carried those plus amdgpu-install, but Examination::probe
only ever reports skipped, pytorch, llama-cpp or unknown. Those arms were
unreachable, so the CLI advertised routing it cannot do. Routing an app that
merely appears in the symptom text belongs to the caller, not the probe.

Both are pinned: one test runs diagnose for each OS family and asserts every
emitted fix agrees with the catalog, another pins the reachable target set.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
The rocm-doctor skill is a thin driver: the probe, the closed 15-mode
catalog and the fixes all ship inside the `rocm` binary. What the skill owns
is a contract -- which fix-ids exist, which the CLI applies itself, which
machines each is for, and what a diagnosis carries. Because it describes
this binary, this repo is its source of truth; the catalog it is published
through vendors the folder from here.

Nothing tested that seam. diagnose.feature deliberately asserts only the
shape of a diagnosis so it stays host-independent, so a renamed fix-id or a
16th failure mode would merge green and silently break the published skill.

rocm_doctor_skill.feature parses reference.md's catalog table, its
auto-applicable prose, the fields and thresholds it names, the verdicts it
enumerates and the trackers it lists, then diffs each against what the CLI
really reports. Every scenario is a query -- no GPU, no serve, no download,
no mutation -- so they run on the blocking mock lane and need no capability
tags. Reading a document as test data is a deliberate, narrow exception to
the suite's black-box rule: nothing is imported from the codebase, and the
document is the thing under test.

The skill folder is excluded from the licence-header check: SKILL.md must
open with YAML frontmatter for the skill loader, which pushes any header out
of hawkeye's detection window. Verified in both directions -- the folder is
exempt, and stripping a header elsewhere still fails the check.

skills/** joins the `heavy` paths filter, since reference.md is now a
fixture and a skill-only edit has to run the e2e job.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
The skill told an agent to route upstream "when `matched` is empty", which
`DiagnoseReport::has_match` exists precisely to prevent: several checkers
open with a nonzero base score for a merely potentially relevant situation,
so a healthy host returns a non-empty `matched` of sub-threshold entries. An
agent following that instruction proposes a fix for a machine with nothing
wrong and never routes upstream. Both skill files now name `has_match` and
gate on it.

That gap was invisible to the contract feature because the field check ran
one way only -- documented ⊆ emitted -- so a field the CLI emits and the
document never mentions could not fail anything. It is now an equality, and
`rocm diagnose` emits exactly the six keys the reference names.

The route check had the same shape. It compared the CLI's target against
every tracker in the Framework routing section, including the three the
skill hands over itself, so a documented-but-unreachable CLI target passed
silently. The section now separates the two lists and the CLI is held to its
own.

Scenario 6's Given was inert: `route_when_no_match` is populated whether or
not anything matched, so the scenario passed identically for a symptom that
did match. It now asserts `has_match` is false first. Where the catalog is
out of scope the check still holds, since the catalog is not run at all; a
matching symptom can only be produced on a host the catalog covers, so the
no-GPU Linux lane is where it bites.

Finally, `route_when_no_match`'s comment claimed a test would flag a newly
added framework. That test iterates a hand-maintained list and reads nothing
from `examine.rs`, so it would not. Reworded to name the three places that
must be edited together.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
… holds still

Two CI failures, both mine.

`rocm_doctor_skill.feature` had no `FEATURE_KEYS` entry, so
`feature_files_and_declared_keys_agree` panicked and took `Test (affected
crates)`, `windows-build-and-test` and `E2E tests` down with it. Adds the key
`skill` and renames the six scenarios to `skill-01`..`skill-06`, which the
sibling naming tests require once a file is indexed. The `@id:` tags already
carried the prefix. I missed this locally by running the workspace suite with
`--exclude e2e-cucumber` -- the crate that holds the test.

Scenario 6 then asserted `has_match == false` on the grounds that its symptom
carried no catalog keyword. That premise does not survive a real host:
`diagnose` scores several checkers from host state alone, and the
GitHub-hosted Linux runner ships /etc/modprobe.d/blacklist-radeon-instinct.conf
with amdgpu unloaded, which is `fix-5-amdgpu-load` at score 90 no matter what
symptom is passed. No symptom can hold that still, so the assertion only
encoded a runner's state. It passed on WSL2 because the catalog is skipped
there entirely, which is why it looked host-independent.

The claim moves to `rocm-core`, where the Examination is constructed instead
of probed: a host whose only causes are sub-threshold has a NON-empty `matched`
and `has_match` false, and the route still names somewhere to go -- the exact
shape that makes gating on emptiness wrong. A companion test asserts a matching
symptom flips the flag, so the assertion is not free.

What is left in the feature is the half only it can check: whatever target the
CLI hands back is one the published document names. The scenario is renamed to
say that, rather than to claim a premise it does not establish.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
@volen-silo
volen-silo force-pushed the feat/skillscope-skill-evals branch from 952903f to ea5f1d3 Compare September 8, 2026 11:00
…Linux

`reference.md` told an agent that auto fixes "print the exact command, honor
--dry-run, refuse on a non-interactive shell without --yes, and confirm before
mutating". That is false for fix-2 on Linux. `run_unset_override` splits by
platform: the Windows arm takes `FixOptions` and does exactly that, while
`run_unset_override_linux` takes no options at all -- it reports where the
override is set and which rc files carry it, then stops, because editing a
user's dotfiles is not its business. So there is no prompt to answer, nothing
for --dry-run to preview, and --yes is never read. An agent following the doc
would tell a Linux user a change was previewed that was never going to happen.

`auto_applicable` is a flat bool on a const recipe, and making it OS-aware
would ripple through the listing, the diagnosis JSON and the catalog row, so
the flag keeps its meaning and the docs stop overpromising: the Auto-fix column
is now defined as what `rocm fix` actually reports -- the CLI has a runner and
will carry it out itself -- and the mutation contract moves to prose that names
fix-2-on-Linux as the exception.

Pinned so it cannot drift back. `auto_applicable_recipes_have_a_runner` cannot
catch this, because fix-2 does have a runner. The rc-file scan is split into
`report_persistent_override`, taking its candidate paths the way this PR
already made `pin_device_in_rc_file` take its rc path -- deriving them from
$HOME would mean mutating a process-global in a parallel test suite. The test
asserts the files are byte-identical afterwards rather than asserting an exit
code, since a runner that started stripping the line would still return 0;
confirmed it fails when the runner is made to mutate.

Also drops a stale duplicate doc line left on `upstream_tracker` when the
framework-keyed arms were removed.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
The skill's prose is now tested against the binary, but nothing checked the
parts an agent runtime reads before it ever gets that far: the frontmatter
that decides whether the skill loads at all, and the links it follows into
reference.md. A `name` that disagrees with its folder, or a link into a
renamed file, fails silently -- the agent simply never uses the skill, or
follows the link, finds nothing, and improvises.

skillscope is AMD's harness for exactly this. Only its `structural` command
runs here: no agent, no API key, and no network beyond the install, so it can
gate a merge without ever being wrong for a reason of its own. It checks the
frontmatter, the `skill-card.md` sections, every internal reference, and the
dataset's coverage bar.

`evals/evals.json` is that dataset -- four prompts the skill must answer and
two near misses it must decline, over the scope gate, the consent rule and
the Phase 0 probe. One case pins the correction this series just made: a
vague report where nothing clears the threshold must be routed upstream on
`has_match`, not diagnosed from the sub-threshold entries still sitting in
`matched`.

Federation does not carry `evals/`, so this dataset is ours and the catalog
keeps its own.

`routing` and `behavioral` stay off: both need an authenticated `claude` CLI
and an ANTHROPIC_API_KEY this repo does not have. They are the half of the
question that asks whether the skill actually fires, so the dataset is
written and checked to make switching them on a workflow change and nothing
more.

skills/rocm-cli-assistant/ is deliberately out of scope. Despite living under
skills/ it is not a published skill: main.rs embeds it verbatim into the chat
system prompt with include_str!, so giving it the frontmatter this check
requires would inject that frontmatter into a live prompt.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
@volen-silo
volen-silo force-pushed the feat/skillscope-skill-evals branch from ea5f1d3 to a2d814d Compare September 8, 2026 11:42
rominf
rominf previously requested changes Sep 10, 2026

@rominf rominf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against test/rocm-doctor-skill-contract rather than main, so #149's changes aren't counted against this PR. Noting it's still a draft — reviewing because you asked, not to push it forward before its dependency lands.

The CI shape is safe and I checked the parts that usually go wrong: pull_request rather than pull_request_target, inherited contents: read, no secrets reaching the job, a timeout, fail-closed on grader error. The evals file validates against skillscope's schema and clears the real Tier 0 minimum with 4 positive and 2 negative cases, and both logs_contain literals do appear in the skill, so it isn't vacuous in the ways I could check. .gitignore adds only /.skillscope/. Stacked conduct is right: draft, and the body links #149 with the rebase plan.

Two things need fixing before this is ready, both in the same category — the PR is about making a check real, and in two places it asserts an enforcement that doesn't currently exist.

I'd also fold this into a conversation with #149 rather than treating them separately: that PR deleted its drift-check on the strength of a federation that doesn't cover this repo, and this one documents a gate that isn't in branch protection. Same shape twice.

Comment thread AGENTS.md Outdated
Comment thread .github/workflows/ci.yml
},
{
"id": "rocm-under-wsl2",
"skill_should_trigger": false,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this case is the wrong shape, and it will read as a skill bug rather than an eval bug when it fires.

Per skillscope's evals.schema.json, skill_should_trigger: false means no skill loads at all, so there's no behavioural phase to grade. But SKILL.md devotes a scope gate and an "Out of scope" section to WSL2 — rocm examine/diagnose detect it and route out, relay that guidance, point at AMD's ROCm-on-WSL guide — and that's behaviour that only happens once the skill has loaded. This case's own note says the skill "declines rather than troubleshoots", which is a description of it firing.

The prompt also matches the frontmatter description almost word for word (torch.cuda.is_available() false, AMD GPU, Linux or Windows), so routing will very likely fire it. Nothing catches the mismatch today because structural never runs the prompts — it'll surface the moment routing is enabled, and the tempting fix at that point is to weaken the skill description, which would be the wrong repair.

Suggest making it a triggering case that grades the decline: skill_should_trigger: true, expected_behavior = state WSL2 is out of scope and point at the ROCm-on-WSL guide, unexpected_behavior = run examine/diagnose/fix or offer any WSL2 troubleshooting. You'd then want a genuine second near-miss to keep the two-negative floor — a pure Windows-driver or NVIDIA-container prompt would do it. cuda-broken-on-nvidia is a clean negative as written; it's only this one.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this was the wrong shape. Flipped rocm-under-wsl2 to skill_should_trigger: true with expected_behavior (state WSL2 is out of scope, point at AMD's ROCm-on-WSL guide) and unexpected_behavior (run examine/diagnose/fix, or offer any WSL2 troubleshooting), matching the skill's own Out-of-scope section almost verbatim. Added a fresh negative, nvidia-container-toolkit-unrelated (a pure NVIDIA container-runtime error with no ROCm/AMD vocabulary), to keep the two-negative floor. Re-ran the pinned skillscope structural command locally against the new file: 12 cases, exit 0.

Comment thread .github/workflows/ci.yml
uses: amd/skillscope@6dde8e8a34ad5456d3c2ff3418bc81c2cd9d5d69 # v0.1.0
with:
command: structural
skills: skills/rocm-doctor

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This names one directory while the comment above and the paths filter both describe the whole of skills/, so a second skill added later is silently never graded.

skillscope's own safety net can't help here: structure.errors() only reports "no skill found" when the configured set is empty, and this still resolves rocm-doctor, so a new skills/foo/ with broken frontmatter passes green. That's the same silent-non-grading failure this PR is built to prevent, just deferred by one skill.

Either pass a glob once rocm-cli-assistant is excluded or moved, or add a one-line guard that fails when ls -d skills/*/ turns up a directory that isn't in the graded list — so adding a skill forces a deliberate CI edit instead of quietly widening the gap.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the cheap guard rather than a glob: a step that lists skills/*/ and fails closed if it finds a directory that's neither graded by the skillscope step nor excluded by design (rocm-cli-assistant, per AGENTS.md #7). A third skill folder now forces a deliberate CI edit instead of passing green ungraded. Left the skillscope 'skills:' input pointed at skills/rocm-doctor specifically, since skillscope grades one directory at a time and rocm-cli-assistant still needs to stay excluded either way.

Comment thread .github/workflows/ci.yml Outdated
Address review feedback: the AGENTS.md/ci.yml text claimed skill-evals
already blocks merges, when it isn't a required status check yet, and
claimed the pinned skillscope action fully covers supply-chain drift,
when the composite's own steps and the uvx-resolved harness aren't
pinned. Both are now stated accurately instead of asserted.

Also: the changes-job `skills` filter now matches its siblings by
covering all of `.github/workflows/**` instead of just `ci.yml`; a new
guard step fails the job if a skills/ directory shows up that isn't
graded or excluded by design, so a second skill can't go ungraded
silently; and the WSL2 eval case is reshaped to `skill_should_trigger:
true` (the skill loads to decline it, per its own scope gate) with a
fresh negative case restoring the two-negative floor.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
@volen-silo
volen-silo force-pushed the test/rocm-doctor-skill-contract branch 2 times, most recently from c7a236c to 88b35c6 Compare September 11, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants