●2.1.278 — The auto mode classifier now runs server-side by default on the Claude API, Enterprise, Bedrock, Vertex and Foundry. You are not billed for the classifier, and /status gained an Auto mode server line●TASKOUT — The TaskOutput tool is gone. taskOutputMaxChars and TASK_MAX_OUTPUT_LENGTH no longer do anything, and background output is read with Read instead●10/07 — The old management-configuration key spellings are accepted until noon PT on October 7, seventeen days from now. After that, entries that still use them stop working until you rewrite them●BUNPANIC — Reports are coming in of the newest build crashing on launch alone. Earlier builds still run on the same machine, which points at the release rather than the environment●NEW — Deciding what belongs in Cowork and what belongs in Claude Code, using the approval boundary as the line●SONNET4.5 — A date in a deprecation table is a floor, not an end date. Sonnet 4.5 is still active and no deprecation notice has been posted●2.1.278 — The auto mode classifier now runs server-side by default on the Claude API, Enterprise, Bedrock, Vertex and Foundry. You are not billed for the classifier, and /status gained an Auto mode server line●TASKOUT — The TaskOutput tool is gone. taskOutputMaxChars and TASK_MAX_OUTPUT_LENGTH no longer do anything, and background output is read with Read instead●10/07 — The old management-configuration key spellings are accepted until noon PT on October 7, seventeen days from now. After that, entries that still use them stop working until you rewrite them●BUNPANIC — Reports are coming in of the newest build crashing on launch alone. Earlier builds still run on the same machine, which points at the release rather than the environment●NEW — Deciding what belongs in Cowork and what belongs in Claude Code, using the approval boundary as the line●SONNET4.5 — A date in a deprecation table is a floor, not an end date. Sonnet 4.5 is still active and no deprecation notice has been posted
The Skill Call That Returned Nothing — Pinning Down Name Resolution Before an Unattended Run
A skill whose name collides with a built-in terminal command may never resolve in a non-interactive session. Here is a preflight that builds the effective-name table before launch, plus the measured run where the checker itself silently skipped two entries.
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 collisionsimport { 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.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A ~90-line Node.js preflight that blocks built-in name collisions before launch, with measured output from a 10-skill tree
✦Two alarm-side defects found after a month in production (permanent exit 1 from a commands-only plugin; a broken symlink crashing the checker) and the fix that splits exit codes into 0/2/3
✦Why a directory-name search finds 1 match where the effective-name count is 2, plus a measured 18ms scan across 500 skills
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The run that revealed the checker's own blind spot
I built a test tree with ten skills: five across two plugins, three in the project directory, two under the home directory. Several names deliberately collide with built-ins.
Here is the first run.
[GAP] scan target does not exist: user:/home/runner/.claude/skillsscanned=8 unique=6 block=4 warn=1 in 1.2ms[BLOCK] /review -> builtin collides with a built-in command; may never resolve in a non-interactive session 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 skills share this name; plugin(ops-kit) wins shadowed: plugin:qa-kit (.claude/plugins/qa-kit/skills/deploy-check)
Ten skills on disk, and scanned=8.
The two under the home directory were dropped. The HOME value in the execution environment differed from what I assumed, so the user source root did not exist.
This was the finding I did not see coming. My prediction was that this checker would fail by being too noisy — too many false positives to be useful. It failed in exactly the opposite direction: it quietly under-reported.
Pointing HOME at the real tree and rerunning:
scanned=10 unique=8 block=5 warn=1 in 1.6ms[BLOCK] /compact -> builtin collides with a built-in command; may never resolve in a non-interactive session shadowed: user:user (.../home/.claude/skills/compact)
Four findings became five — a 25% increase. Pay attention to which one appeared: /compact. Skills you keep under your home directory tend to get short, generic names, exactly like compact or status. The source most likely to be skipped during a scan is also the source with the naming habits most likely to collide.
Run condition
scanned
unique
block
warn
Exit code
User source missing
8
6
4
1
1 (from GAP)
All sources scanned
10
8
5
1
1 (from BLOCK)
Without missingRoots feeding the exit code, the first row returns a perfectly plausible "4 collisions." Nothing in the output signals that the checker declined to look at part of the tree.
Any tool you place in an unattended pipeline needs to report what it could not see. Since this run, every scanning script I write starts by verifying that its scan targets exist.
What a month of running it fixed — in the alarm, not the detector
I have kept this preflight in front of every launch since. What ended up needing repair was not the collision logic. It was the way the tool raises an alarm.
An alarm that always sounds stops being read
I noticed it about a week after the alarm had started firing on every run. A single plugin that ships commands but no skills directory is enough to emit [GAP] every time and pin the exit code at 1.
I confirmed it on a tree with zero collisions.
$ HOME=/tmp/emptyhome node name-preflight.mjs[GAP] scan target does not exist: plugin:.claude/plugins/notes-kit/skillsscanned=2 unique=2 block=0 warn=0 in 0.9ms$ echo $?1
Not one collision. Still a 1. That is the design working as written — I am the one who said a source you could not scan should count as a failure. But behaving as designed and surviving daily use turned out to be two different things.
A plugin without a skills directory is not a failed scan. There was simply nothing there to scan. Folding both into the same [GAP] was my mistake.
One broken symlink takes the whole checker down
The second issue was blunter. A single dangling symlink under .claude/skills stops the preflight with an uncaught exception.
$ ln -sfn /nonexistent/path .claude/skills/linked-skill$ node name-preflight.mjsError: 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 throws by default. Written as .filter((p) => statSync(p).isDirectory()), one broken link ends the scan.
The crash mattered less than the shape of it. Node's uncaught-exception exit code is also 1. In the old version, a stop from a collision was 1, a missing scan root was 1, and a crash was 1 too. A scheduler that reads only the exit code receives all three wearing the same face. For something meant to sit in a pre-launch hook, that is not usable.
Three changes, and what the exit codes mean now
const brokenLinks = [];const noSkillsPlugins = [];// 1. A path you cannot follow becomes a recorded fact, not an exceptionfunction 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) // no more bare statSync .map((p) => ({ name: readName(p), source, owner, path: p })) .filter((r) => r.name);}// 2. A plugin with no skills/ is information, not failurefor (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));}// 3. Let the exit code say why it stoppedprocess.exit( blocks.length > 0 ? 2 : (missingRoots.length > 0 || brokenLinks.length > 0) ? 3 : 0);
Measured before and after on the same tree:
Condition
Old exit
New exit
New output
No collisions, 1 commands-only plugin
1
0
[INFO] plugins without skills: notes-kit
No collisions, 1 broken symlink
1 (crash)
3
[GAP] cannot follow: .claude/skills/dangling
4 collisions, 1 broken symlink
1 (crash, no findings)
2
block=4 warn=1 printed in full, then stop
The third row is the one that stings. The old version aborted the scan the moment it hit the broken link, so the four collisions sitting behind it were never printed at all. The findings I wanted were swallowed by the failure of the thing looking for them.
Alarms should sound different depending on why they went off. That is one more rule I now carry into anything unattended. Keeping the exit code binary and assuming "if it stops, someone will come look" was, I suspect, a habit borrowed from environments where someone actually does.
A directory-name search will not find it
There was a second surprise.
A skill's effective name comes from the name field in its frontmatter, not from its directory name. Those two do not have to match.
I added a directory called weekly-report whose SKILL.md declares name: status. This is a realistic shape — name the directory after its purpose, keep the call name short. That trade-off happens naturally.
$ ls .claude/skillsarticle-gate review status weekly-report$ find .claude -type d -name "status" | wc -l1$ grep -rl "^name: status" .claude --include=SKILL.md | wc -l2
One match by directory name. Two by effective name.
Scanning a directory listing during a collision investigation, weekly-report never becomes a suspect. Grepping for the name gets you nowhere either, as long as what you are grepping is directory names.
That gap translates directly into investigation time. Without a mechanism that reconciles on effective names, you will keep looking past the thing sitting in front of you.
This is why readName() above prefers frontmatter and only falls back to the directory name. Reverse that order and you build a table that disagrees with reality.
Is the scan cheap enough to run every time?
A tool you run before every launch has to justify its cost. I generated a tree of 500 skills and measured.
scanned=500 unique=500 block=0 warn=0 in 18.5msscanned=500 unique=500 block=0 warn=0 in 17.8msscanned=500 unique=500 block=0 warn=0 in 17.4ms
17.4 to 18.5ms across three runs, against 1.2 to 1.6ms for the ten-skill tree.
Skill count
Resolution time (3 runs)
Per skill
10
1.2 / 1.6 ms
~0.14 ms
500
17.4 / 17.8 / 18.5 ms
~0.036 ms
A 50x increase in count produced roughly a 12x increase in time. Per-skill cost actually went down, because process startup and initialization dominate over reading any individual file.
The practical conclusion required no deliberation. Twenty milliseconds is not a quantity you notice inside a single unattended run. I now run it unconditionally before every launch. A design that says "run the check only when a collision occurs" contradicts the premise that collisions are what you fail to notice.
Fixing a collision once you have found one
Detection is the easy half. Three remedies are available, each with a different tail.
Add a prefix
Rename help to ops-help. This is the straightforward option and my default.
The cost is every call site. Scheduled-run prompts, documentation, references from other skills. Each renamed skill means hunting down everything that points at it.
Deciding the prefix convention before a collision is far cheaper than deciding it after. I assign a fixed prefix per publisher, so adding a plugin splits the namespace automatically and the collision never occurs.
Change only the frontmatter
Rewriting name changes the effective name with no other edits. Directory structure and documentation paths stay put, so the change surface is minimal.
But it widens the gap between directory name and effective name, and that gap comes back later as investigation cost. I reserve this for emergency mitigation, with a commitment to reconcile the two afterward.
Give up the name entirely
Redesign the skill so it is not called by name at all — selected by description, or invoked internally from another skill. A skill without a call name has withdrawn from the namespace competition.
For supporting skills, this is the quietest solution available. Not every skill needs its own front door.
Approach
Change surface
Investigability
Best for
Add a prefix
Large (all references)
High
Permanent fixes, distributed packages
Frontmatter only
Small
Low
Emergency mitigation
Give up the name
Medium
High
Supporting skills
Why this matters at indie developer scale
I run four sites and a handful of automated app workflows on my own. There is no colleague reviewing my changes.
Under those conditions, the things that break quietly are the expensive ones. A loud failure sends a notification. A run where nothing happened does not even qualify for one.
That opening incident cost me a day of automation plus three hours of investigation. Writing and validating the preflight took less time than that.
One more distinction is worth stating plainly. This preflight does not prevent collisions. Collisions arrive from outside, on someone else's schedule. Its purpose is to make sure you notice the moment one arrives.
Not prevention — awareness. When I place a tool inside an unattended pipeline, that is the distinction I care about most.
Getting this running
Roughly thirty minutes end to end.
Check the built-in command list in your own environment and commit it as the BUILTIN constant.
Point SOURCES at your real paths. In particular, confirm whether HOME in an unattended run matches the interactive one.
Create one deliberate collision, observe which side answers, and overwrite rank accordingly.
Switch statSync to { throwIfNoEntry: false } and split the exit codes into 0 / 2 / 3.
Wire it into a pre-launch hook or the first step of your scheduled run, and stop only on exit code 2.
Rerun it every time you add or update a plugin.
Do not skip step 3. A precedence table filled in by guesswork looks correct, which is precisely what makes it costly when it is wrong.
Looking at the final output, what stayed with me is that names — the least interesting part of the system — are the part most likely to break in unattended execution. A mechanism that confirms a name still points at the same thing tomorrow will outlast most of the code you write around it.
Thank you for reading. If you have ever opened a morning log and found it empty, I hope this saves you a detour.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.