TL;DR와 결론
Claude Code 스킬은 이름을 치면 완벽하게 실행되는데도, 평범한 말로 부탁하면 전혀 로드되지 않을 수 있습니다. Claude는 스킬을 로드하기로 결정하기 전에 스킬마다 한 줄, 이름과 description을 읽습니다. 그리고 그 줄을 여러분의 말과 맞춰 봅니다. 줄이 여러분의 말을 쓰지 않으면 스킬은 조용히 있습니다.
우리 실행에서는 description이 "Generates changelog documents from repository history."인 릴리스 노트 스킬이 "what did we ship this week?"라는 요청에 새 세션 다섯 번 중 0번 로드됐습니다. 그 한 줄을 스킬이 하는 일, 쓰는 때, 우리가 치는 말로 고쳐 썼습니다. 그러자 같은 요청에서 다섯 번 중 다섯 번 로드됐습니다.
결론:
- 스킬이 값을 하는 건 출력이 정해진 반복 작업입니다. 주간 노트, 풀 리퀘스트 설명, 릴리스 체크리스트.
- 일회성 질문에는 값을 못 하고, 항상 지켜져야 하는 규칙을 위한 도구도 아닙니다. 그건 훅의 일입니다.
- 아무도 안 쓰는 스킬 더미도 비용이 됩니다. 목록 예산을 넘으면 Claude Code가 가장 덜 쓴 것부터 description을 자르기 때문입니다.
- 하나의 규칙: description은 그 작업을 부탁할 때 실제로 치는 말로 쓰세요.
아래 키트에는 스킬 파일의 수정 전과 후, description 템플릿, 새 세션 체크리스트, 우리가 쓴 스무 개 요청 테스트가 들어 있습니다.
문서와 현장이 말하는 것
스킬이 있는 곳. 스킬은 안에 SKILL.md가 있는 폴더이며, 모든 프로젝트용이면 ~/.claude/skills/<skill-name>/에, 팀도 받게 하려면 .claude/skills/<skill-name>/에 둡니다. 따로 놓인 name.md 파일은 로드되지 않습니다. s1 s2
Claude는 본문이 아니라 description을 읽는다. 이름과 description 목록은 매 턴 컨텍스트에 있고, 본문은 스킬이 호출될 때만 로드됩니다. 문서에 따르면 Claude가 스킬을 언제 적용할지 정하는 데 쓰는 것이 description입니다. s1
목록에는 상한과 예산이 있다. description과 when_to_use 텍스트는 합쳐서 1,536자에서 잘립니다. 목록 전체는 컨텍스트 윈도우의 1%로 제한되고, 넘치면 Claude Code는 가장 덜 호출한 스킬부터 description을 버립니다. 문서에 따르면 이러면 Claude가 요청을 맞추는 데 필요한 키워드가 사라집니다. --debug를 쓰면 디버그 로그에서 경고를 볼 수 있습니다. s1
깨진 헤더는 성공처럼 보이는 함정이다. YAML이 깨져 있으면 본문은 빈 메타데이터로 로드되므로 /skill-name은 동작하지만 Claude는 description을 요청과 맞출 수 없습니다. --debug로 파싱 오류를 확인하거나 claude plugin validate .claude/skills를 실행하세요. s1 s2
배지를 확인하라. 스킬이 /skills에 있는데 Claude가 한 번도 호출하지 않는다면, disable-model-invocation: true가 설정돼 있거나 description이 여러분이 부탁하는 방식과 맞지 않는 겁니다. user-only 레이블은 Claude가 스스로 발동하지 않는다는 뜻입니다. s2
좋은 description에 들어가는 것. Anthropic의 가이드는 description에 스킬이 하는 일과 쓰는 때를 모두 담고, 3인칭으로, 사용자가 치는 핵심 용어를 넣으라고 말합니다. 100개가 넘을 수도 있는 스킬 중에서 Claude가 알맞은 것을 고르는 데 쓴다고도 적혀 있습니다. s3
Claude는 undertrigger 경향이 있다. Anthropic의 skill-creator는 "currently Claude has a tendency to 'undertrigger' skills"라고 말하며 약간 "pushy"한 description을 권합니다. s18 트리거 테스트는 20개 쿼리를 쓰는데, 발동해야 하는 것과 발동하면 안 되는 것을 섞고 아슬한 쿼리가 가장 값지다고 합니다. 루프는 60%를 학습용, 40%를 보류한 테스트용으로 나누고, 각 쿼리를 3번 실행해 테스트 점수가 가장 좋은 description을 남깁니다. s4 평가 스크립트는 각 쿼리를 claude -p로 실행하고 스킬이 쓰였는지 확인합니다. s19
발동은 키워드 매칭처럼 동작한다. 실제 바이너리에서 한 서드파티 테스트(프롬프트 22개, Sonnet 4.5)는 두 번의 실행에서 기준선 55%와 50%, 강제 평가 훅 100%를 측정했습니다. 명시적인 토큰이 든 프롬프트는 거의 100% 발동했고, 간접적인 표현은 0% 근처였습니다. 저자의 해석은 의미 매칭보다 키워드 매칭에 가깝다는 것입니다. 블로그 하나, 더 오래된 모델, 다른 레포이니 규칙이 아니라 보조 증거로 보세요. s5
description은 요약이 아니라 트리거다. 실제 스킬 아홉 개를 감사한 글도 같은 결론에 이르렀습니다. 구체적인 상황을 나열한 description이 스킬이 무엇인지 말하는 description을 이깁니다. s8
반대의 실패도 있다. 한 개발자는 스킬이 엉뚱한 작업에서 발동하는 것을 보고 트리거 평가를 쓰고 description을 "Load when..."으로 고쳐 썼으며, 남길 만한 스킬은 보통 두 번 보기 싫은 실패에서 태어난다고 적었습니다. s11
다른 사람들도 겪었다. "Claude Code not aware of skills in .claude/skills"라는 이슈에는 75개의 반응이 있고 2026-10-17 기준 아직 열려 있었습니다. 스레드의 우회책으로는 CLAUDE.md 포인터와 훅이 있습니다. s7
훅이 스킬을 이기는 곳. 778 추천을 받은 스레드의 상위 답변은, 스킬이 "basically just a prompt you save for later"이고 Claude가 쓰기로 결정하거나 슬래시 명령으로 부르는 것이며, 훅은 스킬이 너무 느슨해서 결정론이 필요할 때 쓰는 것이라고 말합니다. s9 다른 스레드는 리마인더 유도가 긴 세션에서 약해질 수 있다고 경고합니다. s10
키트
수정 전과 후의 스킬. 다른 건 description 줄뿐이고 본문은 같습니다.
release-notes.before.SKILL.md
---
name: release-notes
description: Generates changelog documents from repository history.
---
# Release notes
1. Find the range: commits since last Monday (`git log --since="last monday" --no-merges --pretty="%h %s"`).
2. Drop `chore` and `docs` commits.
3. Group what is left by the conventional-commit scope (router, css, jsx, serve-static...), fixes and features apart.
4. Write one line per change, in plain English, present tense, no commit hashes, with the PR number at the end.
5. Output a markdown block titled "Release notes, week of <date>", with a two-line summary on top.
release-notes.before.SKILL.md on GitHub
release-notes.after.SKILL.md
---
name: release-notes
description: Writes this week's release notes from git log, grouped by scope (router, css, jsx). Use when the user asks what we shipped, what changed this week, for release notes, a changelog, or a summary of recent commits.
---
# Release notes
1. Find the range: commits since last Monday (`git log --since="last monday" --no-merges --pretty="%h %s"`).
2. Drop `chore` and `docs` commits.
3. Group what is left by the conventional-commit scope (router, css, jsx, serve-static...), fixes and features apart.
4. Write one line per change, in plain English, present tense, no commit hashes, with the PR number at the end.
5. Output a markdown block titled "Release notes, week of <date>", with a two-line summary on top.
release-notes.after.SKILL.md on GitHub
description 템플릿. 우리의 실제 description이 작업 예시로 들어 있습니다.
description-template.md
# Skill description template
The description is the one line Claude reads before it decides to load your skill. Write it for the moment you ask, not for the file.
## Template
```
<Verb in third person> <what the skill produces>, <how it is shaped>. Use when the user asks <phrase 1>, <phrase 2>, <phrase 3>, or <phrase 4>.
```
Three parts, in this order:
1. What it does. Third person ("Writes", never "I can" or "You can use this"). Name the output.
2. When to use it. Start with "Use when the user asks".
3. Your words. The exact phrases you or your team type, copied from your own history, not synonyms you invented.
Put the key use case first. The description and `when_to_use` are cut together at 1,536 characters in the skill listing.
## Worked example (the real one from our run)
Before, a summary of the skill:
```
Generates changelog documents from repository history.
```
After, what + when + the words we type:
```
Writes this week's release notes from git log, grouped by scope (router, css, jsx). Use when the user asks what we shipped, what changed this week, for release notes, a changelog, or a summary of recent commits.
```
What changed:
- What: "Writes this week's release notes from git log, grouped by scope".
- When: "Use when the user asks ...".
- Your words: "what we shipped", "what changed this week", "release notes", "a changelog", "a summary of recent commits".
## Fill-in sheet
- Skill name:
- The prompt I keep retyping (copy it from your history):
- The output I want from it:
- Verb + output (what it does):
- Four phrases I really type (when):
- Phrases that sound close but are a different job (for your near misses):
## Check before you save
- Third person, no "I" and no "you".
- At least three phrases you really type.
- Under 1,536 characters in total.
- Not so wide that it matches a neighbour job. Add the near misses to your trigger test.
description-template.md on GitHub
새 세션 체크리스트.
trigger-checklist.md
# Fresh-session trigger checklist
A skill that runs by name can still be invisible to a plain request. Test it the way a teammate will use it.
## Test in a fresh session
1. Open a new session. A skill already used stays in the conversation, so a test in the same session proves nothing.
2. Ask for the task in your own words, without the skill's name. Example: "what did we ship this week?"
3. Watch for the Skill tool call before Claude touches anything else. If Claude runs the commands itself and writes its own summary, the skill was skipped.
4. Repeat in several fresh sessions. A handful of runs is a demo, not a statistic.
## If it was skipped, read why before you edit
1. `/skills`: is it listed, is it switched on, is there a `user-only` badge? A `user-only` badge means Claude will not trigger it on its own.
2. Ask "What skills are available?" and check your skill is in the answer.
3. `claude --debug`, then search the log for the skills line (how many loaded) and for a "Skill listing over budget" warning. Over budget means some descriptions are cut, the least invoked first.
4. `claude plugin validate .claude/skills`: a malformed header still lets `/name` work but leaves Claude with empty metadata to match against.
5. Check the file is `.claude/skills/<name>/SKILL.md`, not a loose `<name>.md`, and that the header starts on the first line.
## Fix, then prove it
1. Rewrite the description with `description-template.md`.
2. Fresh session, same plain request, several runs: does the Skill call come first now?
3. Baseline: set the skill to `"off"` in `skillOverrides` (saved to `.claude/settings.local.json` from `/skills`), ask again, then switch it back on.
## Hold it
1. Write twenty requests: ten that should load the skill, ten near misses (`evals.json` is our set).
2. Run each in its own fresh session and note whether the skill loaded (`trigger_test.py`, `tally.py`).
3. Re-run the list every time you change the description.
4. For a skill that must never miss: call it by name, or add a UserPromptSubmit hook (`hook.json`).
trigger-checklist.md on GitHub
스무 개 요청 테스트 세트. 스킬을 로드해야 하는 요청 열 개와 로드하면 안 되는 아슬한 요청 열 개입니다. 쿼리는 여러분의 레포에 맞게 고치세요.
evals.json
[
{"query": "what did we ship this week?", "should_trigger": true},
{"query": "can you put together the release notes for this week", "should_trigger": true},
{"query": "what changed in the repo since monday", "should_trigger": true},
{"query": "write the changelog for the commits we merged this week", "should_trigger": true},
{"query": "I need a summary of this week's commits for the team update on friday", "should_trigger": true},
{"query": "draft the weekly notes of what got fixed, grouped by area", "should_trigger": true},
{"query": "list what landed this week, router and css changes apart", "should_trigger": true},
{"query": "give me something to post in the team channel about this week's fixes", "should_trigger": true},
{"query": "recap the work merged since last monday", "should_trigger": true},
{"query": "what's new in hono this week? short version for the release post", "should_trigger": true},
{"query": "explain what commit 08807ce1 changes in the trie router", "should_trigger": false},
{"query": "the jsx tests are failing on cloneElement, can you fix them", "should_trigger": false},
{"query": "write a commit message for my staged changes", "should_trigger": false},
{"query": "who wrote most of the serve-static code?", "should_trigger": false},
{"query": "add a CHANGELOG entry format section to CONTRIBUTING.md", "should_trigger": false},
{"query": "show me the diff between main and my branch", "should_trigger": false},
{"query": "bump the version in package.json to the next patch", "should_trigger": false},
{"query": "why does the css helper minify url() like that?", "should_trigger": false},
{"query": "squash my last three commits into one", "should_trigger": false},
{"query": "review this PR for breaking changes before I merge it", "should_trigger": false}
]
각 요청을 자기만의 새 claude -p 세션에서 실행하고 Skill 도구가 호출됐는지 세는 스크립트.
trigger_test.py
"""Twenty-prompt trigger test for one skill, the method skill-creator describes, run on the real skill folder.
For each query: a fresh `claude -p` in the repo; the run counts as loaded if the Skill tool is called with the skill's name
before the run ends (stopped early as soon as it is). Usage:
python3 trigger_test.py <repo> <skill-name> <SKILL.md to install> <evals.json> <out.json> [workers]"""
import json, os, shutil, subprocess, sys
from concurrent.futures import ThreadPoolExecutor
repo, name, skill_md, evals_path, out = sys.argv[1:6]
workers = int(sys.argv[6]) if len(sys.argv) > 6 else 4
dest = os.path.join(repo, '.claude', 'skills', name)
os.makedirs(dest, exist_ok=True)
shutil.copy(skill_md, os.path.join(dest, 'SKILL.md'))
evals = json.load(open(evals_path))
allowed = ['Bash(rtk git log *)', 'Bash(git log *)', 'Bash(git show *)', 'Bash(date *)', 'Skill', 'Read']
def run(item):
cmd = ['claude', '-p', item['query'], '--output-format', 'stream-json', '--verbose', '--allowedTools', *allowed]
p = subprocess.Popen(cmd, cwd=repo, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
loaded = False
try:
for line in p.stdout:
try:
d = json.loads(line)
except ValueError:
continue
if d.get('type') == 'assistant':
for c in d['message'].get('content', []):
if c.get('type') == 'tool_use' and c['name'] == 'Skill' and name in json.dumps(c.get('input', {})):
loaded = True
if loaded or d.get('type') == 'result':
break
finally:
if p.poll() is None:
p.kill()
return {**item, 'loaded': loaded, 'pass': loaded == item['should_trigger']}
with ThreadPoolExecutor(workers) as ex:
results = list(ex.map(run, evals))
should = [r for r in results if r['should_trigger']]
shouldnt = [r for r in results if not r['should_trigger']]
summary = {'loaded_when_it_should': sum(r['loaded'] for r in should), 'of_should': len(should),
'loaded_when_it_should_not': sum(r['loaded'] for r in shouldnt), 'of_should_not': len(shouldnt),
'passed': sum(r['pass'] for r in results), 'total': len(results)}
json.dump({'skill': name, 'skill_md': skill_md, 'summary': summary, 'results': results}, open(out, 'w'), indent=2)
for r in results:
print(f"[{'PASS' if r['pass'] else 'FAIL'}] {'loaded ' if r['loaded'] else 'skipped'} expected={'load' if r['should_trigger'] else 'skip'}: {r['query']}")
print(json.dumps(summary))
claude -p --output-format stream-json 실행 결과 폴더를 위한 작은 집계 스크립트.
tally.py
"""Count whether a `claude -p --output-format stream-json` run loaded a given skill. Usage: python3 tally.py <skill> run1.jsonl [run2.jsonl ...]"""
import json, sys
skill, files = sys.argv[1], sys.argv[2:]
fired = 0
for f in files:
hit = False
for line in open(f):
try:
d = json.loads(line)
except ValueError:
continue
if d.get('type') == 'assistant':
for c in d['message'].get('content', []):
if c.get('type') == 'tool_use' and c['name'] == 'Skill' and skill in json.dumps(c.get('input', {})):
hit = True
fired += hit
print(f"{f}: {'loaded' if hit else 'skipped'}")
print(f"{skill}: loaded in {fired} of {len(files)} runs")
반드시 실행되게 하는 대비책: 스킬 이름을 짚어 주는 UserPromptSubmit 훅.
hook.json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "echo 'If the user asks about release notes, a changelog or what shipped this week, use the release-notes skill.'"
}
]
}
]
}
}
월요일에 할 일
- 지난주에 가장 많이 다시 친 프롬프트와, 거기서 원하는 정확한 출력을 적어 둔다.
- 이름, description, 짧은 번호 매긴 본문으로
.claude/skills/<name>/SKILL.md를 만든다. - 이름으로 테스트한 다음, 새 세션을 열고 이름 없이 평범한 말로 작업을 부탁한다.
- 건너뛰면 아무것도 고치기 전에
/skills,claude --debug,claude plugin validate .claude/skills를 읽는다. -
description-template.md로 description을 고쳐 쓴다. 하는 일, 쓰는 때, 여러분이 치는 말. - 새 세션 여러 번에서 다시 테스트하고, 한 번은
skillOverrides에서 스킬을"off"로 하고, 한 번은 다시 켠다. - 스무 개 요청을 쓰고
trigger_test.py로 돌리고, description을 바꿀 때마다 다시 돌린다. - 절대 놓치면 안 되는 스킬은 정한다. 이름으로 부를지, 훅을 추가할지.
우리가 측정한 것
이 수치는 오픈소스 Hono 레포(s20)의 스크래치 클론에서 한 작은 데모 실행입니다. Claude Code 2.1.294, 요청 "what did we ship this week?", 대상은 월요일 2026-10-12 이후의 머지가 아닌 conventional commit 8개입니다. 벤치마크가 아닙니다. 여러분의 모델, 스킬, 레포에 따라 숫자는 달라집니다.
| 테스트 | description | 결과 |
|---|---|---|
| 평범한 요청, 새 세션 5번 | 요약: "Generates changelog documents from repository history." | 5번 중 0번 로드 |
| 평범한 요청, 새 세션 5번 | 고쳐 씀: 하는 일 + 쓰는 때 + 우리 말 | 5번 중 5번 로드 |
이름으로 호출 (/release-notes) |
요약 | 2번 중 2번 실행 |
skillOverrides를 끄기, 다음에 켜기 |
고쳐 씀 | 끔: 건너뜀. 켬: 로드 |
| 스무 개 요청 테스트, 고치기 전 | 요약 | 로드해야 하는 요청 10개 중 4개 로드, 아슬한 요청 10개 중 0개, 20개 중 14개 통과 |
| 스무 개 요청 테스트, 고친 후 | 고쳐 씀 | 로드해야 하는 10개 중 10개, 아슬한 요청 10개 중 0개, 20개 중 20개 통과 |
| 훅과 옛 description | 요약 | 1번 중 1번 로드 |
고치기 전에 로드된 4개 요청은 "release notes", "changelog", "notes", "release"를 담은 것들이었습니다. 키워드 효과가 눈앞에 드러난 겁니다. 고친 뒤에는 넓어진 description이 아슬한 요청을 하나도 끌어들이지 않았습니다.
찾아낸 경고와 솔직한 해석. --debug에서 우리 환경은 "Skill listing over budget: 296 skills, 144833 chars > 30000 budget"을 출력했습니다. 우리 환경은 스킬이 너무 많아서 description이 잘립니다. 그래도 프로젝트 스킬은 로드됐고("Loaded 43 unique skills", 그중 하나가 프로젝트 스킬), 옛 description도 잘림에서 살아남았습니다. 스무 개 요청 테스트에서 description에만 있는 단어 "changelog"를 담은 요청은 스킬을 로드했습니다. 예산이 새 스킬을 일반적으로 어떻게 다루는지는 증명하지 못했습니다. 스킬을 많이 갖고 있다면 직접 확인해 보세요. 우리 환경의 /skills는 277개 스킬을 나열했고, release-notes는 프로젝트 스킬로, 켜진 상태로, user-only 배지 없이 표시됐습니다.
도구 자체에 대한 주의: Anthropic의 평가 스크립트는 우리 버전에서 스킬을 전혀 인식하지 못해서, 같은 요청을 키트의 짧은 스크립트로 돌렸습니다. 방법은 같습니다. 요청마다 새 claude -p 세션 하나입니다.
더 나아가기
반대 문제도 고치기. 너무 많이 맞는 description은 스킬이 나서면 안 될 곳에서 나서게 만듭니다. 그래서 테스트 세트의 절반이 아슬한 요청입니다. 떠오르는 모든 단어가 아니라 실제로 쓰는 단어를 넣으세요. s11
옵티마이저가 고쳐 쓰기를 제안하게 하기. skill-creator의 루프는 새 description을 제안하고, 튜닝에 쓴 쿼리에 과적합하지 않도록 보류한 테스트 점수가 가장 좋은 것을 남깁니다. 여러분의 스무 개 요청을 평가 세트로 쓰세요. s4
실질적인 테스트 프롬프트 쓰기. skill-creator는 Claude가 스스로 쉽게 처리하지 못하는 작업에서만 스킬을 참고한다고 적습니다. 그래서 한 단계짜리 사소한 요청은 완벽하게 맞아도 스킬이 발동하지 않을 수 있습니다. s4
단순한 훅을 넘어서기. 위의 서드파티 테스트에서, Claude에게 각 스킬을 yes나 no로 나열한 뒤 발동하게 하는 강제 평가 훅은 100%에 도달했지만 수동적인 리마인더는 그러지 못했고, 네이티브 type: "prompt" 훅은 훅이 없는 것과 같게 동작했습니다. 우리 훅은 단순한 버전이고 데모 실행 한 번입니다. s5 훅 유도가 긴 세션에서 약해질 수 있다는 보고도 있습니다. s10
본문은 짧게. 위의 감사에서 고장 난 스킬 하나는 1,002줄에 MANDATORY가 도배돼 있었고, 건강한 것들은 짧았습니다. s8
다루지 않은 것. 플러그인 스킬과 플러그인 평가 채점기는 다루지 않았습니다. 이는 플러그인 작성자에게만 중요합니다. 스킬을 맨 모델과 몇 달에 걸쳐 비교하는 방법도 다루지 않았습니다. 약한 모델용으로 쓴 스킬이 죽은 짐이 될 수 있다는 보고도 있으니 가끔 다시 테스트하세요. 이건 측정하지 않았습니다.
FAQ
/release-notes는 되는데 평범한 말은 왜 안 되나요?
이름을 치면 매칭을 건너뜁니다. 평범한 말에서는 Claude가 매칭할 재료가 이름과 description 한 줄뿐이라서, 요청과 겹치는 단어가 없는 요약은 건너뛰어집니다. s1
스킬을 고친 뒤 Claude Code를 재시작해야 하나요?
아니요. Claude Code는 ~/.claude/skills/나 .claude/skills/ 아래에 추가하거나 고친 스킬을 현재 세션에서 바로 집어 듭니다. 세션이 시작될 때 없던 최상위 skills 디렉터리를 만들었다면 /reload-skills를 실행하세요. s1
description은 얼마나 길어도 되나요?
description과 when_to_use는 목록에서 합쳐서 1,536자에서 잘리므로 핵심 사용처를 앞에 두세요. Anthropic의 플랫폼 가이드는 Claude Code 밖에서 업로드하는 스킬의 description을 따로 1,024자로 제한합니다. s1 s3
새 세션 다섯 번이면 수정을 믿기에 충분한가요? 아니요. 방향을 보여 줄 뿐입니다. 우리 결과는 레포 하나에서 한 데모라서, 키트는 description을 바꿀 때마다 다시 돌릴 수 있는 스무 개 요청 테스트로 끝납니다.
스킬 대신 훅을 써야 할 때는 언제인가요? Claude가 매칭을 알아챌 때만이 아니라 규칙이 매번 지켜져야 할 때입니다. 훅은 모든 메시지에 줄을 하나 더하므로, 반드시 실행돼야 하는 소수의 스킬에만 쓰세요. s9
AIDive