深夜に回しているスケジュール実行のログを朝に開いたら、進行の記録がほとんど残っていませんでした。
失敗した形跡はありません。エラーも、スタックトレースも、タイムアウトの記録も残っていませんでした。ただ、呼んだはずの手順が一度も動いていないのです。
原因にたどり着くまでに、私は権限設定とネットワークの許可リストを先に疑いました。どちらも無実でした。実際に起きていたのは、もっと素朴なことです。プラグインから配布していたスキルの名前が、端末の組み込みコマンドと衝突していました。
対話セッションでは、同じ名前を打つと組み込み側が応答します。ですから「動いている」ように見えます。非対話セッションでは、その呼び出しが誰にも解決されないまま落ちていました。
この不具合そのものは 2026年8月6日付の更新で修正されています。ただ、修正されたのは特定の症状であって、構造は残ります。呼び出し名は複数のソースから供給され、実効名は解決順序で決まります。その順序を知らないまま名前を付ければ、また同じ形で静かに壊れます。
そこで、実効名の表を起動前に作る道具を書きました。書いて、走らせて、その検査器自身が取りこぼしていたものを見つけました。
呼び出し名は一覧ではなく解決順序で決まる
スキルの供給元は一つではありません。プロジェクト直下、ユーザーのホーム、プラグイン経由の配布。組織で運用していれば、そこに管理者が配る束が加わります。
私たちはこれを「使えるスキルの一覧」として捉えがちです。実際には一覧ではなく、同じ名前空間に複数のソースが書き込む多重定義です。
| 供給元 | 置き場所 | 誰が変更するか | 変更に気づけるか |
| 組み込みコマンド | 端末本体 | ベンダー | 更新履歴を読めば |
| プロジェクト | リポジトリ内 | 自分・チーム | 差分に出る |
| ユーザー | ホーム配下 | 自分 | 版管理外なら気づけない |
| プラグイン | 導入したプラグイン | 配布元 | ほぼ気づけない |
厄介なのは下の2行です。ホーム配下のスキルはリポジトリの差分に出ません。プラグインが提供するスキルは、配布元が新しい名前を追加した瞬間に、こちらの断りなく名前空間へ入ってきます。
つまり、自分の書いたスキルが今日も同じ名前で呼べるかどうかは、自分だけでは決められません。
ここに組み込みコマンドが加わります。組み込み側はいちばん強く、そしていちばん入れ替わりが速い場所でもあります。8月に入ってからだけでも、手動で起動できるスキルの口が増えています。昨日まで空いていた名前が、今日は埋まっていることもあります。名前空間とは、そういう性質のものなのだと思います。
対話と非対話で挙動が分かれる理由
対話セッションでは、入力した名前は端末の入力層をまず通ります。組み込みコマンドと一致すれば、そこで処理が終わります。人間が打っている以上、応答がないことにはすぐ気づきます。
非対話セッションには、その入力層がありません。呼び出しはエージェント側の解決に委ねられます。ここで名前が組み込み側に予約されていると、エージェントの持つスキル表とベンダーの予約語がずれ、呼び出しが宙に浮きます。
そして無人実行には、宙に浮いたことを咎める人がいません。
私自身、この構造を理解するまで「権限が足りていないはずだ」という筋で3時間ほど調べていました。権限の問題であれば拒否の記録が残ります。何も記録が残っていないという事実こそが、権限ではないという手がかりだったのですが、そこに気づくのが遅れました。
実効名の表を起動前に作る
必要なのは、実行のたびに「この名前を呼んだら、どのファイルが動くのか」を確定させることです。
方針は3つに絞りました。
- すべての供給元を走査し、実効名を1つの表に集約すること
- 組み込みコマンドと衝突する名前は、警告ではなく停止として扱うこと
- 走査できなかった供給元があれば、それ自体を失敗として報告すること
3つ目が後で効いてきます。
以下がそのまま動く実装です。Node.js の標準モジュールだけで完結させました。依存を足すと、無人実行の環境で入っていない事故が起きるためです。
#!/usr/bin/env node
// name-preflight.mjs — スキルの「呼び出し名」を起動前に解決し、衝突を落とす
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
import { join, basename } from "node:path";
// 端末組み込みコマンド。自分の環境の /help 出力から起こし、CI では固定リストとして版管理する
const BUILTIN = new Set([
"help", "clear", "compact", "config", "cost", "doctor", "exit",
"init", "login", "logout", "mcp", "memory", "model", "permissions",
"release-notes", "resume", "review", "rewind", "status", "vim",
]);
// 優先度: 数字が小さいほど先に解決される想定。実環境の観測結果で必ず上書きすること
const SOURCES = [
{ id: "project", rank: 1, root: ".claude/skills" },
{ id: "user", rank: 2, root: join(process.env.HOME ?? "", ".claude/skills") },
{ id: "plugin", rank: 3, root: ".claude/plugins", nested: true },
];
const missingRoots = [];
function readName(dir) {
const f = join(dir, "SKILL.md");
if (!existsSync(f)) return null;
const head = readFileSync(f, "utf8").split(/\n---\s*\n/)[0];
const m = head.match(/^name:\s*(.+)$/m);
return (m ? m[1] : basename(dir)).trim().replace(/^["']|["']$/g, "");
}
function scanFlat(root, source, owner) {
if (!existsSync(root)) { missingRoots.push(`${source}:${root}`); return []; }
return readdirSync(root)
.map((e) => join(root, e))
.filter((p) => statSync(p).isDirectory())
.map((p) => ({ name: readName(p), source, owner, path: p }))
.filter((r) => r.name);
}
function collect() {
const out = [];
for (const s of SOURCES) {
if (!s.nested) { out.push(...scanFlat(s.root, s.id, s.id)); continue; }
if (!existsSync(s.root)) { missingRoots.push(`${s.id}:${s.root}`); continue; }
for (const plug of readdirSync(s.root)) {
out.push(...scanFlat(join(s.root, plug, "skills"), s.id, plug));
}
}
return out;
}
function resolve(entries) {
const rank = Object.fromEntries(SOURCES.map((s) => [s.id, s.rank]));
const byName = new Map();
for (const e of entries) {
const list = byName.get(e.name) ?? [];
list.push(e);
byName.set(e.name, list);
}
const findings = [];
for (const [name, list] of byName) {
list.sort((a, b) => rank[a.source] - rank[b.source]);
const winner = list[0];
if (BUILTIN.has(name)) {
findings.push({
level: "block", name,
reason: "端末組み込みコマンドと同名。非対話セッションでは呼び出しが解決されない可能性がある",
effective: "builtin", losers: list,
});
continue;
}
if (list.length > 1) {
findings.push({
level: "warn", name,
reason: `同名スキルが ${list.length} 件。${winner.source}(${winner.owner}) が優先される`,
effective: `${winner.source}:${winner.owner}`, losers: list.slice(1),
});
}
}
return { total: entries.length, unique: byName.size, findings };
}
const t0 = process.hrtime.bigint();
const r = resolve(collect());
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
const blocks = r.findings.filter((f) => f.level === "block");
const warns = r.findings.filter((f) => f.level === "warn");
for (const m of missingRoots) console.log(`[GAP] 走査対象が存在しません: ${m}`);
console.log(`scanned=${r.total} unique=${r.unique} block=${blocks.length} warn=${warns.length} in ${ms.toFixed(1)}ms`);
for (const f of [...blocks, ...warns]) {
console.log(`[${f.level.toUpperCase()}] /${f.name} -> ${f.effective}`);
console.log(` ${f.reason}`);
for (const l of f.losers) console.log(` shadowed: ${l.source}:${l.owner} (${l.path})`);
}
process.exit(blocks.length > 0 || missingRoots.length > 0 ? 1 : 0);
BUILTIN を定数で持たせている点には理由があります。組み込みコマンドの一覧をその場で問い合わせて取ると、問い合わせ自体が失敗したときに「衝突ゼロ」という誤った安心を返してしまいます。手元で確認した一覧を版管理に置き、更新のたびに差分を目で見る——この鈍い運用のほうが、無人実行では信頼できます。
rank の並びも同様です。優先順位を推測で決め打ちしても、実環境がその通りとは限りません。ここは自分の環境で意図的に衝突を作り、どちらが応答したかを観測して上書きしてください。私が意味を持たせているのは「順序が固定されていて、その順序が明示されている」という一点です。
走らせて分かった、検査器自身の取りこぼし
検証用に、10件のスキルを持つツリーを作りました。プラグイン2種で5件、プロジェクト直下に3件、ホーム配下に2件です。うち、組み込みと衝突する名前を意図的に混ぜてあります。
最初の実行結果がこれです。
[GAP] 走査対象が存在しません: user:/home/runner/.claude/skills
scanned=8 unique=6 block=4 warn=1 in 1.2ms
[BLOCK] /review -> builtin
端末組み込みコマンドと同名。非対話セッションでは呼び出しが解決されない可能性がある
shadowed: project:project (.claude/skills/review)
shadowed: plugin:qa-kit (.claude/plugins/qa-kit/skills/review)
[BLOCK] /status -> builtin
shadowed: project:project (.claude/skills/status)
[BLOCK] /help -> builtin
shadowed: plugin:ops-kit (.claude/plugins/ops-kit/skills/help)
[BLOCK] /release-notes -> builtin
shadowed: plugin:ops-kit (.claude/plugins/ops-kit/skills/release-notes)
[WARN] /deploy-check -> plugin:ops-kit
同名スキルが 2 件。plugin(ops-kit) が優先される
shadowed: plugin:qa-kit (.claude/plugins/qa-kit/skills/deploy-check)
10件置いたはずが scanned=8 です。
ホーム配下の2件が落ちていました。実行環境の HOME が想定と違い、user ソースの走査先が存在しなかったためです。
ここが、私にとって一番の収穫でした。事前の予想では、この検査器の失敗パターンは「誤検出が多すぎて使い物にならない」だと思っていました。実際に起きたのは逆で、誰にも咎められないまま検出漏れを起こしていました。
HOME を実在するツリーへ向け直して再実行すると、こうなります。
scanned=10 unique=8 block=5 warn=1 in 1.6ms
[BLOCK] /compact -> builtin
端末組み込みコマンドと同名。非対話セッションでは呼び出しが解決されない可能性がある
shadowed: user:user (.../home/.claude/skills/compact)
検出件数は4件から5件へ変わりました。25%の増加です。増えた1件が /compact である点に注意してください。ホーム配下に個人が置くスキルは、compact や status のような短く一般的な名前になりがちです。つまり、走査から漏れやすい供給元と、衝突を起こしやすい命名習慣が、同じ場所に重なっています。
| 実行条件 | scanned | unique | block | warn | 終了コード |
| ユーザー供給元が未存在 | 8 | 6 | 4 | 1 | 1(GAP により) |
| 全供給元を走査 | 10 | 8 | 5 | 1 | 1(BLOCK により) |
missingRoots を終了コードに含めていなければ、上の行は「衝突4件」という一見もっともらしい結果を返していました。検査器が黙って一部を見なかったことは、出力のどこにも表れません。
無人実行に置く道具は、見つけられなかったことも報告する必要があります。私はこの一件以降、走査系のスクリプトには必ず「走査対象そのものの存在確認」を入れるようにしました。
一か月動かして直した、警報そのものの欠陥
その後この preflight を毎回の起動前に置いて動かしてきました。そこで直すことになったのは、衝突の検出ロジックではなく、警報の出し方のほうでした。
常に鳴っている警報は、読まれなくなります
最初に気づいたのは、警報が鳴りっぱなしになって一週間ほど経ってからでした。skills ディレクトリを持たないプラグイン——コマンドだけを配っているもの——が1つ導入されているだけで、[GAP] が毎回出て終了コードが 1 に固定されます。
衝突がゼロのツリーで確かめました。
$ HOME=/tmp/emptyhome node name-preflight.mjs
[GAP] 走査対象が存在しません: plugin:.claude/plugins/notes-kit/skills
scanned=2 unique=2 block=0 warn=0 in 0.9ms
$ echo $?
1
衝突は1件もありません。それでも 1 が返ります。前節で「走査できなかったこと自体を失敗にする」と書いたのは私自身ですから、これは設計の意図どおりに動いた結果です。ただ、意図どおりであることと、運用に耐えることは別でした。
skills を持たないプラグインは、走査の失敗ではありません。走査すべきものが最初から無いだけです。この2つを同じ [GAP] に混ぜたことが誤りでした。
壊れたシンボリックリンクが1つあると、検査器ごと落ちます
もう一つは、もっと素朴な取りこぼしでした。.claude/skills の下に、参照先を失ったシンボリックリンクが1つ残っているだけで、preflight 全体が例外で停止します。
$ ln -sfn /nonexistent/path .claude/skills/linked-skill
$ node name-preflight.mjs
Error: ENOENT: no such file or directory, stat '.claude/skills/linked-skill'
at collect (file:///.../name-preflight.mjs:13:83)
errno: -2, code: 'ENOENT', syscall: 'stat'
$ echo $?
1
statSync は既定で例外を投げます。.filter((p) => statSync(p).isDirectory()) は、そのままだと壊れたリンク1つで走査を中断します。
問題は落ちたこと以上に、その落ち方でした。Node.js の未捕捉例外の終了コードも 1 です。旧版では衝突による停止も 1、走査欠落も 1、クラッシュも 1。終了コードだけを見ているスケジュール実行からは、この3つが同じ顔で届きます。 起動前フックに置く道具としては、これでは役に立ちません。
直した3点と、直したあとの終了コード
const brokenLinks = [];
const noSkillsPlugins = [];
// ① 参照先を辿れないものは例外にせず、辿れなかった事実として記録する
function isDir(p) {
const st = statSync(p, { throwIfNoEntry: false });
if (!st) { brokenLinks.push(p); return false; }
return st.isDirectory();
}
function scanFlat(root, source, owner) {
if (!existsSync(root)) { missingRoots.push(`${source}:${root}`); return []; }
return readdirSync(root)
.map((e) => join(root, e))
.filter(isDir) // ← statSync の直呼びをやめる
.map((p) => ({ name: readName(p), source, owner, path: p }))
.filter((r) => r.name);
}
// ② skills を持たないプラグインは「失敗」ではなく「情報」として分ける
for (const plug of readdirSync(s.root)) {
const pr = join(s.root, plug, "skills");
if (!existsSync(pr)) { noSkillsPlugins.push(plug); continue; }
out.push(...scanFlat(pr, s.id, plug));
}
// ③ 停止の理由を終了コードで区別する
process.exit(
blocks.length > 0 ? 2
: (missingRoots.length > 0 || brokenLinks.length > 0) ? 3
: 0
);
同じツリーで前後を測ると、こうなりました。
| 条件 | 旧版の終了コード | 修正版の終了コード | 修正版の出力 |
| 衝突ゼロ・commands-only プラグイン 1 件 | 1 | 0 | [INFO] skills を持たないプラグイン 1 件: notes-kit |
| 衝突ゼロ・壊れたリンク 1 件 | 1(クラッシュ) | 3 | [GAP] 参照先を辿れません: .claude/skills/dangling |
| 衝突 4 件・壊れたリンク 1 件 | 1(クラッシュ・検出結果なし) | 2 | block=4 warn=1 を全件出力したうえで停止 |
3行目がいちばん重い違いです。旧版は壊れたリンクに当たった時点で走査を打ち切るため、その裏にあった4件の衝突は一度も出力されませんでした。検出したかったものが、検出器の停止に巻き込まれて消えていたのです。
警報は、鳴った理由ごとに違う音で鳴らします。 無人実行に道具を置くときの原則を、私はここで一つ増やしました。終了コードを 0 と 1 の2値にしたまま「止まったら人が見に来る」と考えていたのは、見に来る人がいる環境の発想だったのかもしれません。
ディレクトリ名で探しても見つからない
もう一つ、想定と違ったことがあります。
スキルの実効名は、ディレクトリ名ではなくフロントマターの name が決めます。この2つは一致していなくても成立します。
検証ツリーに weekly-report というディレクトリを足し、その SKILL.md には name: status と書きました。実務では十分あり得る形です。ディレクトリは用途で名付けたい、呼び出し名は短くしたい——その判断は自然に起きます。
結果がこれです。
$ ls .claude/skills
article-gate review status weekly-report
$ find .claude -type d -name "status" | wc -l
1
$ grep -rl "^name: status" .claude --include=SKILL.md | wc -l
2
ディレクトリ名で数えると1件。実効名で数えると2件。
衝突の調査でディレクトリ一覧を眺めても、weekly-report は容疑者になりません。名前で grep をかけても、探す対象がディレクトリ名であれば見つかりません。
この差は、衝突が起きたときの調査時間に直結します。実効名で突き合わせる仕組みを先に持っていないと、目の前にあるものを見落とし続けることになります。
上のスクリプトが readName() でフロントマターを優先しているのは、この理由からです。フロントマターに name があればそれを採り、なければディレクトリ名へ落とします。順序が逆だと、実環境と食い違う表を作ってしまいます。
走査コストは毎回払える範囲か
起動前に必ず走らせる道具である以上、コストが問題になります。500件のスキルを持つツリーを生成して測りました。
scanned=500 unique=500 block=0 warn=0 in 18.5ms
scanned=500 unique=500 block=0 warn=0 in 17.8ms
scanned=500 unique=500 block=0 warn=0 in 17.4ms
3回の実行で 17.4〜18.5ms です。10件のツリーでは 1.2〜1.6ms でした。
| スキル件数 | 解決時間(3回) | 1件あたり |
| 10 | 1.2 / 1.6 ms | 約 0.14 ms |
| 500 | 17.4 / 17.8 / 18.5 ms | 約 0.036 ms |
件数が50倍になっても時間は約12倍にとどまりました。1件あたりのコストはむしろ下がっています。ファイル1つあたりの読み取りより、プロセス起動と初期化の固定費のほうが支配的だからです。
実務的な結論としては、迷う必要のない水準でした。20ms は、無人実行1回のうちで意識に上る量ではありません。私は毎回の起動前に無条件で走らせる形に倒しています。「衝突が起きたときだけ走らせる」という設計は、衝突に気づけないという前提と矛盾します。
見つかった衝突をどう解消するか
検出はできました。次は直し方です。選択肢は3つあり、それぞれ副作用が違います。
接頭辞を付ける
help を ops-help にする。最も素直で、私が既定として採っている方法です。
副作用は呼び出し側の書き換えです。スケジュール実行のプロンプト、ドキュメント、他のスキルからの参照。名前を1つ変えるたびに、参照している箇所を全部探す必要があります。
接頭辞の規約は、衝突が起きてから決めるより先に決めておくほうが安く済みます。私は配布元ごとに固定の接頭辞を割り当てる形にしました。プラグインを追加した時点で名前空間が分かれるため、そもそも衝突が起きません。
ディレクトリはそのまま、フロントマターだけ変える
name の書き換えだけで実効名が変わります。ディレクトリ構成やドキュメントの参照パスに手を入れずに済むため、変更範囲は最小です。
ただし前節の通り、ディレクトリ名と実効名の乖離が広がります。乖離は調査コストとして後から返ってきます。私はこの方法を、緊急の回避としてのみ使い、後で必ず揃えることにしています。
名前を明け渡す
そのスキルを、そもそも名前で呼ばない設計へ寄せる方法です。
説明文で選ばせるか、別のスキルから内部的に呼び出す形にします。呼び出し名を持たなければ、名前空間の競争から降りられます。
補助的な役割のスキルであれば、これがいちばん収まりのよい解になります。すべてのスキルに固有の呼び出し口を用意する必要はありません。
| 方法 | 変更範囲 | 調査しやすさ | 向いている場面 |
| 接頭辞を付ける | 大(全参照) | 高い | 恒久対応・配布物 |
| フロントマターのみ | 小 | 低い | 緊急回避 |
| 名前を明け渡す | 中 | 高い | 補助的なスキル |
個人開発の規模でも、これは効きます
私が運用しているのは4つのサイトと、いくつかのアプリの自動処理です。人手は私ひとりで、レビューしてくれる同僚はいません。
その条件では、音を立てずに壊れるものが最も高くつきます。派手に落ちてくれれば通知が飛びます。何も起きなかっただけの実行は、通知の対象にすらなりません。
冒頭の一件で私が失ったのは、実質的には1日分の自動処理と、原因を追った3時間でした。preflight の実装と検証に使った時間は、それより短く済んでいます。
もう一つ、書き添えておきたいことがあります。この preflight は、衝突を「起こさない」ためのものではありません。衝突は、こちらの都合と無関係に外から入ってきます。目的は、衝突が入ってきた瞬間に気づける状態を作ることです。
防ぐのではなく、気づく。無人実行に道具を置くとき、私はこの区別を最も大事にしています。
明日から動かすなら
順に進めれば、30分ほどで運用に載ります。
- 自分の環境で組み込みコマンドの一覧を確認し、
BUILTIN の定数として版管理へ置くこと
SOURCES の走査先を実環境のパスへ合わせること。特に無人実行時の HOME が対話時と同じかどうかの確認
- 意図的に衝突するスキルを1つ作り、どちらが応答するかを観測して
rank を上書きすること
statSync を throwIfNoEntry: false にし、終了コードを 0 / 2 / 3 へ分けること
- 起動前フックか、スケジュール実行の先頭に組み込み、終了コード 2 だけで止めること
- プラグインを追加・更新したときの再実行
3番を飛ばさないことをお勧めします。優先順位を推測のまま置いた表は、正しく見えるだけに、間違っていたときの被害が大きくなります。
最後の実行結果を眺めながら思ったのは、名前という一番地味な部分が、無人実行では最も壊れやすいということでした。動くコードを書く時間より、名前が明日も同じものを指しているかを確かめる仕組みのほうが、長く効いてくれます。
お読みいただきありがとうございました。同じ形で夜のログが空になった経験のある方に、少しでも回り道を減らしていただけたら嬉しく思います。