import type { HookEventIssue } from "./core/hook-events.js"; import { type DescriptionOverlap } from "./core/description-overlap.js"; import { type DescriptionBudgetIssue } from "./core/skill-description-budget.js"; import { type HookLinePlan } from "./core/hook-command.js"; import type { SkillRefSource } from "./skill-refs.js"; import { type PluginLayout } from "./core/layout.js"; import type { HarnessDialect } from "./core/dialect.js"; import type { HookRegistration } from "./core/hook-normalize.js"; import type { HookScriptEntry } from "./core/hook-block-ineffective.js"; import type { HookMatcherEntry } from "./core/hook-matcher.js"; import type { ScanSkill, ScanAgent, ScanHook, FrontmatterIssue, FrontmatterValueIssue, FrontmatterParseIssue, ScanDelegationFinding, ScanReport, VocabularyNote } from "./scan.js"; /** * Per-kind surface classifiers, built from the harness `PluginLayout`'s * `skillDir`/`agentDir`/`commandDir` — so adding a harness whose subagents live * somewhere other than `agents/` (OpenCode's `.opencode/agent`) needs no change * here. Each anchors on a real path boundary (start-of-path or a `/`), so a * directory whose NAME merely ends in the keyword isn't misclassified — e.g. the * skill `skills/dispatching-parallel-agents/SKILL.md` must NOT register as an * agent named "SKILL" (the `-agents/` substring), which real plugins like * obra/superpowers ship. See scan.test.ts for the regression cases. */ export interface SurfaceClassifier { readonly isSkill: (f: string) => boolean; readonly isAgent: (f: string) => boolean; readonly isCommand: (f: string) => boolean; /** * The subagent identity for a path `isAgent` accepts — the layout-scoped name * from {@link agentSurfaceName}. Lives here because the classifier is already * the one thing holding the layout's surface dirs; a caller deriving the name * itself would need the layout too, and would be free to derive a different * one. Null for a path this classifier does not call an agent. */ readonly agentName: (f: string) => string | null; /** * A path-scoped RULES file (`/.md`) — an INSTRUCTION surface, * not an invocable one. Always false for a layout with no `rulesDir`. */ readonly isRule: (f: string) => boolean; } export declare function makeClassifier(layout: PluginLayout): SurfaceClassifier; /** * ONE classifier over SEVERAL layouts — a path is a skill if ANY declared * harness would read it as one. * * 🔴 IT EXISTS BECAUSE A MERGED FILE MAP HAS NO SINGLE LAYOUT. A repo declaring * `{"harnesses": {"claude-code": {…}, "codex": {}}}` is read under BOTH layouts * and the two file maps merge into one, at which point classifying with one * layout drops the other's surfaces on the floor — Codex's `prompts/x.md` is not * a Claude Code command, and silently counting it as nothing is the same shape of * bug as reading no surfaces and grading A (100). * * `agentName` answers from the FIRST layout that calls the path an agent, so the * name is always the one the layout that claimed it would give — never a name * derived under a layout that would not have read the file at all. * * A single-layout list behaves byte-identically to {@link makeClassifier}, which * is what lets every existing caller keep passing one layout. */ export declare function makeUnionClassifier(layouts: readonly PluginLayout[]): SurfaceClassifier; /** The plugin-root + materialize-root + dialect context skill scanning needs. */ export interface SkillScanContext { readonly root: string; readonly materializeRoot: string; readonly dialect: HarnessDialect; /** * Materialized-key → real on-disk path (from `loadPlugin`). A surface can be * materialized under a canonical key while living at a different root (repo-root * `skills/` vs project-level `.claude/skills/`), so bundled-resource resolution * uses the true dir from here rather than reverse-guessing from the key. */ readonly sources?: Record; /** OPT-IN top-level shared-resource dirs (`.vigilesrc.json` `sharedDirs`). */ readonly sharedDirs?: readonly string[]; /** * The REPO root the `sharedDirs` are declared relative to (where `.vigilesrc.json` * lives) — distinct from the scan `root`, which may be a scoped SUBDIR (e.g. * `lint packages/foo`). A shared-dir ref resolves against THIS, not the subdir, * so a scoped scan doesn't false-flag the top-level shared tree. Defaults to * `root` (unchanged when scanning the repo root itself). */ readonly sharedDirsRoot?: string; /** * REQUIRED existence check for bundled-resource resolution. The disk scan * (`scanPlugin`) injects `node:fs` `existsSync`; the browser file-map engine * (`scanFiles`) a map-backed impl — so the same `scanSkills` runs on disk OR * in the browser with no `node:` import in this node-free module. */ readonly existsSync: (p: string) => boolean; } /** * Remap a batch of path-tagged findings from the synthetic materialize key to the * real on-disk path (dogfood E1) — in BOTH the `path` field AND any embedded * `message` (the detectors interpolate the key into their message text). Applied * at report assembly to the frontmatter-family findings, which iterate the loaded * file map's canonical keys directly (unlike ScanAgent/ScanSkill, whose `.path` * is already remapped). No-op for a finding whose key IS the real path. */ export declare function remapFindingPaths(findings: readonly T[], sources: Record, root: string): T[]; /** * Does the repo already ship its OWN test setup — a real `package.json` `test` * script, or a conventional test dir? Used by `audit` to credit an existing * testing story as OPTIONAL (a repo isn't scolded for surfaces with no vigiles * test when it clearly tests some other way). Node-free + injectable so the same * detector runs on disk (`node:fs`) AND in the browser (map-backed), keeping the * disk report and the demo report byte-identical (the OUTPUT-PARITY the * scanFiles-vs-scanPlugin gate enforces). */ export declare function detectOwnTestSignal(root: string, io: { readonly readFile: (p: string) => string; readonly existsSync: (p: string) => boolean; }): boolean; export declare function scanSkills(files: Record, cls: SurfaceClassifier, ctx: SkillScanContext): ScanSkill[]; /** * The `(name, reported path, content)` triples `brokenSkillRefs` reads. * * 🔴 Built from the loaded file map's CANONICAL keys, NOT from `ScanSkill.path`. * That field is the REAL on-disk path (the E1 remap), and in the ordinary * published-plugin layout — `skills/foo/SKILL.md` — it is not a key of `files` * at all: the loader materializes those under `.claude/skills/foo/SKILL.md`. * Reading `files[skill.path]` therefore returned `undefined` for EVERY skill and * dropped it, so the check reported nothing on the layout plugins actually ship * in — including this repo's own. Measured 2026-08-11 on a two-skill fixture: * byte-identical content found 2 broken refs under `.claude/skills/` and 0 under * `skills/`. A silent no-op, which is the failure class this tool exists to * catch in other people's harnesses. * * The path is still remapped for the MESSAGE — a finding must name a file the * reader can open. */ export declare function skillRefSources(files: Record, cls: SurfaceClassifier, ctx: { readonly root: string; readonly sources?: Record; }): SkillRefSource[]; /** Where a materialized key really lives — for reporting a surface by path. */ export interface SurfacePathContext { readonly root: string; readonly sources?: Record; } export declare function descriptionOverlapsFor(files: Record, cls: SurfaceClassifier, where?: SurfacePathContext): DescriptionOverlap[]; /** * Model-invocable skills whose description is so long the trigger signal is * buried (heuristic proxy; degrades recall + precision). Same surfaces as the * overlap check. See skill-description-budget.ts. */ export declare function descriptionBudgetFor(files: Record, cls: SurfaceClassifier): DescriptionBudgetIssue[]; export declare function scanAgents(files: Record, dialect: HarnessDialect, declaredServers: readonly string[], cls: SurfaceClassifier, ctx?: { root: string; sources?: Record; }): ScanAgent[]; /** * The hook sources a repo has, from the names in its hooks directory: every * `.hook.` source, and every source a stamp names — so wiring left behind by a * deleted hook still plans its line, and the missing program is reported. */ export declare function hookSourceRefs(names: readonly string[]): readonly string[]; /** * The `hook-script-exists` finding text. It names WHY nobody noticed, because * that is the part the author cannot see: a hook wired fail-open turns "cannot * load" into the same silence as "nothing to say". `compile` ends every * compiled reminder `|| exit 0` on purpose; a hand-written line may end * `|| true`, which does the same. A missing file under * `node_modules/` is a dependency that is not installed, so the cure differs. */ export declare function hookScriptMissingMessage(script: string): string; /** * A hook line vigiles did not write: no line `compile` would write, so vigiles * cannot tell what it runs or whether it swallows its own failure. */ export interface HandWrittenHook { readonly event: string; readonly command: string; } /** * A line vigiles DID write that is now wrong: `compile`'s merge key says it * wires one of this repo's hook sources, but it is not the line `compile` * writes for it — edited by hand, or compiled by another version. A defect in * vigiles's own wiring, so `hook-script-exists` reports it, and the cure is * stable: recompile. */ export interface StaleHook { readonly event: string; readonly command: string; /** The hook source it wires. */ readonly ref: string; } /** The `hand-written-hook` finding for one line. */ export declare function handWrittenHookMessage(hook: HandWrittenHook): string; /** The `hook-script-exists` finding for a compiled line that is no longer the planned one. */ export declare function staleHookMessage(hook: StaleHook): string; /** * The files every compiled hook line needs (from the plan), and every line * vigiles did not write. */ export declare function scanHooks(regs: readonly HookRegistration[], scan: Pick): HookLineFindings; /** What reading a set of hook lines finds. */ export interface HookLineFindings { /** The files compiled lines need, and whether each is on disk. */ readonly hooks: readonly ScanHook[]; readonly inline: number; readonly handWritten: readonly HandWrittenHook[]; readonly stale: readonly StaleHook[]; } /** What {@link scanHookScripts} needs to know about the engine it runs in. */ export interface HookScriptScan { /** The directory every root token and relative path resolves against. */ readonly root: string; readonly layout: Pick; readonly exists: (path: string) => boolean; /** The lines vigiles would write here; see {@link HookLinePlan}. */ readonly plan: HookLinePlan; /** * Whether this engine can see the file at all. The disk scan sees * everything; the file map is a GitHub fetch and never holds `node_modules`, * so there a dependency path absent from the map proves nothing. */ readonly observable: (absPath: string) => boolean; } /** * {@link scanHooks} over everything a repo WIRES, for the existence check: * the hooks the loader chose (`regs`), plus the project settings a plugin * manifest shadowed (`shadowed`, see `LoadedPlugin.shadowedSettingsHooks`). * * 🔴 WHY THE RUNNER IS ON THE LIST. A compiled hook is * `node "$CLAUDE_PROJECT_DIR/node_modules/vigiles/dist/cli.js" … || exit 0`, and * the `|| exit 0` is deliberate: a reminder fails open. So when the runner is * missing, the shell turns MODULE_NOT_FOUND into "nothing to say", and only a * path check can tell the two apart. Measured 2026-10-11 on the vigiles * repository itself: both of its nudges dead since #260, three weeks, every gate * green — the runner was never checked and the file it was wired in was never * read (the repo is a plugin, and the loader reads the manifest's hooks instead). * * The shadowed registrations feed ONLY the existence list and the stale list * (both are about lines vigiles wrote): the inline count, * the hand-written list, and every other hook detector still see what the * loader chose — widening those is a separate decision. So in a plugin repo the * hand-written hooks of `.claude/settings.json` are not listed. */ export declare function scanHookScripts(regs: readonly HookRegistration[], shadowed: readonly HookRegistration[], scan: HookScriptScan): HookLineFindings; /** True when `absPath` lies inside a `node_modules` directory. */ export declare function isDependencyPath(absPath: string): boolean; /** * Frontmatter-schema check — **subagents only**. Per the Claude Code docs, a * subagent (`agents/*.md`) REQUIRES `name` + `description` (no fallback) or it * won't register. A SKILL.md requires NOTHING: `name` falls back to the directory * name and `description` to the first body paragraph, so a frontmatter-less skill * still loads — flagging it would be a false positive (skill description QUALITY * is a separate, behavioral concern). See https://code.claude.com/docs/en/skills * and …/sub-agents. */ export declare function frontmatterIssuesFor(files: Record, cls: SurfaceClassifier): FrontmatterIssue[]; /** * Agent frontmatter VALUE validity — a `model:` or `color:` that's a close typo * of a real one. A bad `model:` silently falls back; a bad `color:` is ignored. * High-precision (close-typo only); a full/dated model id is left alone. Folded * into the `subagent-frontmatter` rule. Agents only (skills have no model/color). */ export declare function frontmatterValueIssuesFor(files: Record, cls: SurfaceClassifier): FrontmatterValueIssue[]; /** * Frontmatter that EXISTS but isn't valid YAML — the `frontmatter-valid` signal. * Reported for skills + agents via the shared reader's `malformed` flag. Honest * caveat (see docs/rules/frontmatter-valid.md): js-yaml is stricter than some * loaders, so a one-line `description:` containing a `: ` colon or an `` * block is flagged even though it may still load — which is why scan surfaces it * as an informational note (NOT a structural defect) and the lint rule is a * warn, not an error. The file's other fields are still salvaged. */ export declare function malformedFrontmatterFor(files: Record, cls: SurfaceClassifier): FrontmatterParseIssue[]; /** * Skill-metadata RECOMMENDATION (not a correctness check): a `SKILL.md` loads * fine without frontmatter (`name` ← dir, `description` ← first body paragraph), * but relying on those fallbacks is fragile — the dir name may be unclear and the * first paragraph is often a heading or boilerplate, making a weak trigger * surface. Best practice is an EXPLICIT `name` + `description`. Flags skills * missing either; surfaced as a soft note in scan (NOT a structural defect, NOT * scored) and gated by the `skill-frontmatter` lint rule (warn by default). */ export declare function skillMetaIssuesFor(files: Record, cls: SurfaceClassifier): FrontmatterIssue[]; /** * Flatten the per-surface lethal-trifecta and skill-fence findings into the * path-tagged report lists the `audit` report AND the lint rules both consume * (one detector, no drift). * Shared by the disk scan and the browser twin, and keyed by the report's own * field names, so both engines spread the same lists. */ export declare function collectSurfaceFindings(agents: readonly ScanAgent[], skills: readonly ScanSkill[]): Pick; /** * Build the subagent delegation graph and flag a lethal trifecta that EMERGES * across an edge (own ∪ delegated-to capability) though no single unit trips it. * * Edge source (deterministic, audit-available): a subagent that lists the `Task` * tool can dispatch any sibling subagent, so it `delegatesTo` every OTHER agent. * An inherits-all agent (`tools === null`) carries the wildcard, which the * detector's FP-safe guard skips (that maximal-blast case is the per-unit * advisory's job). One detector, no drift. (Richer edge sources — a typed * railway's `delegate()` chain, a Flue subagent inheritance tree — plug in here.) */ export declare function collectDelegationTrifecta(agents: readonly ScanAgent[], dialect: HarnessDialect): ScanDelegationFinding[]; /** * Build `hookBlockIssues` entries PER REGISTRATION — so a hook registered under * several events is inspected under EACH. A compiled line's planned files that * are on disk are inspected; any other line is inspected as its command text * (scriptPath null): vigiles does not read a hand-written line to find a script. */ export declare function collectHookBlockEntries(regs: readonly HookRegistration[], scan: Pick): HookScriptEntry[]; /** Extract (event, matcher) pairs from the normalized hook registrations. */ export declare function collectHookMatchers(regs: readonly HookRegistration[]): HookMatcherEntry[]; /** Tally how many scanned agents fall into each purity rung (effectSurface). */ export declare function summarizePurity(agents: readonly ScanAgent[]): { pure: number; bounded: number; unrestricted: number; }; /** * Gather the advisory half of the vocabulary findings for the report. Kept in * one place so hook events and subagent tools present identically — the two used * to answer the same question with different policies. */ export declare function collectVocabularyNotes(hookEventIssues: readonly HookEventIssue[], agents: readonly ScanAgent[]): VocabularyNote[]; //# sourceMappingURL=scan-core.d.ts.map