/** * WHERE a repo's model surfaces (skills/agents/commands) live โ€” and the key each * one is materialized under. * * ๐Ÿ”ด THIS EXISTS BECAUSE THE LOADER USED TO CHOOSE. It read the repo-root * `skills/` **or** the project-level `.claude/skills/` โ€” never both โ€” and * materialized whichever it picked under the SAME canonical * `//โ€ฆ` key. Two real files, one key: the loser was * never read, and the winner's content sat under the loser's name. Measured * 2026-08-18 on `nyldn/claude-octopus` (pinned corpus): **50 skill names exist in * both `skills/` and `.claude/skills/`, and all 50 pairs differ** โ€” the `.claude/` * copies carry multi-line unquoted `description:` blocks that a strict YAML * loader rejects. vigiles reported those fifty skills as clean without ever * having opened the files it named. * * The vendor settles it โ€” Claude Code loads BOTH, in two namespaces: * * > Plugin skills use a `plugin-name:skill-name` namespace, so they can't * > conflict with other levels. * > For example, `my-plugin/skills/deploy/SKILL.md` becomes `/my-plugin:deploy` * > and loads alongside a `deploy` skill in your project's `.claude/skills/`. * > โ€” https://code.claude.com/docs/en/skills ยง "Where skills live" * * So "pick one" was never a tie-break to get right; it was a question that has no * answer, asked because the key shape forced one. The fix removes the question: * every scope present is read, and **a scope's key prefix is derived from the * scope, not from a winner**, so two files can no longer claim one key. * * Node-free and IO-free on purpose: the disk loader (`src/plugin-loader.ts`) and * the browser file-map twin (`src/scan-files.ts`) each probe their own storage * and call THIS for the decision, so the pair that this repo has repeatedly been * bitten by fixing on one side only cannot disagree about scoping. */ import { type PluginLayout } from "./layout.js"; /** * One discovery level, and the prefix its files are materialized under. * * `base` is the repo-relative dir the surfaces really live under (`""` = the repo * root, the published-plugin shape; `.claude` = the plain-user project shape). * `materializeUnder` is the prefix prepended to `/` to form the * `LoadedPlugin.files` key. */ export interface SurfaceScope { /** Repo-relative dir holding `/โ€ฆ`; `""` for the repo root. */ readonly base: string; /** Key prefix for this scope's files; `""` for none. */ readonly materializeUnder: string; /** Human label for warnings โ€” `plugin` (root) or `project` (`.claude/`). */ readonly label: string; /** * This scope exists only because the REPO OWNER named its base in * `.vigilesrc.json#harnesses[""].roots` โ€” it is not a location the harness itself * reads. Marked so {@link multiScopeWarning} can stay about the ambiguity it * describes (plugin-vs-project, both of which a real session loads) instead of * claiming the harness loads a declared root too. */ readonly declared?: boolean; } /** Which shape the audited target is, and every scope to read from it. */ export type SurfaceSource = { readonly kind: "single-skill"; readonly skillName: string; /** * The layout's skill dir, CARRIED rather than re-read by the consumer. * * ๐Ÿ”ด IT USED TO BE RE-DERIVED AT BOTH CONSUMERS, AND THE BRANCH THAT * FOLLOWED WAS UNREACHABLE. `layout.surfaces.skill` is optional, so each * of `plugin-loader.ts` and `scan-files.ts` narrowed it again and wrote a * `return` for the `undefined` case โ€” a case this variant is never * constructed in, since the test below is the condition for making one. * Two dead returns, in two engines that must agree, plus a comment in each * explaining why the code beneath it cannot run. Carrying the value is the * same construction the rest of this port uses: keep one copy, where it is * KNOWN, instead of re-deriving it where it is not. */ readonly skillDir: string; } | { readonly kind: "scopes"; readonly scopes: readonly SurfaceScope[]; }; /** What the caller must probe on its own storage for {@link surfaceSource}. */ export interface SurfaceProbe { /** A `/SKILL.md` exists โ€” the target IS one skill dir. */ readonly hasRootSkillFile: boolean; /** Name to give that single skill (the target dir's basename). */ readonly skillName: string; /** Some `//` holds a loadable file. */ readonly rootHasLoadable: boolean; /** A plugin manifest or the hooks convention path exists. */ readonly isPluginShaped: boolean; /** Some `///` holds a loadable file. */ readonly userHasLoadable: boolean; /** * The repo owner's declared roots, normalized by {@link normalizeSurfaceRoots}, * in declaration order. * * Unlike the flags above this is NOT a probe result โ€” the caller passes the * declaration as written and does not check whether each root holds anything. * It does not have to: a declared scope is appended AFTER the no-scope * fallback, so an empty one adds an empty tree and changes nothing, while a * filter here would be a guard with no observable behaviour to defend. */ readonly declaredRoots?: readonly string[]; } /** * The repo owner's `.vigilesrc.json#harnesses[""].roots`, normalized โ€” or * dropped. (The flat top-level `surfaceRoots` key this once read was removed in * the same change that nested it under a harness name.) * * A DECLARATION BY THE REPO OWNER, never by an adapter: the rejected option B * let each harness declare roots, which inverts the dependency (registering a * Cursor adapter would start reading `.cursor/rules` in everyone's repo). A key * in the repo's own config cannot do that โ€” it widens exactly one repository, * the one whose owner wrote it. See `research/audit-harness-dx.md` ยง9. * * Dropped rather than errored, because the failure of a bad entry is harmless * (nothing extra is read) while refusing to audit over a config typo is not: * absolute paths, `.`/empty, and anything with a `..` segment, which would reach * OUTSIDE the audited repo and is the only entry that could do real damage. * Order is kept and duplicates collapse, so the scope list is stable. */ export declare function normalizeSurfaceRoots(roots: readonly string[] | undefined): readonly string[]; /** * Classify the target and list every scope to read, HIGHEST-PRECEDENCE FIRST. * * Precedence decides only one thing: which scope keeps the canonical * `/โ€ฆ` key. The project scope takes it, because that key IS * where a project skill lives โ€” `.claude/skills/deploy/SKILL.md` is loaded from * exactly that path and answers to `/deploy`. A plugin scope keeps its own real * location (`skills/deploy/SKILL.md`), which is likewise where the harness reads * it from, under `/plugin:deploy`. Nothing is relocated on top of something else. * * ๐Ÿ”ด THE COLLISION IS STRUCTURAL, NOT CHECKED. Only the FIRST scope is relocated; * every later one keeps `base` as its prefix. Since the first scope is the only * one that can produce a `/โ€ฆ` key, and every other prefix is a * distinct real directory, two scopes cannot mint the same key โ€” there is no * ordering, no "if already taken", and no last-write-wins to get wrong. * {@link assertDistinctScopeKeys} is the LOUD backstop for a future * `PluginLayout` that breaks the premise (e.g. one naming `.claude` as BOTH the * materialize prefix and a second scope's base). * * โš ๏ธ HALF OF WHAT IT GUARDED IS NOW UNREPRESENTABLE. The premise used to have * two ways to break: a declared root colliding with the prefix, and a layout * whose `materializeRoot` differed from its `userSurfaceRoot` at all. The second * field is gone โ€” the prefix IS `userSurfaceRoot ?? ""` โ€” so only the first * remains, and this backstop is kept for it. */ export declare function surfaceSource(layout: PluginLayout, probe: SurfaceProbe): SurfaceSource; /** The `LoadedPlugin.files` key for one file under one scope. */ export declare function scopeKey(scope: SurfaceScope, surface: string, rel: string): string; /** * Throw when two scopes would mint the same key prefix. Unreachable for every * shipped layout (see {@link surfaceSource}) โ€” it exists so a NEW layout that * breaks the premise fails loudly at load, rather than silently dropping a * surface file the way the shadowing bug did for a year. */ export declare function assertDistinctScopeKeys(scopes: readonly SurfaceScope[], layoutName: string): void; /** * The warning to emit when more than one scope is present. Both scopes load in a * real session under DIFFERENT names, but the deterministic sandbox is a project * dir โ€” it registers the project scope only, so a plugin-scope skill sitting at * `skills/โ€ฆ` in the fixture never activates there (the footgun * `unregisteredSkillFiles` already warns about for inline arm files). Say so, * rather than quietly relocating one on top of the other. */ export declare function multiScopeWarning(allScopes: readonly SurfaceScope[], counts: Record): string | undefined; //# sourceMappingURL=surface-scopes.d.ts.map