Skip to content

fix(skills): make the skill-pack globs mean what an operator writes - #940

Open
brahyam wants to merge 1 commit into
yc-software:mainfrom
brahyam:skill-globs-match-skill-md-path
Open

fix(skills): make the skill-pack globs mean what an operator writes#940
brahyam wants to merge 1 commit into
yc-software:mainfrom
brahyam:skill-globs-match-skill-md-path

Conversation

@brahyam

@brahyam brahyam commented Sep 4, 2026

Copy link
Copy Markdown

What

config.skillGlobs on a skill pack was matched against the skill's directory, never against the path of the SKILL.md that defines it:

const skillDir = skillDirOf(f.path);
if (cfg?.skillGlobs && !matchesAny(skillDir, cfg.skillGlobs)) continue;

So the natural way to write the pattern — skills/*/SKILL.md, which is the path the glob appears to be filtering — matched nothing, every skill was skipped, and the catalog reported total 0 with no error. The pack read as empty rather than as misconfigured.

Four changes, three of which are failures found in the same guard.

1. A trailing /SKILL.md is stripped from the glob, and the directory is matched. Normalizing the pattern rather than also matching the file path matters: the OR form would widen every directory-shaped glob, because skills/*/* — written to select skills/<team>/<skill> — also matches skills/<team>/SKILL.md as a file path. An imported pack skill injects instructions into every session in the target scope, so an allowlist admitting a skill it did not name is not only untidy.

2. exclude gets the same leniency, because as a denylist it was failing open. It also meant two different things in one config: collectSharedBundle filters bundle files through isExcludedPath, which walks ancestor prefixes, while this path compared the skill directory exactly. exclude: ["trusted"] therefore dropped every bundle file under trusted/ and imported the skill sitting in it, and exclude: ["trusted/*/SKILL.md"] matched nothing at all. Both call sites now go through isExcludedPath, which is one rule instead of two.

3. An empty skillGlobs means unset rather than "nothing". asConfig sets cfg.skillGlobs = [] whenever the client sends an array — a cleared field, or an array of non-strings. An empty array is truthy and matchesAny(x, []) is false, so every skill was dropped; on the next sync importPack returned an empty kept and archiveRemoved archived every skill the pack had ever published into each target scope. A cleared field silently uninstalled the pack.

4. counts.filtered reports how many SKILL.md files were dropped by either list before any other classification. total 0, filtered 7 says the filter is the cause; total 0, filtered 0 says the repository has no skills. Those were indistinguishable, which is what made the first failure hard to find.

5. exclude is normalized the same way, and matched against the directory. Routing it through isExcludedPath on the raw SKILL.md path would widen it exactly as matching skillGlobs against that path would: exclude: ["**/*.md"] — an operator stripping documentation out of the shared bundle — dropped 2 of 3 skills in a test repository, and skills/*/* reached a skill one level up through its manifest. That is not a missing import: a skill that never becomes a candidate is absent from kept, so archiveRemoved archives every already-imported skill of the pack on the next sync.

6. A bare SKILL.md glob selects the repository-root skill. planIngest supports a manifest at the root, and the glob an operator would write for it has no directory part to strip.

Deliberately not included

counts.filtered reaches the API and not the admin. The pack catalog renders a fixed PILL_ORDER with no filtered entry, so an operator still sees an empty candidate list with no explanation. Adding "filtered" to PILL_ORDER and a REASON entry is enough — pillCount already falls through to counts[key] and pillShown already hides a zero — but it should not be clickable, because filtering to it shows an empty table: those skills never became candidates.

That is a visible change to the admin and this repository asks for a demo with one. It is left out rather than shipped undemonstrated, and is worth a follow-up — two review passes asked for it, so treat it as wanted rather than optional. The count is not dead in the meantime: it is in the catalog response and is persisted by recordImport, so a sync's own record says what its filters dropped.

Verified

test/ingest.test.ts covers a glob ending in /SKILL.md, a directory glob (unchanged), a glob matching neither, a directory-shaped glob that must not be widened, an empty skillGlobs, exclude by ancestor and by manifest path, and the filtered count separating a filtered pack from an empty one.

npm run typecheck, lint, prettier --check, and the ingest, sync-engine, pack-store, bundle and skill-pack-routes suites pass.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

config.skillGlobs was matched against the skill's DIRECTORY, never against
the path of the SKILL.md that defines it. So the natural way to write the
pattern — skills/*/SKILL.md, the path the glob appears to be filtering —
matched nothing, every skill was skipped, and the catalog reported total 0
with no error. The pack read as empty rather than misconfigured.

A trailing /SKILL.md is now stripped from the glob and the directory matched.
Normalizing the pattern rather than ALSO matching the file path is the point:
matching both widens every directory-shaped glob, because skills/*/* — written
to select skills/<team>/<skill> — also matches skills/<team>/SKILL.md as a
file path. An imported pack skill injects instructions into every session in
its target scope, so an allowlist admitting a skill it did not name is not
only untidy.

exclude gets the same normalization and the same directory comparison, for
both halves of that reasoning. It was failing open — collectSharedBundle
filters bundle files through isExcludedPath, which walks ancestor prefixes,
while this path compared the skill directory exactly, so exclude: ["trusted"]
dropped every bundle file under trusted/ and imported the skill sitting in it.
But matching the raw SKILL.md path instead would fail the other way:
exclude: ["**/*.md"], which an operator writes to strip documentation out of
the shared bundle, would drop every skill, and skills/*/* would reach a skill
one level up. Neither is a missing import — a skill that never becomes a
candidate is absent from kept, so archiveRemoved archives every
already-imported skill of the pack on the next sync.

An empty skillGlobs now means unset rather than "nothing". asConfig sets it
to [] whenever the client sends an array — a cleared field, or an array of
non-strings. An empty array is truthy and matches nothing, so every skill was
dropped and the same archive cascade followed: a cleared field silently
uninstalled the pack.

A bare SKILL.md glob selects the repository-root skill, which planIngest
supports and which has no directory part to strip.

counts.filtered reports how many SKILL.md files either list dropped before
any other classification. `total 0, filtered 7` says the filter is the cause;
`total 0, filtered 0` says the repository has no skills. Those were
indistinguishable, which is what made the first failure hard to find. It
reaches the API and recordImport but not the admin, whose catalog renders a
fixed pill order — that is a visible change and is left for a follow-up that
can carry a demo.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant