vigiles API reference
    Preparing search index...

    Interface PluginLayout

    Where one harness keeps the files vigiles reads: its instruction file, its model surfaces, its hook registrations and settings, plus the env token expanded to the plugin root. The loader reads all of it from this descriptor rather than hard-coding one harness's conventions, which is what lets a second harness be a VALUE passed to the same loader instead of a fork of it.

    🔴 EVERY FIELD HOLDS ITS FACT ONCE. Four of the fields this interface used to have were second copies of a fact another field already held — surfaceDirs beside skillDir/agentDir/commandDir, intraRefDirs beside both, and materializeRoot beside userSurfaceRoot — and nothing related the copies, so a layout could disagree with itself and no check would see it. One did: opencodeLayout named a skill dir that surfaceDirs omitted, and OpenCode's skills were read by nothing. The copies are now surfaceDirs(), executableSourceDirs() and materializePrefix() — functions of what is left, so there is no second place to disagree with.

    interface PluginLayout {
        hooksConventionPath?: string;
        hookScriptsDir?: string;
        instructionFile: string;
        manifestPath: string;
        mcpConfigFile: string;
        mcpManifestKey: string;
        name: string;
        outputStyles?: OutputStyleRules;
        pluginRootToken: string;
        projectRootTokens?: readonly string[];
        rulesDir?: string;
        settings: SettingsCodec;
        settingsLocalInfix?: string;
        settingsPath: string;
        surfaces: SurfaceDirs;
        userSurfaceRoot?: string;
        instructionChain(files: Readonly<Record<string, string>>): InstructionChain;
    }
    Index

    Properties

    hooksConventionPath?: string

    The conventional standalone hooks FILE a plugin may ship instead of inline registrations, e.g. hooks/hooks.json. A file, never a directory.

    Optional because OpenCode has no such file — its .opencode/plugin is a DIRECTORY of JS modules, and naming it here made every reader that treats this as a file (dirname, parse, round-trip) wrong about it.

    hookScriptsDir?: string

    The directory a plugin keeps its EXECUTABLE HOOK SCRIPTS in (hooks) — distinct from where the hooks are REGISTERED (hooksConventionPath, settingsPath). Absent means the harness has no scripts directory to scan (OpenCode's hooks are in-process code modules).

    🔴 IT REPLACES TWO AD-HOC DERIVATIONS AND ONE HAND-WRITTEN LIST, which is why it is a field rather than something computed at each site. The list was intraRefDirs, written out per layout and therefore free to disagree with the surfaces beside it (on opencode it did, dropping the skill dir). The derivations were hooksConventionPath.split("/")[0], copied into scan.ts and scan-files.ts — which reads hooks from hooks/hooks.json but .codex from .codex/hooks.json, i.e. it did not name a scripts directory at all for Codex. Both are now executableSourceDirs.

    instructionFile: string

    Top-level instruction file, e.g. CLAUDE.md.

    manifestPath: string

    Plugin manifest, e.g. .claude-plugin/plugin.json.

    mcpConfigFile: string

    Standalone MCP config file, e.g. .mcp.json.

    mcpManifestKey: string

    Manifest key declaring MCP servers, e.g. mcpServers.

    name: string

    Stable identifier, e.g. "claude-code".

    outputStyles?: OutputStyleRules

    Where this harness keeps output styles and how it switches one on (absent = this harness has none). Not a SurfaceKind: like a rules file, a style is read into the session, not invoked. See output-style.ts.

    pluginRootToken: string

    Env token expanded to the plugin's absolute root in hook commands.

    projectRootTokens?: readonly string[]

    Tokens that root a path at the PROJECT being scanned, braced form (${CLAUDE_PROJECT_DIR}); the unbraced spelling is derived. Optional — a harness with no such variable omits it.

    🔴 SEPARATE FROM pluginRootToken, and its absence made a whole surface kind invisible. hookScripts stripped only the plugin token, so a project's own "$CLAUDE_PROJECT_DIR/.claude/hooks/x.sh" — Claude Code's DOCUMENTED spelling for a project hook, because hooks do not run with a stable cwd — failed existsSync and was dropped. MEASURED on a real consumer repo: audit listed sixteen hooks and the SURFACE list held zero.

    rulesDir?: string

    Path-scoped RULES dir, holding flat <dir>/<name>.md, e.g. rules (absent = this harness has no such layer; "" is refused by conformance).

    Claude Code loads .claude/rules/*.md as project instructions, scoped by a paths: frontmatter key. It is an INSTRUCTION surface — often where a team's hardest policies actually live — and until now no layout named it, so frontmatter-valid and the rule map simply never saw those files. An adopter reported five such files arriving in a session labelled "project instructions" while lint did not mention them at all (#175.3).

    Not a SurfaceKind: an instruction is read, not invoked — see the docblock there for the count behind that.

    settings: SettingsCodec

    How manifestPath and settingsPath are ENCODED — a codec, not a format name. It knows bytes-to-value and nothing about what the value means; the hooks-entry SHAPE lives on HookProtocol.registration.

    🔴 IT USED TO BE settingsFormat: "json" | "toml", and nine call sites branched on it. The type had exactly two inhabitants and both were ours, so a third-party adapter whose settings are YAML could declare nothing the core would honour — and the conformance kit re-checked the enum, which is the tell: a data field whose legal values the core already knows is not data, it is a hidden switch. Six of the nine branches were the encoding and are now settings.parse / settings.render; three were the entry shape and moved to the port that owns shapes.

    settingsLocalInfix?: string

    The WORD a per-machine settings sibling inserts before the extension, e.g. local → .claude/settings.local.json (absent = this harness has no per-machine settings layer that we have OBSERVED).

    🔴 THE CORE USED TO SYNTHESIZE THIS FOR EVERYBODY, and the function it used says on its own docblock why that cannot be right: "Both vendors name a per-machine file by inserting a word before the extension, and they choose DIFFERENT words — Claude Code local, Codex override — so the word is the argument and the spelling is not." settingsSourcePaths passed "local" unconditionally three lines below it, so a Codex repository holding .codex/config.local.toml had that file read as a real settings layer, and a project_doc_fallback_filenames in it could name an instruction file we then scored — out of a file the harness never opens. opencode.local.json the same.

    Absence is the honest default: only the adapter knows whether its vendor has such a layer, and we model what has been observed rather than what is plausible by analogy. Every other siblingNamed caller already lives in an adapter for exactly this reason.

    settingsPath: string

    Repo settings carrying hooks, e.g. .claude/settings.json or .codex/config.toml.

    surfaces: SurfaceDirs

    Where each model surface lives — see SurfaceDirs. At least one kind is required (conformance); a kind this harness does not have is an absent key, never "".

    userSurfaceRoot?: string

    The dot-directory a plain END USER keeps the same surfaces under, when the harness has such a second home (.claude → .claude/skills). When set, every surface is read from BOTH <surface> and <userSurfaceRoot>/<surface>, and this is ALSO the prefix a relocated scope is keyed under.

    🔴 IT USED TO BE TWO FIELDS. materializeRoot sat beside this one and was EQUAL to it in all three shipped layouts (.claude/.claude, ""/absent, ""/absent) while having no defined meaning when they differed — the scope-key guard existed precisely to catch a layout that named .claude as both its materialize root and a second scope's base, and with one field that state cannot be written. Absent means the surfaces have exactly one home and file-map keys equal on-disk paths; see materializePrefix.

    Methods

    • Which of the instruction-shaped files the DOMAIN enumerated this harness loads at a repo-root session, in load order — and for every one it does not load, WHY. Pure: a function of files alone, with no root, no node: module and no way to name a path the domain did not open.

      🔴 IT REPLACED A GLOB MINI-LANGUAGE AND ITS PRIVATE INTERPRETER. HarnessDialect.instructionBudget.alwaysLoaded was an array of globs an ADAPTER wrote and the CORE walked the repository to expand, so shipping "**/AGENTS.md" made vigiles read every directory of somebody else's project. See ./instruction-chain.ts for the three defects that had, and for why none of them is expressible as a better glob.

      instructionFile still names the PRIMARY root file — the one compile writes, init scaffolds and detect scores. Whether it is in the loaded chain, and what else is, is this method's answer and never that field's.

      Parameters

      • files: Readonly<Record<string, string>>

      Returns InstructionChain