Skip to content

Commit 530794b

Browse files
uipreligaclaude
andcommitted
feat(orchestration): refuse an unoffered skill_triggered target at resolution; read plugin manifests as Claude Code does
validate_plugins now fails plan when a skill_triggered skill_name is not among the offered skills, so a misnamed skill no longer costs a full run before it finishes ERROR (a ${row...} name is checked on its expanded row; the checker gate stays for detached grades of recorded runs). Plugin scanning now matches Claude Code's plugins reference and a CLI 2.1.273 spike: a manifest skills path ADDS to the default skills/ instead of replacing it, a declared path may name one skill, a root holding SKILL.md is a single-skill plugin, and a skill is named by its SKILL.md frontmatter name. The deliberately invalid skill_not_offered fixture is deleted; unit tests cover the refusal on plan and resolution. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d68f01c commit 530794b

9 files changed

Lines changed: 184 additions & 75 deletions

File tree

‎.claude/notes/agents.md‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -648,22 +648,32 @@ adapter used to scan the authored path its own way. claude-code loaded nothing f
648648
skills directory, with no error, so an activation suite scored recall 0.0 and read exactly
649649
like a skill that never triggers.
650650

651-
- **Both authored layouts are accepted.** For each root, the manifest-declared skill dirs
652-
that exist are scanned (default `skills/`). If none exists, the root is a bare skills
653-
directory. The manifest's `skills` field is read, not hardcoded, so a plugin that
654-
relocates its skills keeps working.
651+
- **A plugin root is read the way Claude Code reads it.** The default `skills/` is always
652+
scanned, and each path the manifest's `skills` field declares ADDS to it; a declared path
653+
may parent skills or be one skill (it holds `SKILL.md`). A root holding `SKILL.md` is a
654+
single-skill plugin. If a root yields nothing that way, it is read as a bare skills
655+
directory. A skill's name is its frontmatter `name`, else its directory name: Claude
656+
Code invokes the frontmatter name, so a gate keyed on the directory name would refuse a
657+
skill that loads. Confirmed by the plugins reference ("Adds to the default: `skills`")
658+
and a CLI 2.1.273 spike on 2026-09-16; the moved reader had treated the manifest as a
659+
REPLACEMENT, which dropped the default `skills/` of any plugin that declared extras.
655660
- **Only skills are staged.** A plugin's agents, hooks, commands and MCP servers are
656661
dropped on every harness, claude-code included. That also removes a confound: a project
657662
subagent beside `skills/` can no longer answer the request the skill should answer.
658-
- **The staged manifest is `{"name": "coder-eval-plugins"}` and nothing else.** A spike
659-
with `claude -p --plugin-dir` showed that a manifest declaring `"skills": ["skills"]`
660-
loaded no skill, symlinked or copied. A name-only manifest loads the `skills/` default.
663+
- **The staged manifest is `{"name": "coder-eval-plugins"}` and nothing else.** A
664+
2026-09-17 spike with `claude -p --plugin-dir` showed a staged root whose manifest declared
665+
`"skills": ["skills"]` load no skill; a 2026-09-16 spike on CLI 2.1.273 loaded a real
666+
`["./skills"]` fine. The name-only manifest loads the `skills/` default either way.
661667
- **Refusal is at resolution.** `validate_plugins` runs in `validate_resolved_task`, so a
662-
path with no skill, an unresolvable path, or one skill name from two sources fails
663-
`plan`. A run can no longer measure the model WITHOUT the skill under test and look normal.
668+
path with no skill, an unresolvable path, one skill name from two sources, or a
669+
`skill_triggered` `skill_name` the plugins do not offer fails `plan` before the run is paid
670+
for. A name still holding a `${row...}` placeholder is checked on its expanded row. The
671+
cost: a task whose skill under test comes from a template or `setting_sources` while it
672+
also sets `agent.plugins` is refused, because only plugin skills are offered.
664673
- **`skills_offered` is recorded** in `environment_info` and passed to the checker.
665-
`skill_triggered` raises `CheckerMisuseError` when its `skill_name` is not offered: the
666-
positive control cannot run, so the row escalates instead of scoring 0.0.
674+
`skill_triggered` still raises `CheckerMisuseError` when its `skill_name` is not offered.
675+
Resolution catches every new run first; the checker gate remains for a detached grade of
676+
a recorded run, where resolution does not re-run.
667677

668678
Delivery, per harness:
669679

‎.claude/notes/contracts.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,8 @@ judge's author-written `files:` entry, because it names one file of the solution
191191
compared against and traversal out of the staged copy is always a mistake.
192192

193193
`skill_triggered` escalates the same way when its `skill_name` is not among the skills
194-
`agent.plugins` offered (`CheckContext.skills_offered`). The agent was never offered the
194+
`agent.plugins` offered (`CheckContext.skills_offered`). Resolution refuses the same task
195+
first (`validate_plugins`), so this gate fires only on a detached grade of a recorded run. The agent was never offered the
195196
skill, so the positive control cannot run. Scored as 0.0, every positive row of an
196197
activation suite would read as a skill that never triggers. The gate applies only when the
197198
task sets plugins: with `skills_offered` `None` the criterion scores as before, so a skill

‎docs/TASK_DEFINITION_GUIDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1358,7 +1358,7 @@ Observed label is `"yes"` when either signal is found, else `"no"`. Expected lab
13581358

13591359
**Requires agent telemetry.** This criterion reads `turn_records`, so it only works against a real agent run (not a static check). With no turn records it reports `score=0.0` and an `error`.
13601360

1361-
**The skill must be offered.** When the task sets `agent.plugins`, coder-eval stages the skills those paths offer (a plugin root or a bare skills directory) and records their names in `environment_info.skills_offered`. A `skill_name` that is not among them makes the criterion finish `ERROR`, not `0.0`: the positive control cannot run. This applies even when the skill reaches the agent another way (for example a template's `.claude/skills/`): with `agent.plugins` set, put the skill under test in a plugin path. A plugin path that offers no skill fails `coder-eval plan`. See [Plugin staging](agents/HARNESS_PARITY.md#plugin-staging).
1361+
**The skill must be offered.** When the task sets `agent.plugins`, coder-eval stages the skills those paths offer (a plugin root or a bare skills directory) and records their names (the `SKILL.md` frontmatter `name`) in `environment_info.skills_offered`. A `skill_name` that is not among them fails `coder-eval plan` before the run is paid for: the positive control cannot run. A `skill_name` taken from a dataset row is checked on each expanded row. This applies even when the skill reaches the agent another way (for example a template's `.claude/skills/`): with `agent.plugins` set, put the skill under test in a plugin path. Re-grading a recorded run whose `skill_name` was not offered finishes `ERROR`, not `0.0`. A plugin path that offers no skill also fails `coder-eval plan`. See [Plugin staging](agents/HARNESS_PARITY.md#plugin-staging).
13621362

13631363
**Classification metrics.** `skill_triggered` returns a `ClassificationCriterionResult`, so on a [dataset-backed task](#dataset) the suite aggregator computes accuracy / precision / recall / F1 / confusion matrix across all rows. Gate the suite with `suite_thresholds` using any of: `accuracy`, `macro_f1`, `weighted_f1`, `micro_f1`, or per-label `precision.<label>` / `recall.<label>` / `f1.<label>` (labels are `yes` / `no`). The run exits non-zero if any listed metric falls below its minimum.
13641364

‎docs/agents/HARNESS_PARITY.md‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -659,8 +659,12 @@ whose cap fires should not look like a task whose harness hung.
659659

660660
## Plugin staging
661661

662-
Each `agent.plugins[].path` names a plugin root (`<path>/skills/<name>/SKILL.md`) or a bare
663-
skills directory (`<path>/<name>/SKILL.md`). Both layouts work on every harness. Before the
662+
Each `agent.plugins[].path` names a plugin root or a bare skills directory
663+
(`<path>/<name>/SKILL.md`). A plugin root is read as Claude Code reads it: the default
664+
`skills/` plus every path its `.claude-plugin/plugin.json` `skills` field declares (a
665+
declared path may be one skill), or the root itself when it holds `SKILL.md`. A skill's name
666+
is its `SKILL.md` frontmatter `name`, else its directory name. Every layout works on every
667+
harness. Before the
664668
agent starts, coder-eval stages the skills into one root, `<run_dir>/plugin_root`: a
665669
`.claude-plugin/plugin.json` that names `coder-eval-plugins`, and one `skills/<name>` symlink
666670
per skill. Each harness receives that root in its native way. Only skills are staged: a
@@ -669,9 +673,10 @@ included. Claude Code names staged skills `coder-eval-plugins:<skill>`, not `<pl
669673
Files beside the skills also stay behind: a skill that reads `${CLAUDE_PLUGIN_ROOT}/scripts/`
670674
or a shared `references/` directory at the plugin root cannot find it. Keep a skill's files
671675
inside its own `<name>/` directory.
672-
A path that offers no skill, or two paths that offer the same skill name, fail `coder-eval plan`.
673-
`environment_info.skills_offered` records the staged skill names. A `skill_triggered` criterion
674-
whose `skill_name` is not in that list finishes `ERROR`, not 0.0.
676+
A path that offers no skill, two paths that offer the same skill name, or a `skill_triggered`
677+
criterion whose `skill_name` the plugins do not offer fail `coder-eval plan`, before the run is
678+
paid for. `environment_info.skills_offered` records the staged skill names; re-grading a
679+
recorded run whose `skill_name` is not in that list finishes `ERROR`, not 0.0.
675680

676681
## Reproducing
677682

‎docs/tutorials/07-plugin-in-claude-code.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,7 @@ covers telling them apart with `/doctor` and `/context`.
160160
| No `/coder-eval:` commands after installing | Check `/plugin`; re-run the install |
161161
| A skill offers to install the CLI, or Bash reports `command not found` | The CLI isn't installed or isn't on `PATH` — accept the offer, or install it yourself |
162162
| `coder-eval run` matches nothing | Wrong directory — use the path `init` reported in step 2 |
163-
| Step 5 stops with a config error about `agent.plugins` | `SKILL_SOURCE_PATH` is unset, or points at a directory that holds no `<name>/SKILL.md` |
164-
| A `skill_triggered` row finishes `ERROR` | The skill name is not among the skills the plugin path offers |
163+
| Step 5 stops with a config error about `agent.plugins` | `SKILL_SOURCE_PATH` is unset, points at a directory that holds no `<name>/SKILL.md`, or the suite's `skill_name` is not the frontmatter `name` of a skill that path offers |
165164

166165
To update after the marketplace moves, `/plugin marketplace update coder-eval`; to
167166
remove it, `/plugin uninstall`.

‎plugins/coder-eval/skills/check-skill/SKILL.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,9 @@ export SKILL_SOURCE_PATH="$(pwd)/.claude"
176176

177177
Keep it an environment variable rather than baking an absolute path into the YAML — the
178178
suite is committed and re-run on other machines. If the variable is unset, or the path holds
179-
no skill, `coder-eval plan` fails with a config error. If `skill_name` is not among the skills
180-
the path offers, every `skill_triggered` row finishes `ERROR` rather than scoring 0.
179+
no skill, `coder-eval plan` fails with a config error. It fails the same way when `skill_name`
180+
is not among the skills the path offers (their `SKILL.md` frontmatter `name`), before any
181+
run is paid for.
181182

182183
`skill_name` must be the bare name even when the skill comes from a plugin and is
183184
invoked as `plugin:skill` — the checker strips the namespace before comparing. A

‎src/coder_eval/orchestration/plugin_staging.py‎

Lines changed: 64 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
33
The canonical root is ``<root>/.claude-plugin/plugin.json`` plus
44
``<root>/skills/<name>`` (a symlink to the authored skill directory, or a copy where
5-
symlinks fail). Both authored layouts are accepted: a plugin root whose manifest (or
6-
default ``skills/``) parents the skills, and a bare skills directory.
5+
symlinks fail). A plugin root is read the way Claude Code reads it: the default
6+
``skills/`` plus every manifest-declared path, where a path may parent skills or be one
7+
skill, and a root holding ``SKILL.md`` is a single-skill plugin. A bare skills
8+
directory is accepted too. A skill's name is its ``SKILL.md`` frontmatter ``name``,
9+
else its directory name.
710
811
Rationale: .claude/notes/agents.md § Skills, per harness
912
"""
@@ -17,6 +20,9 @@
1720
from pathlib import Path
1821
from typing import TYPE_CHECKING, Any
1922

23+
import yaml
24+
25+
from coder_eval.models import SkillTriggeredCriterion
2026
from coder_eval.orchestration.harness_contract import TaskResolutionError
2127
from coder_eval.utils import expand_env_vars
2228

@@ -60,7 +66,7 @@ def resolve_plugin_path(raw: str) -> Path:
6066
return root
6167

6268

63-
def _declared_skill_dirs(root: Path) -> list[Path]:
69+
def _declared_skill_paths(root: Path) -> list[Path]:
6470
manifest = root.joinpath(*_MANIFEST_RELPATH)
6571
declared: list[str] = []
6672
if manifest.is_file():
@@ -74,34 +80,55 @@ def _declared_skill_dirs(root: Path) -> list[Path]:
7480
declared = [value]
7581
elif isinstance(value, list):
7682
declared = [entry for entry in value if isinstance(entry, str)]
77-
if not declared:
78-
declared = [_DEFAULT_SKILLS_SUBDIR]
7983
return [(root / relative).resolve() for relative in declared]
8084

8185

82-
def scan_plugin_skills(plugins: Sequence[LocalPluginConfig]) -> dict[str, Path]:
83-
"""Skill name -> its directory, over every entry, both authored layouts.
86+
def _skill_name(skill_dir: Path) -> str:
87+
"""The frontmatter ``name`` of ``<skill_dir>/SKILL.md``, else the directory name."""
88+
text = (skill_dir / _SKILL_FILE).read_text(encoding="utf-8", errors="replace")
89+
if text.startswith("---"):
90+
front, _, _rest = text[3:].partition("\n---")
91+
try:
92+
data: Any = yaml.safe_load(front)
93+
except yaml.YAMLError:
94+
data = None
95+
if isinstance(data, dict) and isinstance(data.get("name"), str) and data["name"].strip():
96+
return data["name"].strip()
97+
return skill_dir.name
98+
99+
100+
def _skill_dirs(root: Path) -> list[Path]:
101+
"""Every skill directory one ``plugins:`` root offers, in Claude Code's reading order."""
102+
candidates = [root / _DEFAULT_SKILLS_SUBDIR, *_declared_skill_paths(root)]
103+
found: list[Path] = []
104+
for candidate in candidates:
105+
if (candidate / _SKILL_FILE).is_file():
106+
found.append(candidate)
107+
elif candidate.is_dir():
108+
found += [skill_file.parent for skill_file in sorted(candidate.glob(f"*/{_SKILL_FILE}"))]
109+
if found:
110+
return found
111+
if (root / _SKILL_FILE).is_file():
112+
return [root]
113+
return [skill_file.parent for skill_file in sorted(root.glob(f"*/{_SKILL_FILE}"))]
114+
84115

85-
For each root, the manifest-declared skill directories that exist are scanned;
86-
if none exists, the root itself is a bare skills directory.
116+
def scan_plugin_skills(plugins: Sequence[LocalPluginConfig]) -> dict[str, Path]:
117+
"""Skill name -> its directory, over every entry and every accepted layout.
87118
88119
Raises:
89120
PluginStagingError: an unresolvable path, a skill name from two sources, or no skill at all.
90121
"""
91122
skills: dict[str, Path] = {}
92123
for plugin in plugins:
93-
root = resolve_plugin_path(plugin["path"])
94-
candidates = [directory for directory in _declared_skill_dirs(root) if directory.is_dir()] or [root]
95-
for candidate in candidates:
96-
for skill_file in sorted(candidate.glob(f"*/{_SKILL_FILE}")):
97-
skill_dir = skill_file.parent
98-
previous = skills.get(skill_dir.name)
99-
if previous is not None and previous != skill_dir.resolve():
100-
raise PluginStagingError(
101-
f"ambiguous skill name {skill_dir.name!r}: agent.plugins offers it from both "
102-
+ f"{previous} and {skill_dir.resolve()}"
103-
)
104-
skills[skill_dir.name] = skill_dir.resolve()
124+
for skill_dir in _skill_dirs(resolve_plugin_path(plugin["path"])):
125+
name, source = _skill_name(skill_dir), skill_dir.resolve()
126+
previous = skills.get(name)
127+
if previous is not None and previous != source:
128+
raise PluginStagingError(
129+
f"ambiguous skill name {name!r}: agent.plugins offers it from both {previous} and {source}"
130+
)
131+
skills[name] = source
105132
if not skills:
106133
paths = [plugin["path"] for plugin in plugins]
107134
raise PluginStagingError(
@@ -114,12 +141,25 @@ def scan_plugin_skills(plugins: Sequence[LocalPluginConfig]) -> dict[str, Path]:
114141
def validate_plugins(task: TaskDefinition) -> None:
115142
"""Resolution-time refusal; no-op when plugins is unset or empty.
116143
144+
Also refuses a ``skill_triggered`` criterion whose ``skill_name`` the plugins do
145+
not offer, before the run is paid for. A name still holding a ``${row...}``
146+
placeholder is checked on its expanded row instead.
147+
117148
Raises:
118-
PluginStagingError: see ``scan_plugin_skills``.
149+
PluginStagingError: see ``scan_plugin_skills``, or a ``skill_triggered`` target not offered.
119150
"""
120151
plugins = task.agent.plugins if task.agent is not None else None
121-
if plugins:
122-
scan_plugin_skills(plugins)
152+
if not plugins:
153+
return
154+
offered = scan_plugin_skills(plugins)
155+
targets = {c.skill_name for c in task.success_criteria if isinstance(c, SkillTriggeredCriterion)}
156+
missing = sorted(name for name in targets if "${" not in name and name not in offered)
157+
if missing:
158+
raise PluginStagingError(
159+
f"skill_triggered names skill(s) {missing} but agent.plugins offers only {sorted(offered)}: "
160+
+ "the positive control cannot run. With agent.plugins set, the skill under test must come "
161+
+ "from a plugin path (a skill from a template or setting_sources is not offered)."
162+
)
123163

124164

125165
def link_or_copy(source: Path, target: Path) -> None:

‎tasks/skills/skill_not_offered.yaml‎

Lines changed: 0 additions & 21 deletions
This file was deleted.

0 commit comments

Comments
 (0)