Skip to content

Commit eca9979

Browse files
docs(examples): harden eval-rules and eval-agents after live audits
eval-rules: resolve paths against files on disk (a gitignored rules directory no longer looks dead), report local-only rules directories, flag Cursor-style globs fields, score every pattern and the pattern union, bundle a resolver, add an audit-only mode, cite the v2.1.284 changelog for external symlinked rules. eval-agents: decide loadability with claude plugin validate instead of a strict YAML library, check field values, tool names and skills entries, report user agents shadowed by project agents, follow symlinks in discovery, add audit-only and triage modes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 2561533 commit eca9979

2 files changed

Lines changed: 88 additions & 17 deletions

File tree

‎examples/skills/eval-agents/SKILL.md‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ Claude Code picks the definition from the highest-priority location when several
4949
- `name` without `description`: skipped.
5050
- YAML that does not parse: skipped. A plugin agent without `name`, or with unparseable frontmatter, still loads under its filename.
5151

52+
"Does not parse" means Claude Code's own parser, which accepts some frontmatter a strict YAML library rejects, such as an unquoted multi-line `description` whose lines contain `: `. Decide with `claude plugin validate <agents directory>` (v2.1.233 or later) or with the session's agent list (`/agents`), never with a strict YAML library alone. A file that only a strict parser rejects loads: report it as a portability warning (other tools may reject it) and suggest a `>` block scalar.
53+
5254
Two files in the same agents tree that declare the same `name`: only one loads, chosen by filesystem read order, not by a documented precedence. `/doctor` reports them.
5355

5456
### Claude Code: frontmatter fields
@@ -112,12 +114,12 @@ A Markdown agent with YAML frontmatter, or a TOML file using `prompt` instead of
112114

113115
| # | Criterion | Max | What is checked |
114116
|---|---|---|---|
115-
| 1 | **loadable** | 2 | Frontmatter parses and `name` is valid: present, no `:`, no leading `-` (1); `description` present (1). A 0 here means the runtime skips the file: stop scoring and report it as a blocker. |
117+
| 1 | **loadable** | 2 | Frontmatter parses for Claude Code (see above) and `name` is valid: present, no `:`, no leading `-` (1); `description` present (1). A 0 here means the runtime skips the file: stop scoring and report it as a blocker. A strict-YAML-only failure keeps the point and adds a portability warning. |
116118
| 2 | **description** | 3 | States when to delegate (1); distinguishable from every other agent in the same scope (1); concise, since combined descriptions above 15,000 tokens trigger a startup warning (1) |
117119
| 3 | **model** | 2 | Declared or deliberately inherited (1); tier fits the task (1) |
118120
| 4 | **tools** | 3 | `tools` explicit (1); no write-capable tool (`Bash`, `Edit`, `Write`, `NotebookEdit`) unless the task mutates files or runs commands (1); `disallowedTools` or `tools` excludes `Agent` when the agent must not delegate further (1) |
119121
| 5 | **system prompt** | 5 | Role and scope in the first paragraph (1); what it does and does not do (1); output or return contract (1); no placeholder or TODO (1); no instruction that requires a tool the runtime removes, such as asking the user a question (1) |
120-
| Bonus | **field hygiene** | +1 | No unrecognized field, and no field that the agent's source ignores (for example `hooks` in a plugin agent) |
122+
| Bonus | **field hygiene** | +1 | No unrecognized field, no invalid field value, and no field that the agent's source ignores (for example `hooks` in a plugin agent) |
121123

122124
### Codex agents (12 pts)
123125

@@ -150,31 +152,47 @@ Subagents do not get `AskUserQuestion` or plan-mode tools, and by default they r
150152

151153
Resolve the scope from the argument, or default to the project plus user scope of each requested host.
152154

155+
| Request | Run |
156+
|---|---|
157+
| Default | Steps 1-6, with the interactive review |
158+
| `audit-only`, or the user asks for no changes | Steps 1-4 and 6: list proposed changes instead of asking or editing |
159+
| More than 20 agents in scope | Triage: run Steps 1-3 and the structural checks of Step 2 on every file, score in full only the agents with a finding plus a sample of the rest, and report the others in one compact table |
160+
153161
```bash
154-
# Claude Code: every .md file, recursively
155-
find .claude/agents -name "*.md" 2>/dev/null
156-
find ~/.claude/agents -name "*.md" 2>/dev/null # user scope, when requested
162+
# Claude Code: every .md file, recursively (-L follows symlinked directories)
163+
find -L .claude/agents -name "*.md" 2>/dev/null
164+
find -L ~/.claude/agents -name "*.md" 2>/dev/null # user scope, when requested
157165

158166
# Codex: one TOML file per agent
159-
find .codex/agents -name "*.toml" 2>/dev/null
160-
find ~/.codex/agents -name "*.toml" 2>/dev/null # user scope, when requested
167+
find -L .codex/agents -name "*.toml" 2>/dev/null
168+
find -L ~/.codex/agents -name "*.toml" 2>/dev/null # user scope, when requested
161169
```
162170

163171
Also list nested `.claude/agents/` directories between the working directory and the repository root.
164172

173+
When a `ctxharness doctor --format json` report generated during this task is available, every agents-layer finding it reports must appear in the audit.
174+
165175
Done when: every candidate file is listed with its host and scope, or the report states that no agent directory exists and stops.
166176

167177
### Step 2: Parse and check loadability
168178

169179
For each file, read it in full and record: host, scope, `name`, `description` length, `model`, `tools` or `(inherits)`, every other key, body or `developer_instructions` line count.
170180

171-
For Claude Code, `claude plugin validate .claude/agents` (v2.1.233 or later) reports files whose frontmatter does not parse; it does not flag a parseable file with no `name`, so check that separately.
181+
For Claude Code, `claude plugin validate .claude/agents` (v2.1.233 or later) reports files whose frontmatter does not parse; it does not flag a parseable file with no `name`, so check that separately. It only reads files, so it is safe in `audit-only` mode.
182+
183+
Check values as well as field names:
184+
185+
- `permissionMode` is one of the documented values; anything else, such as `ask`, is not a mode.
186+
- `model` is an alias from the table or a full model ID; `effort`, `memory`, `isolation` and `experimental.cacheTtl` use their documented values.
187+
- Each `tools` and `disallowedTools` entry names a tool from the tools reference (https://code.claude.com/docs/en/tools-reference) or an MCP tool (`mcp__server__tool`). An undocumented name, such as `MultiEdit`, does not resolve: flag it, and flag the agent as a launch risk when no entry resolves.
188+
- Each `skills` entry resolves to a skill available in that scope.
172189

173-
Done when: each file is classified as loadable, skipped by the runtime (with the reason from the host reference above), or unreadable.
190+
Done when: each file is classified as loadable, skipped by the runtime (with the reason from the host reference above), or unreadable, with its invalid values listed.
174191

175192
### Step 3: Check collisions and overlap
176193

177194
- Same `name` twice in one Claude Code agents tree, or a Codex custom agent that shadows a built-in: report which file wins or that the choice is undefined.
195+
- Same `name` in the user and project scopes: the project definition wins inside that project, so the user copy is dead there. List these pairs and whether the two definitions differ.
178196
- Compare descriptions pairwise within each host and scope. Flag pairs that claim the same kind of request without a stated boundary.
179197

180198
Done when: every collision and overlapping pair is listed with both file paths.

‎examples/skills/eval-rules/SKILL.md‎

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ This skill covers Claude Code instruction rules in `.claude/rules/` and `~/.clau
2020
- Periodic hygiene: are these rules still accurate and useful?
2121
- After onboarding to a new codebase
2222

23+
## Modes
24+
25+
| Request | Run |
26+
|---|---|
27+
| Default | Steps 1-5, with the interactive review |
28+
| `audit-only`, or the user asks for no changes | Steps 1-3 and 5: list each proposed change instead of asking or editing, and omit the user-answer rows from the report |
29+
30+
When a `ctxharness doctor --format json` report generated during this task is available, every rules-layer finding it reports must appear in the audit. The doctor checks structure only; dead patterns, breadth and content are this skill's job.
31+
2332
## Key Concepts
2433

2534
| Mechanism | When it loads |
@@ -30,7 +39,7 @@ This skill covers Claude Code instruction rules in `.claude/rules/` and `~/.clau
3039
| User rule in `~/.claude/rules/` | Applies to every project; loaded before project rules. Whether `paths` scoping applies to user rules is not documented: report it as unverified instead of assuming |
3140

3241
- All `.md` files under `.claude/rules/` are discovered recursively.
33-
- `paths` is the only frontmatter field Claude Code reads from a rule; other fields are ignored without an error. It accepts a YAML list or a comma-separated string.
42+
- `paths` is the only frontmatter field Claude Code reads from a rule; other fields are ignored without an error. It accepts a YAML list or a comma-separated string. Fields from other tools' rule formats, such as Cursor's `globs`, `alwaysApply` or `description`, are therefore ignored too: a rule scoped with `globs:` silently loads always-on.
3443
- If the frontmatter YAML does not parse, Claude Code ignores the frontmatter and loads the rule as if it had no `paths`, so a broken scoped rule silently becomes always-on.
3544
- Brace expansion works (`"src/**/*.{ts,tsx}"`). A rule's whole `paths` list shares a budget of 1,000 expanded patterns and 4 MiB; a pattern over budget is used unexpanded and its literal braces match nothing.
3645
- A `[` that cannot be read as a bracket expression (for example `photos [2024/**`) makes that pattern invalid: it matches nothing, the other patterns still work. Escape a literal bracket as `\[`.
@@ -43,7 +52,7 @@ This skill covers Claude Code instruction rules in `.claude/rules/` and `~/.clau
4352
- A symlink whose target is outside the working directory is treated like an external import: it does not load until external imports are approved for the project, and after approval only the linked rules without `paths` load.
4453
- A symlink to a network path (UNC share, `/net`, `/Network`) never loads. `\\wsl$` paths are not network paths.
4554
- Circular symlinks are detected and handled.
46-
- The documentation says the approval prompt appears only for `@path` imports, but Claude Code v2.1.284 fixed externally symlinked rules being skipped without any prompt and now asks for the same approval. On older versions, an external symlinked rule is skipped silently.
55+
- The memory documentation says the approval prompt appears only for `@path` imports, but the v2.1.284 changelog reads: "Fixed rules symlinked into `.claude/rules` from outside the project being skipped without ever showing the external-imports approval prompt". From v2.1.284, an external symlinked rule asks for the same approval; on older versions it is skipped silently. Check the running version before reporting the load status.
4756

4857
To share rules across projects without approval, put them in `~/.claude/rules/`.
4958

@@ -53,13 +62,15 @@ To share rules across projects without approval, put them in `~/.claude/rules/`.
5362
|---|---|---|---|
5463
| 1 | **frontmatter parses** | 1 | Frontmatter, when present, is valid YAML (a broken block turns the rule always-on) |
5564
| 2 | **paths field** | 2 | Present (1) + at least one non-empty pattern, as a list or comma-separated string (1) |
56-
| 3 | **pattern validity** | 3 | Each pattern (up to 3 checked) matches at least one file and is not invalid or over the expansion budget |
57-
| 4 | **scope** | 2 | Not dead (1) + not too broad: under 30% of the project files counted in Step 1 (1) |
65+
| 3 | **pattern validity** | 3 | Every pattern is checked. Score 3 times the share of patterns that match at least one file and are neither invalid nor over the expansion budget, rounded down |
66+
| 4 | **scope** | 2 | Not dead: the union of all patterns matches at least one file (1) + not too broad: that union covers under 30% of the project files counted in Step 1 (1) |
5867
| 5 | **content quality** | 3 | Clear title (1) + specific, actionable rules (1) + under 150 lines (1) |
5968
| Bonus | **focus** | +1 | Under 15 rules in the file |
6069

6170
**Always-on rules** (no `paths`): score criteria 1 and 5 only (4 pts, plus bonus), mark them `always-on`, and decide in the interactive step whether they should be scoped.
6271

72+
**User rules** (`~/.claude/rules/`): their patterns resolve against whichever project is open, so criteria 3 and 4 are `N/A` in a user-level audit. Score them only when auditing a named project.
73+
6374
**Symlinked rules**: add a load-status note (`loads`, `needs external-import approval`, `loads only after approval and only if unscoped`, `never loads: network path`). A rule that cannot load gets status `Fix` regardless of score.
6475

6576
**Thresholds:** Good >= 83%, Needs work 58-82%, Fix < 58%.
@@ -79,24 +90,64 @@ If an argument names another path, use it. Include `~/.claude/rules/` when the u
7990
Count project files for the scope percentage from what the repository actually contains, not a fixed list of extensions:
8091

8192
```bash
82-
git ls-files | wc -l # inside a git repository
93+
git ls-files --cached --others --exclude-standard | wc -l # inside a git repository
8394
```
8495

8596
Outside git, count files while excluding `.git`, `node_modules`, build output and other ignored directories.
8697

98+
Claude Code matches `paths` against the files Claude reads, tracked or not. Resolve patterns in Step 3 against the files on disk (excluding `.git` and `node_modules`), not against `git ls-files` alone, or a pattern that targets a gitignored directory looks dead when it is not.
99+
100+
Check whether the rules directory is shared with the team: `git check-ignore -v .claude/rules/<file>` or `git ls-files .claude/rules | wc -l`. Report a gitignored or untracked rules directory as local-only: those rules apply on this machine and nowhere else.
101+
87102
For every symlink in the rules tree, record the resolved target and whether it is inside the working directory or a network path.
88103

89-
Done when: every rule file is listed with its location (project or user), symlink status, and the project file count is recorded. If no rules directory exists, report it and stop.
104+
Done when: every rule file is listed with its location (project or user), symlink status and tracking status, and the project file count is recorded. If no rules directory exists, report it and stop.
90105

91106
### Step 2: Parse each rule
92107

93-
For each file: read it in full, extract the frontmatter, normalize `paths` (list or comma-separated string) into a list, classify the rule as scoped or always-on, and note parse errors, unknown fields (ignored by Claude Code), line count, and title.
108+
For each file: read it in full, extract the frontmatter, normalize `paths` (list or comma-separated string) into a list, classify the rule as scoped or always-on, and note parse errors, unknown fields (ignored by Claude Code), line count, and title. A `globs` field without `paths` is a finding of its own: the author meant to scope the rule, but it loads always-on; propose renaming the field to `paths`.
94109

95110
Done when: every rule has a classification and a parse status.
96111

97112
### Step 3: Resolve patterns
98113

99-
For each pattern, resolve it against the project with the file search capability. Record the match count, up to 10 sample paths, and flag dead patterns, invalid `[` patterns, patterns whose brace expansion exceeds the budget, and patterns matching more than 30% of the counted files.
114+
For each pattern, resolve it against the project files. Record the match count, up to 10 sample paths, and flag dead patterns, invalid `[` patterns, patterns whose brace expansion exceeds the budget, and rules whose pattern union matches more than 30% of the counted files. This resolver lists tracked and untracked files from git, then also walks a gitignored directory when a pattern's literal prefix points into it, so `.claude/rules/**` is not reported dead in a repository that ignores its rules. It expands braces and treats `**/` as zero or more directories; dotfiles match. The exact matcher Claude Code uses is not documented, so report a borderline result as such. Pass one rule's patterns per run:
115+
116+
```bash
117+
python3 - "$PROJECT_ROOT" "src/**/*.{ts,tsx}" "prisma/**" <<'EOF'
118+
import os, re, subprocess, sys
119+
root, patterns = sys.argv[1], sys.argv[2:]
120+
def braces(p):
121+
m = re.search(r"\{([^{}]*)\}", p)
122+
return [q for alt in m.group(1).split(",") for q in braces(p[:m.start()] + alt + p[m.end():])] if m else [p]
123+
def regex(p):
124+
out, i = "", 0
125+
while i < len(p):
126+
if p.startswith("**/", i): out, i = out + "(?:.*/)?", i + 3
127+
elif p.startswith("**", i): out, i = out + ".*", i + 2
128+
elif p[i] == "*": out, i = out + "[^/]*", i + 1
129+
elif p[i] == "?": out, i = out + "[^/]", i + 1
130+
else: out, i = out + re.escape(p[i]), i + 1
131+
return re.compile(out + r"\Z")
132+
def walk(top):
133+
for d, ds, fs in os.walk(os.path.join(root, top)):
134+
ds[:] = [x for x in ds if x not in {".git", "node_modules"}]
135+
yield from (os.path.relpath(os.path.join(d, f), root) for f in fs)
136+
git = subprocess.run(["git", "-C", root, "ls-files", "-co", "--exclude-standard"], capture_output=True, text=True)
137+
files = set(git.stdout.splitlines()) if git.returncode == 0 else set(walk(""))
138+
base = len(files)
139+
for p in patterns:
140+
prefix = re.split(r"[*?\[{]", p)[0].rsplit("/", 1)[0] if "/" in p else ""
141+
if prefix and git.returncode == 0 and subprocess.run(["git", "-C", root, "check-ignore", "-q", prefix]).returncode == 0:
142+
files |= set(walk(prefix))
143+
union = set()
144+
for p in patterns:
145+
hits = {f for f in files if any(regex(q).match(f) for q in braces(p))}
146+
union |= hits
147+
print(f"{len(hits):6d} {p} {sorted(hits)[:3]}")
148+
print(f"union {len(union)} of {base} project files ({100 * len(union) / max(base, 1):.1f}%)")
149+
EOF
150+
```
100151

101152
Done when: every pattern has a match count and a flag list.
102153

@@ -168,4 +219,6 @@ Codex has no equivalent of `.claude/rules/`. When the request is about Codex:
168219
- **Empty file**: flag as broken, skip the interactive step, ask whether to delete it.
169220
- **Dead pattern**: flag it and suggest a fix from the rule content.
170221
- **Pattern matching more than 30% of files**: flag as too broad and suggest narrowing.
222+
- **`globs:` instead of `paths:`** (a rule written for another tool): the rule loads always-on; propose the rename, or dropping the field if always-on is the intent.
223+
- **Gitignored rules directory**: the rules are local-only; ask whether team sharing was intended before proposing anything else.
171224
- **Rule not loading as expected**: add an `InstructionsLoaded` hook; its matcher filters on the load reason (`session_start`, `nested_traversal`, `path_glob_match`, `include`, `compact`) and it logs which rule files load and when.

0 commit comments

Comments
 (0)