8月18日に入った CLI 1.1.14 の変更点を眺めていて、inheritCustomizations のところで手が止まりました。markdown で定義したエージェントが skills・rules・plugins・subagents・MCP サーバーを引き継ぐかどうかが、スイッチ一つで決まるようになった、という一行です。
これまでは種類ごとに既定値がばらついていました。私も「継承しているつもりで一部だけ外れている」状態を、実際に何度か踏んでいます。統一されたのはありがたい。ただ、統一された瞬間に、これまで曖昧だった判断が自分の手元に返ってきました。
全部渡すのか、全部止めて必要なものだけ明示するのか。
最初に思ったのは「スキルを増やしすぎたから、重くなる。false にしよう」でした。この直感は、実際に数えてみたら外れていました。
継承がひとつのスイッチになって、判断はむしろ難しくなりました
種類ごとに既定値が違っていた頃は、判断が分散していたぶん、間違いも部分的でした。MCP サーバーだけ継承されていない、といった形で表に出るので、気づいたところだけ直せばよかったのです。
一本化されると、間違いも一括になります。true にして群ごと渡すか、false にして一本ずつ列挙するか。どちらを選んでも、影響はエージェント全体に及びます。
だから、決める前に自分が何を持っているかを見ることにしました。手元には個人開発のアプリ運用とサイト運用が混ざったワークスペースがあり、スキルは長いあいだ足すだけ足して、棚卸ししていませんでした。
継承候補を棚卸しする 60 行
まず、スキルディレクトリを渡すと本文量と description の状態を並べるだけのスクリプトを書きました。特別なことはしていません。SKILL.md のフロントマターから description を取り出し、語に割って重なりを見るだけです。
日本語混じりの description を扱うので、英数字語と CJK の 2-gram の両方を語として数えています。形態素解析器を入れるほどの精度は要らず、「似ているかどうか」の当たりがつけば十分だったためです。
#!/usr/bin/env python3
"""継承候補になっているスキルを棚卸しする。
使い方: python3 skill_inventory.py <スキルを置いているディレクトリ> [...]
各ディレクトリ直下の <name>/SKILL.md を1件のスキルとして数える。
"""
import re
import sys
import statistics
from pathlib import Path
from collections import Counter, defaultdict
FM = re.compile(r"\A---\r?\n(.*?)\r?\n---\r?\n", re.S)
DESC = re.compile(r"^description:\s*(.*)$", re.M)
# 日本語混じりでも壊れないよう、英数字語と CJK の 2-gram を両方とも語として扱う
WORD = re.compile(r"[A-Za-z][A-Za-z0-9_-]{2,}")
CJK = re.compile(r"[ぁ-んァ-ヶ一-龠]{2,}")
def tokens(text):
t = {w.lower() for w in WORD.findall(text)}
for run in CJK.findall(text):
for i in range(len(run) - 1):
t.add(run[i:i + 2])
return t
def collect(roots):
skills = []
for root in roots:
for path in sorted(Path(root).glob("*/SKILL.md")):
raw = path.read_text(encoding="utf-8", errors="replace")
fm = FM.match(raw)
desc = ""
if fm:
m = DESC.search(fm.group(1))
if m:
# YAML のクォートは剥がしておく。付いていない環境も多い
desc = m.group(1).strip().strip('"').strip("'")
skills.append({"name": path.parent.name, "body": len(raw), "desc": desc})
return skills
def main(roots):
skills = collect(roots)
if not skills:
print("SKILL.md が1件も見つかりませんでした")
return 1
body = [s["body"] for s in skills]
desc_total = sum(len(s["desc"]) for s in skills)
dead = [s["name"] for s in skills if not s["desc"]]
print(f"スキル数 : {len(skills)}")
print(f"本文合計 : {sum(body):,} 文字")
print(f"本文の中央値 / 最大 : {int(statistics.median(body)):,} / {max(body):,} 文字")
# desc_total が 0 のときは 1/0 で落ちるため、先に分岐しておく
ratio = sum(body) // desc_total if desc_total else 0
print(f"description 合計 : {desc_total:,} 文字 (本文の 1/{ratio})")
print(f"description 欠落 : {len(dead)} 件 {dead}")
tok = {s["name"]: tokens(s["desc"]) for s in skills if s["desc"]}
freq = Counter()
for t in tok.values():
freq.update(t)
overlap = defaultdict(list)
for a, ta in tok.items():
for b, tb in tok.items():
if a >= b or not ta or not tb:
continue
j = len(ta & tb) / len(ta | tb)
if j >= 0.25:
overlap[round(j, 2)].append((a, b))
print("\n--- description が 25% 以上重なる組 ---")
if not overlap:
print("なし")
for j in sorted(overlap, reverse=True):
for a, b in overlap[j]:
print(f" {j:.2f} {a} <-> {b}")
print("\n--- 5本以上の description に出てくる語(選択の手がかりにならない語) ---")
noisy = [(w, c) for w, c in freq.most_common(24) if c >= 5]
print(" " + ", ".join(f"{w}({c})" for w, c in noisy))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:] or ["."]))a >= b で組を片側だけに絞っているのは、同じ組を二度数えないためです。しきい値の 0.25 は、手元で回してみて「無関係な組がほとんど混ざらない」最小値でした。ここは環境によって動かしてください。