I opened the logs from an overnight scheduled run and found almost nothing in them.
No failure. No stack trace. No timeout record. The step I had wired up simply never executed.
Before I found the real cause, I spent three hours suspecting permission settings and network allowlists. Both were innocent. What actually happened was far more mundane: a skill I was distributing through a plugin shared its name with a built-in terminal command.
In an interactive session, typing that name gets you the built-in. It looks like everything works. In a non-interactive session, the same call resolves to nobody at all, and falls on the floor without a sound.
That particular defect was fixed in the August 6, 2026 update. But what got fixed is a symptom, not the shape of the problem. Call names arrive from several sources, and the effective name is decided by a resolution order. If you name a skill without knowing that order, you will break the same way again — just as quietly.
So I wrote a tool that builds the effective-name table before launch. Then I ran it, and found something the tool itself had been missing.
Call names are a resolution order, not a list
Skills come from more than one place. The project directory. Your home directory. Plugins you have installed. In an organization, add whatever an administrator distributes on top of that.
It is tempting to think of this as "the list of skills I have available." It is not a list. It is a single namespace that several independent sources write into.
| Source | Location | Who changes it | Will you notice a change? |
|---|---|---|---|
| Built-in commands | The terminal itself | The vendor | Only if you read the changelog |
| Project | Inside the repository | You and your team | Yes, it shows in the diff |
| User | Under your home directory | You | Not if it is outside version control |
| Plugin | Installed plugins | Whoever publishes it | Almost never |
The bottom two rows are where the trouble lives. Skills under your home directory never appear in a repository diff. Skills provided by a plugin enter your namespace the moment the publisher adds a new name, without asking you.
Which means whether your own skill still answers to its name tomorrow is not entirely your decision.
Built-in commands sit on top of all of this. They win, and they change fastest. In the first week of August alone, new manually invocable skill entry points were added. A name that was free yesterday can be occupied today.
Why interactive and non-interactive behave differently
In an interactive session, whatever you type passes through the terminal's input layer first. If it matches a built-in command, resolution stops there. Since a human is doing the typing, a missing response registers immediately.
A non-interactive session has no such input layer. The call goes to the agent's own resolution path. When the name is reserved on the built-in side, the agent's skill table and the vendor's reserved words disagree, and the call is left hanging.
And in unattended execution, nobody is around to complain about a hanging call.
The tell I missed for three hours: a permission problem leaves a denial record. The complete absence of any record was itself the evidence that permissions were not the cause.
Building the effective-name table before launch
What I needed was a way to answer, on every run, a single question: if I call this name, which file actually executes?
I settled on three rules.
- Scan every source and collapse the results into one effective-name table.
- Treat a collision with a built-in command as a stop condition, not a warning.
- Treat any source that could not be scanned as a failure in its own right.
The third rule turned out to matter more than I expected.
Here is the implementation. It uses only Node.js standard modules — adding a dependency invites the failure where the package is simply absent in the unattended environment.
#!/usr/bin/env node
// name-preflight.mjs — resolve skill call names before launch and block collisions
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
import { join, basename } from "node:path";
// Built-in terminal commands. Derive this from /help in your own environment
// and keep it under version control as a fixed list.
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",
]);
// Lower rank resolves first. Always overwrite this with what you observe
// in your own environment.
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: "collides with a built-in command; may never resolve in a non-interactive session",
effective: "builtin", losers: list,
});
continue;
}
if (list.length > 1) {
findings.push({
level: "warn", name,
reason: `${list.length} skills share this name; ${winner.source}(${winner.owner}) wins`,
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] scan target does not exist: ${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 is a hardcoded constant on purpose. If you query the live list of built-in commands instead, a failed query hands you a comforting "zero collisions." A dull list in version control, reviewed by eye whenever the tool updates, is more trustworthy in an unattended context.
The same reasoning applies to rank. Guessing at the precedence order is not good enough. Create a deliberate collision in your own environment, observe which side answers, and overwrite the values. What the code contributes is that the order is fixed and written down — not that my particular ordering is correct for you.