/** * Surface DISCOVERY โ€” WHERE a repo's loadable surfaces are, answered by SHAPE * over a BOUNDED set of roots, BEFORE anything asks which harness this repo is. * * ๐Ÿ”ด WHY THIS MODULE EXISTS โ€” THE ORDER OF THE TWO QUESTIONS WAS BACKWARDS. * The tool asked "which ONE harness is this?" first and then trusted the winning * {@link PluginLayout} to name the directories. `PluginLayout` does two jobs at * once โ€” it is the DIALECT (what the harness understands) and the SEARCH PATH * (where to look) โ€” so a repo whose skills sit somewhere no shipped layout names * was not merely unread, it was graded anyway. Measured by an outside adopter * (#240, vigiles 27.2.0): 37 skills, 24 over-budget descriptions and 14 missing * bundled resources under `.ai/` produced * * Detected harness: codex * Harness health: A (100/100) * โœ“ no structural issues found * * while the same binary pointed straight at `/.ai` graded it F (0/100). * Same repo, same commit, same version โ€” the grade decided by one positional * argument, and the one nobody would question was the wrong one. * * โ”€โ”€ THE DEPENDENCY DIRECTION THIS BUYS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ * Discovery is the DOMAIN's job and an adapter cannot widen it. A harness * adapter answers only the pure question `claims(path)` โ€” "is this path mine?" โ€” * so it can LABEL what was already found and nothing else. The property: * **registering a new adapter cannot make vigiles read more in anyone's * repository.** The rejected alternative (each adapter declares roots, the audit * walks the union) inverts that: adding a Cursor adapter would start reading * `.cursor/rules` in every user's repo, and a surface no adapter declared would * stay invisible โ€” the blindness preserved. See `research/audit-harness-dx.md` * ยง9 for the four options and why this one. * * And the corollary that makes it a product statement rather than a refactor: a * surface NOBODY claims is a FINDING, not silence. That is this tool's own thesis * applied to itself โ€” a passing signal standing in for work nobody did. * * โ”€โ”€ THE BOUND, AND WHY IT IS NOT A PORT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ * Walking the whole repo is not an option and the reporter of #240 said why * before we did: an unbounded `**\/SKILL.md` in that repo finds 53 VENDORED * third-party plugin skills beside the 37 real ones and grades them as one * machine. So discovery looks in the repo root, plus each of its DEPTH-1 * DOT-DIRECTORIES ({@link isDiscoveryRoot}). The reason that set is the right shape โ€” * every harness convention puts its tree in a top-level dot-directory * (`.claude`, `.codex`, `.agents`, `.cursor`, `.gemini`, `.opencode`, * `.windsurf`, and #240's `.ai`), and an ordinary source tree does not. Cost is * one `readdir` of the repo root plus one per dot-directory, then descent ONLY * into a directory named by {@link SURFACE_SHAPES} โ€” never into `src/`, * `packages/` or anything else. * * It is a DOMAIN CONSTANT, not a port method, and that is load-bearing: if an * adapter could add a root we would be back to the inverted dependency above. * * โš ๏ธ KNOWN LIMITATION, NAMED RATHER THAN FAKED โ€” the Codex WALK-UP is still not * expressed. The vendor scans `.agents/skills` in EVERY directory from the cwd up * to the repository root; this bound covers `/.agents/skills` only, so a * subdirectory's own `.agents/skills` stays invisible. Reaching it means walking * arbitrary directories, which is exactly what the paragraph above refuses. For * the normal case โ€” `vigiles audit` at the repo root โ€” the root IS the whole * walk-up chain, so the gap is a monorepo subpackage, not the common shape. * * Pure, IO-free and Node-free ON PURPOSE, exactly as `surface-scopes.ts` is: the * disk loader (`src/scan.ts` via `src/surface-discovery-fs.ts`) and the browser * file-map twin (`src/scan-files.ts`) each enumerate paths from their own storage * and call THIS for the decision, so the pair this repo has repeatedly been * bitten by fixing on one side only cannot disagree about what a surface is. */ import { type PluginLayout } from "./layout.js"; /** A surface kind discovery can recognize from a path alone. */ export type DiscoveredKind = "skill" | "agent" | "command"; /** * The SHAPE of each surface kind: the directory name that holds it, and the * path tail that makes a file inside it loadable. * * These are the CROSS-VENDOR names, not one harness's: Codex's skills live at * `.agents/skills`, Claude Code's at `skills` or `.claude/skills` โ€” the same * `skills` segment under a different root, which is why the root is the variable * and the shape name is not. The tails quote the classifier the scan already * uses (`makeClassifier`, scan-core.ts): a skill is `/SKILL.md`, an agent * is read RECURSIVELY per {@link AGENT_FILE_LEAF_RE} (do not hard-code a depth * here โ€” that header says why), a command is any `.md` below the dir. * * Codex's `prompts` is deliberately absent: it is claimed by `codexLayout` and * read by the loader, and giving a non-dot word like `prompts` shape status at * the repo root would match ordinary prompt-engineering directories that are not * a harness surface at all. */ export declare const SURFACE_SHAPES: ReadonlyArray<{ readonly dir: string; readonly kind: DiscoveredKind; readonly tail: string; }>; /** One surface found by shape, and the bounded root it was found under. */ export interface DiscoveredSurface { readonly kind: DiscoveredKind; /** Repo-relative POSIX path of the loadable file. */ readonly path: string; /** The discovery root it sits under โ€” `""` for the repo root. */ readonly root: string; /** The surface directory, repo-relative (`.ai/skills`, `skills`, โ€ฆ). */ readonly dir: string; } /** * Is this repo-root-relative DIRECTORY NAME one of the bounded discovery roots? * * The repo root itself is always one (`""`). Everything else must be a * DOT-directory โ€” see the module header for why that is the whole bound. The * caller supplies the names (a `readdir` on disk, the key set in the browser), * so this function stays IO-free. */ export declare function isDiscoveryRoot(name: string): boolean; /** * Every surface a set of repo-relative paths holds, by SHAPE โ€” harness-blind. * * `paths` must already be bounded by the caller's walk; this re-checks the root * rule anyway so the browser twin (which hands over the whole fetched key set) * gets the same answer as the disk walk. */ export declare function discoverSurfaces(paths: readonly string[]): readonly DiscoveredSurface[]; /** * Every repo-relative location a layout READS โ€” the raw material for * {@link layoutClaims}. * * Derived from the layout rather than listed per adapter, so a layout that moves * (as `codexLayout` just did, `.codex/skills` โ†’ `.agents/skills`) moves its claim * with it and the two cannot drift. * * โš ๏ธ The materialize prefix goes through {@link located} because `codexLayout` * has none (`""`) and a directory prefix of `""` is meaningless. It is DEFENCE IN DEPTH, * not the load-bearing guard, and the difference was MEASURED rather than * assumed: removing this filter alone leaves `layoutClaims(codexLayout, * "src/index.ts")` at `false`, because the boundary form `path.startsWith(`${d}/`)` * in {@link layoutClaims} already refuses a `""` prefix (no repo-relative path * starts with `/`). Both have to go before Codex claims the whole repository โ€” * which is the combined mutation `surface-discovery.test.ts` pins, precisely * because removing either one on its own is survivable and would otherwise read * as "this line is protecting us". */ export declare function layoutLocations(layout: PluginLayout): { readonly dirs: readonly string[]; readonly files: readonly string[]; }; /** * Does this layout's harness READ this repo-relative path โ€” "is it mine?" * * The ONE answer behind every adapter's `claims`, so an adapter cannot express a * claim its own layout contradicts. Pure: a string question about a string, with * no filesystem and no knowledge of what else exists. */ export declare function layoutClaims(layout: PluginLayout, path: string): boolean; /** * Does a repo owner's DECLARED root cause this path to be read, under the * layout of the harness it was declared UNDER? * * ๐Ÿ”ด IT CLAIMS EXACTLY WHAT IT MAKES READABLE, AND NOT ONE PATH MORE. The set is * `//โ€ฆ` for that harness's own `surfaceDirs` โ€” which is * verbatim the set `materializeSurfaces` reads for a declared scope. Derived * from the same field rather than listed twice, so the two cannot drift. * * The consequence is the point: a declaration silences the finding only where it * actually reaches. Declaring `.ai` under `"codex"` does not silence a * `.ai/skills/` tree, because Codex's skill dir is `.agents/skills` and * `.ai/skills` is still read by nobody. Under the flat `surfaceRoots` key that * was where the story ended โ€” the tool stayed quiet about a declaration that * changed nothing. Now the harness is part of the declaration, so the same fact * is an ERROR the owner can act on ({@link unresolvedDeclaredRoots}). * * โš ๏ธ A declaration is the REPO OWNER's, never an adapter's. Nothing here lets a * harness widen what vigiles reads in someone else's repository โ€” the property * the module header exists to protect. See `research/audit-harness-dx.md` ยง9. */ export declare function declaredRootClaims(layout: PluginLayout, roots: readonly string[], path: string): boolean; /** * The repo-relative dirs a declaration REACHES: `/` for every * declared root crossed with this layout's own surface dirs. * * ONE derivation, read two ways โ€” {@link declaredRootClaims} asks whether a * found path is in the set, {@link unresolvedDeclaredRoots} asks whether any * member of the set exists on disk. Two spellings of "where does this * declaration reach" is how a declaration could be claimed by one and refused by * the other. */ export declare function declaredRootDirs(layout: PluginLayout, roots: readonly string[]): readonly string[]; /** One declared harness as this module needs it: a name and its layout + roots. */ export interface DeclaredRootScope { /** The harness KEY the root was declared under. */ readonly harness: string; readonly layout: PluginLayout; readonly roots: readonly string[]; } /** * Declared roots that reach NO directory on disk โ€” the one silent state the * nested shape would otherwise keep (#240). * * ๐Ÿ”ด WHY THIS IS AN ERROR AND NOT A WARNING. A root under a harness whose layout * keeps that surface somewhere else is not a near-miss, it is a statement that * cannot be true: `{"codex": {"roots": [".ai"]}}` over a `.ai/skills/` tree makes * vigiles look at `.ai/.agents/skills/` and `.ai/prompts/`, finds neither, reads * nothing, and changes not one line of the report. The owner wrote a line * believing their skills were now graded. Silence there is the tool agreeing. * * โš ๏ธ IT CHECKS THE DIRECTORY, NOT ITS CONTENTS, and the difference is * deliberate: an EMPTY `.ai/skills/` is a real, correctly-declared home that * happens to hold nothing today, and failing a build over an empty directory * would be a gate on repo state rather than on the declaration. What is refused * is a declaration that names no directory at all. * * `dirExists` is injected (repo-relative path in, boolean out) so the rule is * pure, node-free and testable without a filesystem โ€” the same contract every * other decision in this module keeps. */ export declare function unresolvedDeclaredRoots(scopes: readonly DeclaredRootScope[], dirExists: (repoRelativeDir: string) => boolean): readonly string[]; /** * The discovered surfaces NO registered harness claims โ€” the finding. * * `claimers` is every registered adapter's `claims`, so "unclaimed" means "no * harness vigiles knows about reads this", not "the detected harness does not". * That distinction is the whole point of discovering before detecting: a repo * carrying both `.claude/skills` and `.agents/skills` has each half claimed by a * different adapter and neither is a finding, while #240's `.ai/skills` is * claimed by nobody and becomes one. */ export declare function unclaimedSurfaces(surfaces: readonly DiscoveredSurface[], claimers: ReadonlyArray<(path: string) => boolean>): readonly DiscoveredSurface[]; /** * The unclaimed surfaces, grouped into ONE finding per directory. * * A finding per FILE would put 37 lines in #240's report for one mistake; the * actionable unit is the directory nobody reads, so that is the unit counted and * the unit graded. */ export declare function unclaimedDirs(unclaimed: readonly DiscoveredSurface[]): ReadonlyArray<{ readonly dir: string; readonly root: string; readonly kind: DiscoveredKind; readonly count: number; }>; /** One directory of surfaces that no registered harness reads. */ export interface UnclaimedSurfaceFinding { /** The unread surface directory, repo-relative (`.ai/skills`). */ readonly dir: string; readonly kind: DiscoveredKind; /** How many loadable files it holds. */ readonly count: number; /** The report line, worded ONCE so the disk scan and the browser twin agree. */ readonly message: string; } /** * The whole discovery verdict in one call: paths in, findings out. * * The ENTRY POINT both producers use (`src/scan.ts` over a bounded disk walk, * `src/scan-files.ts` over the fetched key set), so the wording, the grouping * and the claim rule cannot differ between the CLI and the in-browser audit. * * It takes LAYOUTS rather than bare predicates because the answer needs both * halves of the same fact: who claims a path, and where those claimants * actually keep that kind of surface. Taking them apart is how the message and * the claim rule would come to disagree. * * `declared` is the repo owner's opt-in: one entry per harness declared in * `.vigilesrc.json#harnesses`, each carrying the roots declared under it and the * LAYOUT those roots are read with. It joins the claimers rather than filtering * the findings afterwards, so "no harness reads this" and "this is read" stay * ONE question with one answer. Omitted by the browser twin, which has no * config โ€” see {@link declaredRootClaims} for what it does and does not silence. * * โš ๏ธ `exclude` needs no mention here and that is structural, not an oversight: * an excluded path never reaches `paths` (the walk drops it), so it can be * neither a finding nor a graded surface however it was declared. */ export declare function unclaimedSurfaceFindings(paths: readonly string[], layouts: readonly PluginLayout[], declared?: readonly DeclaredRootScope[]): readonly UnclaimedSurfaceFinding[]; //# sourceMappingURL=surface-discovery.d.ts.map