8月23日のリリースノートに、一行だけ気になる修正がありました。同梱スキルのエイリアスが、同名のユーザースキルやプロジェクトスキルに影を作られて「Unknown command」を返す問題が直った、という記述です。
自分には関係のない話だと思いました。私が使っているスキルはどれも名前を分けて付けたつもりでいたからです。
念のため数えてみました。プロジェクト側に 42 本、ユーザー側に 24 本、合わせて 66 本。そのうち 5 本が同じ名前で二か所に存在していました 。
そこまでは、まだ想定の範囲でした。問題はその先です。5 組すべてで SKILL.md が sha256 まで一致していたのに、そのうち 2 組は同梱ファイルの数が違っていました 。
名前の衝突は、それ自体では事故になりません
どちらが選ばれるかという解決順序の話は、以前 呼んだはずのスキルが何も返さない — 起動前に名前の解決順序を確定させる で扱いました。あのときの結論は「実効名の表を起動前に作る」でした。
今回ぶつかったのは、その先にある層です。実効名が確定していても、選ばれた実体が完全とは限りません 。
同名のスキルが二か所にあるとき、多くの人はまず本文を見比べます。私もそうしました。
diff .agents/skills/seo-audit/SKILL.md ~/.claude/skills/seo-audit/SKILL.md
# 出力なし(差分ゼロ)
差分ゼロ。バイト数も 10,688 で一致。ここで安心して閉じました。
閉じてから、ディレクトリ全体を見ていないことに気づきました。
欠けていたのは本文ではなく、本文が名指ししているファイルでした
同じ 2 つのディレクトリを、ファイル単位で並べます。
( cd .agents/skills/seo-audit && find . -type f | sort )
# ./SKILL.md
# ./evals/evals.json
# ./references/ai-writing-detection.md
( cd ~/.claude/skills/seo-audit && find . -type f | sort )
# ./SKILL.md
本文は同一。付随するファイルは 3 対 1。
そして SKILL.md の本文には、次の一行が入っています。
references/ai-writing-detection.md
つまり、ユーザースコープ側が選ばれた場合、スキルは実在しないファイルを読みに行くことになります 。SKILL.md が完全に一致しているせいで、diff でも sha256sum でも、この状態は検出できません。
欠けていた references/ai-writing-detection.md は 6,528 バイト。SKILL.md 本体の約 61% に相当する分量が、参照されているのに存在しない状態でした。
比較の観点 プロジェクト側 ユーザー側 気づけるか
SKILL.md の内容 10,688 バイト 10,688 バイト(同一) 気づけない
SKILL.md の sha256 一致 一致 気づけない
ディレクトリ内のファイル数 3 1 気づける
本文が名指しする参照の実在 すべて実在 1 件が不在 気づける
上の 2 行だけを見て運用していたのが、私の見落としでした。
検出器を三度書き直して、4 件が 2 件になりました
66 本すべてに同じ検査をかけようと思い、まず一行の grep で済ませようとしました。
grep -oE '(references|scripts|assets|evals|templates)/[A-Za-z0-9_./-]+\.(md|py|sh|json|txt|csv)' SKILL.md
これで全スキルを回すと、欠落は 4 件 。内訳を開いて、2 件が誤りだと分かりました。
拾っていたのは _scripts/ukiyoe_batch.py という記述です。私が個人開発で使っている壁紙アプリ側の運用スキルが、リポジトリ外の別ディレクトリを指していたもので、スキル同梱物ではありません。正規表現が _scripts の先頭一文字を無視して scripts/ にマッチしていました。ディレクトリ名を列挙して照合するときの、よくある落とし穴です。
先読みを足して直します。あわせて、コードフェンスの中は例示であって同梱物とは限らないので除外し、拡張子に mjs を加えました。
再実行すると、また 4 件 。件数は同じなのに、中身が入れ替わっていました。
✗ antigravitylab-site → scripts/generate-content.mjs
✗ rorklab-site → scripts/generate-content.mjs
✗ seo-audit → references/ai-writing-detection.md
✗ skill-creator → evals/evals.json
上の 2 件は、SKILL.md の表に書かれたビルドコマンドでした。
| ビルドコマンド | `node scripts/generate-content.mjs && ...` |
サイト側リポジトリのスクリプトを node で呼ぶ記述であって、スキルに同梱されるファイルではありません。前段では拡張子が対象外だったので、たまたま出ていなかっただけです。
同時に、フェンス除外を入れたことで先ほどの _scripts/ukiyoe_batch.py が消えました。件数が 4 のまま揃ってしまったので、数字だけを見ていたら「直った」と誤読していたはずです 。
ここが今回いちばん腹に落ちた点でした。検出器の出力は件数ではなく、必ず一件ずつ開いて読む。件数の一致は、正しさの証拠になりません。
三度目で、実行コマンドの引数を除外する条件を足しました。結果は 2 件 。これが真の欠落です。
版 加えた条件 報告件数 うち誤検出
v1 正規表現のみ 4 2(_scripts/ の部分一致)
v2 先読み・フェンス除外・mjs 追加 4 2(実行コマンドの引数)
v3 実行コマンドの引数を除外 2 0
同梱物の実在を検査する
三度目に落ち着いた実装です。そのまま動きます。
#!/usr/bin/env python3
"""SKILL.md が名指ししている同梱ファイルの実在を検査する(v3)。
v1(素朴な正規表現)と v2 では、外部パスや実行コマンドの引数を
同梱ファイルへの参照と取り違えていた。v3 では次の3つを除外する。
(1) 直前が _ や / の場合 … 別ディレクトリの一部(例: _scripts/x.py)
(2) コードフェンスの中 … 例示であって同梱物とは限らない
(3) 実行コマンドの引数 … node scripts/build.mjs のような呼び出し
"""
import os, re, sys
BUNDLE_DIRS = ( "references" , "scripts" , "assets" , "evals" , "templates" , "examples" , "data" )
EXTS = ( "md" , "py" , "sh" , "json" , "txt" , "csv" , "yaml" , "yml" , "js" , "mjs" )
RUNNERS = ( "node" , "python" , "python3" , "bash" , "sh" , "npx" , "deno" , "ruby" , "pnpm" , "yarn" )
REF_RE = re.compile(
r " (?<! [ A-Za-z0-9_/- ] ) (?: \. / ) ? (?: " + "|" .join( BUNDLE_DIRS )
+ r ")/ [ A-Za-z0-9_./- ] + \. (?: " + "|" .join( EXTS ) + r ")"
)
RUNNER_RE = re.compile( r " (?: " + "|" .join( RUNNERS ) + r ") \s + $ " )
FENCE_RE = re.compile( r " ^\s * (?: ``` | ~~~ ) " )
def extract_refs (text):
refs, inside = set (), False
for line in text.splitlines():
if FENCE_RE .match(line):
inside = not inside
continue
if inside:
continue
for m in REF_RE .finditer(line):
if RUNNER_RE .search(line[: m.start()]): # (3) 実行コマンドの引数
continue
refs.add(m.group( 0 ).lstrip( "./" ))
return sorted (refs)
def main (roots):
skills = refs_total = 0
missing = []
for root in roots:
if not os.path.isdir(root):
print ( f "走査対象が存在しません: { root } " , file = sys.stderr)
sys.exit( 2 ) # 走査漏れを成功として扱わない
for name in sorted (os.listdir(root)):
d = os.path.join(root, name)
md = os.path.join(d, "SKILL.md" )
if not os.path.isfile(md):
continue
skills += 1
with open (md, encoding = "utf-8" , errors = "replace" ) as f:
refs = extract_refs(f.read())
refs_total += len (refs)
for r in refs:
if not os.path.isfile(os.path.join(d, r)):
missing.append((name, r))
print ( f "走査したスキル: { skills } 本 / 同梱参照: { refs_total } 件" )
for name, r in missing:
print ( f " ✗ { name } → { r } " )
print ( f "欠落: { len (missing) } 件" )
return 1 if missing else 0
if __name__ == "__main__" :
sys.exit(main(sys.argv[ 1 :]))
手元の 66 本に対する実行結果です。
走査したスキル: 66 本 / 同梱参照: 51 件
✗ seo-audit → references/ai-writing-detection.md
✗ skill-creator → evals/evals.json
欠落: 2 件
所要 129 ミリ秒。CI に常設しても負担になりません。
sys.exit(2) を入れているのは、走査対象のディレクトリが存在しないときに黙って「欠落ゼロ」を返させないためです。パスを一つ書き間違えただけで検査が空振りし、しかも成功として通ってしまう。この失敗は以前に一度やっているので、走査漏れは終了コードで区別するようにしています。
$ python3 skill_refs.py .agents/skills /nonexistent/skills
走査対象が存在しません: /nonexistent/skills
exit=2
同名スキルの完全性を比べる
もう一つ、名前が重なっている組だけを取り出して、本文の一致と同梱物の数を並べる小さな道具を作りました。
#!/usr/bin/env python3
"""同名スキルが複数スコープにあるとき、中身の完全性が揃っているかを比べる。"""
import hashlib, os, sys
from collections import defaultdict
def digest (path):
with open (path, "rb" ) as f:
return hashlib.sha256(f.read()).hexdigest()[: 12 ]
def inventory (root):
out = {}
for name in sorted (os.listdir(root)):
d = os.path.join(root, name)
md = os.path.join(d, "SKILL.md" )
if not os.path.isfile(md):
continue
files = [
os.path.relpath(os.path.join(dp, fn), d)
for dp, _, fns in os.walk(d)
for fn in fns
if not fn.startswith( "." ) # .DS_Store 等は数に入れない
]
out[name] = { "sha" : digest(md), "files" : sorted (files)}
return out
def main (roots):
seen = defaultdict( list )
for root in roots:
if not os.path.isdir(root):
print ( f "走査対象が存在しません: { root } " , file = sys.stderr)
sys.exit( 2 )
for name, info in inventory(root).items():
seen[name].append(info)
dupes = {n: v for n, v in seen.items() if len (v) > 1 }
print ( f "同名スキル: { len (dupes) } 件" )
risky = 0
for name, entries in sorted (dupes.items()):
verdict = "SKILL.md 一致" if len ({e[ "sha" ] for e in entries}) == 1 else "SKILL.md 相違"
counts = [ len (e[ "files" ]) for e in entries]
flag = ""
if max (counts) - min (counts):
# 本文が同じでも同梱物が違えば、選ばれた側だけが参照先を失う
risky += 1
only = set ().union( * [ set (e[ "files" ]) for e in entries]) - set .intersection(
* [ set (e[ "files" ]) for e in entries]
)
flag = f " ← 片側のみ: { ', ' .join( sorted (only)) } "
print ( f " { name } : { verdict } / ファイル数 { counts }{ flag } " )
print ( f "本文一致でも同梱物が欠ける組: { risky } 件" )
return 1 if risky else 0
if __name__ == "__main__" :
sys.exit(main(sys.argv[ 1 :]))
実行結果です。
同名スキル: 5 件
find-skills: SKILL.md 一致 / ファイル数 [1, 1]
frontend-design: SKILL.md 一致 / ファイル数 [2, 1] ← 片側のみ: LICENSE.txt
seo-audit: SKILL.md 一致 / ファイル数 [3, 1] ← 片側のみ: evals/evals.json, references/ai-writing-detection.md
sleek-design-mobile-apps: SKILL.md 一致 / ファイル数 [1, 1]
web-design-guidelines: SKILL.md 一致 / ファイル数 [1, 1]
本文一致でも同梱物が欠ける組: 2 件
所要 223 ミリ秒。
frontend-design の欠落は LICENSE.txt なので実害はありません。ただ、実害の有無を判定するには、まず差分が見えている必要があります 。この道具の役目はそこまでで、どちらが致命的かは人が決めます。
隠れた .DS_Store を数から除いているのは、macOS で作業していると片側にだけ紛れ込み、毎回「ファイル数が違う」と鳴り続けるからです。ノイズで鳴る検査は、そのうち誰も見なくなります。
二か所に置くのをやめるか、正本を決めるか
見つけたあとの選択肢は三つありました。
対処 向いている場面 引き受ける手間
片方を消す 片方が明らかに劣化コピーである 消した側に依存していた設定の確認
接頭辞で名前を分ける 意図的に別物として併存させたい 呼び出し側の書き換え
正本を決めて同期する 同じものを複数の場所から使いたい 同期の検査を CI に常設する
私はこの中の 3 番目を採りました。プロジェクト側を正本とし、上の 2 本のスクリプトを push 前の検査に入れています。1 番目に踏み切れなかったのは、どちらが選ばれるかの解決順序が環境によって変わり得るからです。選ばれない側を消すのではなく、どちらが選ばれても壊れない状態を保つほうが安全だと判断しました。
判断が分かれるとすれば、複数人で採用する場合だと思います。手元の設定が人によって違う環境では、2 番目の「名前を分ける」がいちばん誤解を生みません。名前が一つなら実体も一つ、という状態は、検査で守るより設計で守るほうが軽く済みます。
同じ形の見落としは、たぶん他にもあります
今回の教訓を一般化すると、こうなります。
本体が一致していることは、成果物が完全であることを意味しません。 SKILL.md、package.json、設定ファイル。どれも「同じものが二か所にある」状態を作りやすく、そして本体だけを見比べて済ませたくなります。
参照が本文に書かれている以上、参照先の実在は機械で確かめられます。確かめられるものを目視に任せていたのが、今回の私の怠慢でした。
SKILL.md 側の設計そのものについては SKILL.md の後半が読まれていなかった話 — 200行に収める設計と progressive disclosure に書いています。本文を短く保ち、詳細を references/ に逃がす設計は有効ですが、逃がした先が消える経路があることを、今回まで想像していませんでした。編集した SKILL.md を再起動なしに差し替える手順は 編集した SKILL.md が反映されない — /reload-skills と .claude/skills 自動ロードで再起動なしに差し替える にまとめてあります。
明日、最初に走らせる一つ
自分のスキルディレクトリを二つ以上持っているなら、まず同名の組だけを数えてください。
comm -12 <( ls .agents/skills | sort ) <( ls ~/.claude/skills | sort )
出力が空なら、この記事の話は当面関係ありません。一行でも出たなら、次の順に進めてください。
その組の find . -type f を並べ、ファイル数が揃っているかを見ます。ここで差が出なければ、以降は不要です。
差が出た組について、SKILL.md 本文が欠けている側のファイルを名指ししているかを確かめます。名指ししていなければ、実害はありません。
名指ししていた組だけを、正本の決定か名前の分離のどちらかで畳みます。ここまで来た組は、私の場合 5 件中 1 件でした。
個人開発で扱う規模でも、この 3 手順は 1 分で終わります。私の場合は 5 行出て、そのうち 2 行が本物でした。
読んでくださってありがとうございました。私自身、検出器の件数が二度とも 4 で揃ったときに手を止めずに中身を開いたのは、半分は偶然でした。同じ幸運に頼らずに済むよう、こうして手順の形にしています。