深夜に回しているスケジュール実行のログを朝に開いたら、進行の記録がほとんど残っていませんでした。
失敗した形跡はありません。エラーも、スタックトレースも、タイムアウトの記録もない。ただ、呼んだはずの手順が一度も動いていない。
原因にたどり着くまでに、私は権限設定とネットワークの許可リストを先に疑いました。どちらも無実でした。実際に起きていたのは、もっと素朴なことです。プラグインから配布していたスキルの名前が、端末の組み込みコマンドと衝突していました。
対話セッションでは、同じ名前を打つと組み込み側が応答します。ですから「動いている」ように見えます。非対話セッションでは、その呼び出しが誰にも解決されないまま、静かに落ちていました。
この不具合そのものは 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 の並びも同様です。優先順位を推測で決め打ちしても、実環境がその通りとは限りません。ここは自分の環境で意図的に衝突を作り、どちらが応答したかを観測して上書きしてください。私が意味を持たせているのは「順序が固定されていて、その順序が明示されている」という一点です。