* fix(hook): the subagent-delivery caveat is stale, and now a test says so Claude Code #34692 — a PreToolUse hook not firing for a subagent's tool calls — was closed not-planned and quoted across these docs for months as a standing limit on what a gate can promise. It is fixed. Measured against a stock @anthropic-ai/claude-code@2.1.241 installed from the registry, not the container's binary: a subagent's own Bash reaches the hook and an exit-2 deny stops it, with the parent's identical command in the same run as the control and the marker files on disk as ground truth. The event also carries agent_type, naming which subagent made the call. The claim is about someone else's product, so prose cannot keep it honest — a doc cannot notice that the platform moved. src/subagent-delivery.test.ts pins both directions and goes red on a regression; removing the deny from its hook fails it on its own assertion. What did NOT change is stated at every site that was edited: a model can still route around a tool entirely, so a gate remains a strong default and is never unbypassable. The measurement's scope is stated too — headless only, with interactive sessions unmeasured and depth-2 nesting absent there. Gates run, in order: vitest (3282 passed), lint (0 errors), prettier --check, docs:check, internal:check, api:check, build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: regenerate the committed project-file types for the new test CI's generated-types:committed gate compares a fresh `generate types` run against the committed file, and adding a source file changes that list. Caught by CI rather than locally because I ran the individual commands instead of `npm run check`, which is the aggregate CI actually runs — the same shortcut this repo has already written down as a recurring miss. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
262 KiB
CLAUDE.md
Positioning
vigiles verifies the rule references in agent instruction files — that each linter rule exists AND is enabled, that file paths and scripts are real. ⚠️ ADOPTION DIRECTION (committed 2026-07-15): audit-FIRST, not spec-first. The markdown the user hand-edits is the SOURCE OF TRUTH; enforcement of code-quality rules lands in the repo's NATIVE linter config (ruff/eslint), not the spec; the typed .spec.ts is an OPTIONAL authoring layer for harness-STRUCTURE rules only (subagent contracts, purity, railway, composition — what no linter can express). init is the GRADUATION to a spec, not the front door (audit is — see below); adopt/strengthen are skills, not verbs. The first pass writes nothing and asserts nothing false (ref-verification is advisory, never written into the file). eject always reverses. The rule: the spec AUTHORS, the native linter RUNS (same architecture as @vigiles/rule-enforcer) — Rule of Least Power applied to enforcement homes. [LEGACY, being retired: the earlier spec-first three-level ladder — inline/frontmatter/typed — and init adopting a CLAUDE.md into a spec-as-source-of-truth with the markdown as a build artifact. Treat as current-shipped, NOT the target.] Nobody else does this — other tools lint markdown after the fact. See docs/markdown-mode.md.
Positioned in the harness engineering frame coined early 2026: Agent = Model + Harness. The harness has two enforcement modes — probabilistic compliance (prompts, instructions) and deterministic constraints (linters, types, hooks). vigiles is the deterministic-constraints layer for instruction files.
The sharper, structural form of "deterministic constraints" is the CATEGORY line: markdown is inert prose, but a typed .spec.ts is a PROGRAM, so the whole PL/formal-methods toolbox applies to the harness — and none of it can apply to a markdown file. vigiles is a COMPILER/VERIFIER for agent harnesses. What that buys, concretely: UNSAFE HARNESSES DON'T COMPILE — a config that leaks, exceeds its declared effect floor, hands off mismatched data, or mutates out of order is a TYPE error at edit time, not a runtime surprise a linter notices after the fact (SHIPPED: typed purity — purity:'pure'+'Bash' won't tsc — and typed composition); the keystone is TYPE-SAFE PIPELINING — pipe(producer, pipeStep(agent, needs({…}))) cross-references at compile time that step N's ok SUPPLIES step N+1's needs, so a multi-agent pipeline DOESN'T COMPILE IF THE HANDOFFS DON'T LINE UP. The enforcement is GRADUATED/OPT-IN, never all-or-nothing — the Level 0/1/2 ladder, the plain agent() vs the opt-in typed vigiles/claude-code import, the purity:'dangerously-unrestricted' escape hatch — progressive like TypeScript's strict, not a wall.
THE ADOPTION FRONT DOOR is vigiles audit — "Lighthouse for your harness": one zero-config command → four DETERMINISTIC category RINGS (Truthfulness/Triggering/Structure/Tested, weighted A–F) + each finding's fix inline + a shareable HTML report. A plain audit is a DETERMINISTIC READ — safe to run anywhere (even a prod-wired repo), IDENTICAL on every OS, nothing executes. It is a LOCAL report (like Lighthouse), NOT a CI step — CI uses vigiles lint. The TWO executing checks — live MCP resolution (do referenced tools resolve on the real server?) + trigger-rate (do your skills FIRE?) — sit behind ONE CONSENT (the read-vs-run axis, src/scan-trigger-suggest.ts decideExecute): at a TTY audit ASKS ONCE (a bundled prompt that DISCLOSES confinement + cost) and REMEMBERS in .vigilesrc.json (audit.measure); headless (--json/CI/non-interactive/agent) it stays a read + a one-line nudge (never hangs, never silently executes). There is deliberately NO execution flag: audit is a LOCAL report (like Lighthouse), NOT a CI step — CI uses vigiles lint (the deterministic gate). It runs the executing checks only when a human can consent; AUTOMATION tests the harness via the vigiles testing API + skills, never the report verb. The founder-driven simplification (2026-06-27) collapsed the earlier --deep/--measure/--fast flag sprawl into this ONE read-vs-run consent, because a uniform interactive choice beats per-tier toggles. WHY these two are opt-in: live MCP STARTS your own servers (a real backend connection) and trigger-rate spends model quota — neither is unsafe, but both DO something beyond a read, so a plain audit never does them without a human's yes. On consent: LIVE MCP is own-repo only (never a stranger's server) because STARTING a server connects to a backend and deny-all-net would break the tools/list it performs; trigger-rate STUBS skill bodies so no skill PROCEDURE runs (hasModelAccess/isMeteredAccess only shape the disclosure wording, sub=$0 vs metered=credits). THE SAFETY BATTERY (do your hooks actually block?) is DELIBERATELY NOT an audit ring (narrowed 2026-06-27, founder "no half-made shit pre-release"): running ARBITRARY hooks safely needs cross-platform confinement and that's parked (bubblewrap is Linux-only; env-scrub ephemeral floor + macOS sandbox-exec are the unbuilt exit criterion) — so rather than ship a Linux-confined/Mac-unconfined ring, the battery lives in the vigiles testing API (guardrail-check/assertBlocksDisasters) where you opt in EXPLICITLY (a test you wrote, no zero-config-safety promise to break). audit re-promotes a Safety ring only once one confinement works the same on macOS+Linux. Everything renders FROM the versioned AuditReport JSON (src/audit-report.ts, schemaVersion), never from the HTML: the local React/shadcn single-file report and audit --json for CI. The report UI is a real Vite + React + shadcn app (report/) built to ONE self-contained file the CLI fills with the JSON (React runs in the reader's browser; the CLI ships only the built template + stays runtime-dep-light), and its components are presentational so any other renderer works off the same JSON contract.
The cross-referencing engine is what the tool is built on: enforce("@typescript-eslint/no-floating-promises") verifies the rule exists AND is enabled in your linter config. Same for ESLint, Ruff, Clippy, Pylint, RuboCop, Stylelint, Cedar policies (for AWS Bedrock AgentCore and other Cedar-using runtimes), and the JVM/Go ecosystem — detekt, ktlint, Checkstyle, golangci-lint. No other tool resolves rules across 11 catalog APIs.
Authoring-time feedback comes two ways: generate-types emits a .d.ts so the TS compiler PROVES .spec.ts references at edit time, and generate-schema emits a JSON Schema so a YAML LSP autocompletes and squiggles vigiles: frontmatter rule names — same guarantee, no TypeScript required. Both scan all 11 catalog APIs, package.json, and project files.
Second layer — testing the harness. Beyond verifying instruction files, vigiles tests the harness itself (hooks, settings, skills) as an assembled machine, not one hook at a time: runHarnessTest/runEval take a plugin path that loads the real harness (hooks with ${CLAUDE_PLUGIN_ROOT} resolved, CLAUDE.md, skills) from .claude-plugin/plugin.json or .claude/settings.json (src/plugin-loader.ts, the harness-agnostic loader at the composition root), so you test what ships. Three tiers, lowest cost first: runHook pipes a synthesized event JSON straight to a hook process (no claude, no model) and checks the block/allow decision — the cheap base of the pyramid, and the only tier that reaches every event incl. Edit/Write, PreCompact, Notification, SessionEnd, SubagentStop (src/run-hook.ts); runHarnessTest runs the real claude CLI against a scripted mock model for deterministic, key-free checks that a hook is wired into the assembled machine and fires (src/harness-test.ts, src/mock-model.ts); and runEval drives the real model across A/B arms × trials, aggregating mean ± se so a gap can be read for significance (src/eval.ts). The loader materializes hooks, CLAUDE.md, skills, subagents and commands, and flags via loadPlugin().warnings any surface only a real model can drive — so loading a whole plugin never silently tests an empty machine. The API is runner-agnostic (node:test, vitest, jest) via plain async functions plus helpers/matchers in src/harness-assert.ts and an optional LLM-as-judge in src/judge.ts; a zero-dep CLI fallback runs them as vigiles test (*.harness.mjs) and vigiles eval (*.eval.mjs), with canonical examples under examples/harness/. Unlike reference verification (bounded by undecidability), this layer has no ceiling: a test measures reality, so there is nothing to game. A harness eval is NOT a model/prompt eval: the unit under test is the harness loaded as it SHIPS (the real Claude Code system prompt + the real CLAUDE.md + real hooks/settings), which is exactly what a generic eval runner (promptfoo et al., which configure an agent from YAML) cannot reproduce — so vigiles owns this, it does not rebuild the eval stack. The discipline is to keep the costly real-model surface THIN: push every question that can be answered deterministically into runHook/runHarnessTest, and let only the two irreducibly-real-model questions (does a description FIRE, does behaviour MOVE) touch a real model. The highest-value question this layer exists to answer is BEHAVIORAL and side-effecting — does the assembled harness, run end-to-end, actually DO the task AND not do the dangerous thing? — which a completion-grader (promptfoo et al.) structurally cannot reach. notTool/interceptTools ship today for the safety half (assert/deny the irreversible externals — push, paid API — you must never actually execute); the ephemeral run environment + disposable-dependency provisioning (compose/testcontainers) that make a full side-effecting run safe to REPEAT are the committed next step. This apex tier is the most valuable AND the most expensive, so it stays THIN by design — the deterministic tiers are what make it affordable. See docs/safety.md. AFFORDABILITY is the positioning of this layer: the deterministic tiers are free (no model, no key, every commit) and the real-model tier runs on your Claude PRO/MAX SUBSCRIPTION — vigiles drives the real claude CLI, so an eval authenticates like your own CLI (no metered API billing), which is why a team can actually afford to run harness evals at all, where an SDK-based runner bills per token against a metered API key on every run. Be precise about what is and is not distinctive: the deterministic mock-model tier itself is TABLE STAKES — the code-defined SDKs (Pydantic AI's TestModel/FunctionModel, Vercel AI SDK's MockLanguageModelV3, LangGraph's FakeListChatModel, LlamaIndex's MockLLM) all ship a first-party fake model, so "mock the model, assert deterministically" is not what differentiates vigiles. The difference is the three things no SDK mock does: (1) testing the harness loaded as it SHIPS — the real CLI agent with its real system prompt + CLAUDE.md + hooks, not an agent re-assembled from SDK config; (2) deterministic tool-contract enforcement of the ASSEMBLED agent (the gap the Claude Agent SDK still carries OPEN as bug #172 — declared tools/disallowedTools are not propagated to a subagent, so vigiles's PreToolUse rail in src/adapters/claude-code/agent-runtime.ts is the fix, not a nicety); and (3) a real-model tier affordable on the sub. Evals run where the subscription already is — a Claude Code session or locally — NOT a standalone GitHub Actions job needing a token (CI runs only the free deterministic tiers). measureTriggerRate measures on the realistic SELECTOR (Sonnet default, a minModel floor) since a weaker model under-selects; the model lives in the spec (model/minModel), not an env override (trials, a run knob, can be an env). What vigiles tests sorts onto three rungs: R1 (cheap/deterministic — fire/trigger/contract/safety, nothing executes) and R2 (record-replay — the skill's deterministic logic consumes a tool/MCP/API result RECORDED ONCE from a real tool and REPLAYED by shadowing the binary on PATH, never model-synthesized) cover ~90%+ of real plugin surface with NO Docker, on the subscription; R3 (the real disposable service whose semantics IS what's under test — a browser, a relational DB, redis) is a THIN apex vigiles COMPOSES with a container for rather than reinventing the sandbox (a survey of popular community collections and an audit of a ~90-artifact production skill set converge on R1≈48–90% / R2≈10–43% / R3≈0–9%). Safety: R1 nothing runs, R2 fake outputs touch no real system, R3 real side effects only inside an isolated disposable container; viability: R1+R2 need no Docker and run on the sub; performance: R1/R2 ms-fast, R3 Docker cold-start stays thin. No tool does containerless reproducible e2e (every e2e benchmark/lab runs in a container) — so vigiles owns R1+R2 + sub-affordability + a clean container hand-off, not e2e-without-a-container. A SECOND, orthogonal axis decides the COST — the correctness oracle: a DETERMINISTIC check (hook decision, tool-contract, a structural fact) is free in CI, while a MODEL-GATED question (does a description FIRE, is the guidance's output GOOD, does prose guidance MOVE behaviour vs off — measureTriggerRate for firing AND, for behaviour, TWO oracles not one: the ABSOLUTE "is this exact skill's output good" via a single-arm measure()+judged()+assertRates (the right default when there is no on/off baseline — what promptfoo/DeepEval lead with), and the RELATIVE "does it MOVE behaviour vs off" via a runEval A/B + assertSignificant (regression / noise-floor)) runs on the sub; we tag the latter -MG, so a prose/guidance skill is R1-MG (nothing executes, but only a model judges its worth — fully testable on your sub via trigger-rate + judged behaviour, NOT uncovered and NOT free). State coverage as the TWO buckets vigiles owns — (A) free & deterministic + (B) model-gated on your sub — vs (C) needs-a-container (composed), and grade a plugin with TWO numbers ("% testable at all (A+B, free+sub)" vs "% needs-a-container"), never letting "model-gated" read as "uncovered" (testing a prose skill's behaviour needs a real model for EVERYONE — promptfoo, the SDKs, all of it; vigiles just does it on the sub). The sub-affordability is ToS-CLEAN: vigiles drives YOUR OWN claude CLI to test YOUR OWN harness on YOUR OWN subscription (the Claude Agent SDK ToS restricts PRODUCTIZING claude.ai login/limits in a third-party offering, not running your own tests on your own sub — exactly vigiles's posture). See docs/harness-testing.md.
Third layer — COMPILED HOOKS (the GATE instrument; vigiles/hook, src/core/hook-program.ts + src/hook.ts). The reliability frame is four instruments that SHRINK the harness state-space — construct (typed spec), VERIFY (lint), GATE (hooks), test (evals) — and the gate is the deterministic stop before something irreversible. A hook today is opaque shell, and the parts the author hand-writes (exit code, JSON field, a grep matcher) are exactly where the #1 verified pain lives: FALSE CONFIDENCE — a guard that LOOKS like it blocks and silently doesn't (exit 1≠2, wrong field). Invert it: author a hook as a PURE typed function (event) => Decision against a CLOSED vocabulary; vigiles compile emits the protocol, vigiles hook-runtime run-program runs it. This makes WHOLE CLASSES OF BUGS UNREPRESENTABLE — you never write the exit-code/field (false confidence), the matcher is AST-backed (command.runs("git push",{force}) catches cd x && git push -f the native glob/#30519 misses; + touches()/pipesToShell() for secret-read/curl|sh), capability = API surface (an import outside vigiles/hook does NOT compile), the artifact is STAMPED (a hand-edit breaks the SHA-256 → the runtime refuses it, fail-closed), and a category mistake (block on a no-decision event) is a tsc type error (a role FAMILY: tool-gate (defineHook/defineFileGate)→Decision, plus prompt-gate (definePromptGate — sees the prompt TEXT e.prompt, deny blocks the prompt: a security filter) and stop-gate (defineStopGate — deny keeps the agent GOING, gate-until-tests-pass, honour e.stopHookActive loop guard) also →Decision on the SAME shared exit-2 runtime (so they work on CC AND Codex), inject→Injection, react→Reaction (now sees the tool RESPONSE e.response.isError()/contains()), each with its own return type). Every gate takes mode:'enforce'|'observe' — observe is the SHADOW/rollout mode (compute the same Decision, RECORD what it would block to .vigiles/hook-observations.jsonl, exit 0, never block; harness-NEUTRAL by construction — exit 0 + a local record; gateAction is the pure decision→action mapping the runtime + tests share). PROVEN by an OSS dogfood (src/hook-dogfood.test.ts): a widely-copied hand-written safety hook blocks 2/7 of the disaster battery; the compiled rewrite (examples/harness/safe-bash-guard.mjs) blocks 7/7 — measured with the verify feature's own DISASTER_CATALOG (src/guardrail-check.ts, "prove your guardrail blocks", on vigiles). HONEST SCOPE, kept in every doc: compile/verify fix AUTHORING + LOGIC, not the harness's DELIVERY. 🔴 THE DELIVERY FLOOR MOVED (2026-08-24) — CC's subagent-bypass (#34692, closed not-planned), quoted across these docs for months as a standing limit, is FIXED: measured against a STOCK @anthropic-ai/claude-code@2.1.241 from the registry, a subagent's own Bash REACHES PreToolUse and an exit-2 deny STOPS it (the parent's identical command in the same run is the in-run control; ground truth on disk, not the trace), and the event carries agent_type naming which subagent called. Pinned by src/subagent-delivery.test.ts so a regression goes RED instead of silently reverting — prose in a doc cannot notice that someone else's product moved, a test can. WHAT DID NOT CHANGE: a model can route around a tool entirely (#45427/#32376), so a gate is STILL a STRONG DEFAULT, never an unbypassable wall (VERIFY, a claim about logic, survives any delivery gap; never claim "unbypassable"). SCOPE of the measurement: headless claude -p only — interactive is unmeasured, and depth-2 subagent nesting does not occur there at all. 🔴 EXPERIMENTAL, and the marking is STRUCTURAL (2026-08-20): the six entry points are exported as experimental_defineHook / experimental_defineFileGate / experimental_definePromptGate / experimental_defineStopGate / experimental_defineInject / experimental_defineReact, aliased at the import site. Only the entry points carry the prefix because every other name in the vocabulary is reachable ONLY from inside a define* call — the same chokepoint argument as experimental_skill.input(). Named gaps, not a disclaimer: named state (record/state) landed 2026-08-12 and is untested by anyone but its author; TESTING a stateful hook is archaeology (the store path is derived by the runtime and there is no seeding API beside runHook — the dogfood repo hard-codes the private path and it broke when facts were renamed); compile is not idempotent; two consumers, both the author's. See docs/compiled-hooks.md (the public guide) and docs/experimental.md (what would have to be true to drop the prefix).
Multi-harness by design (a hard requirement). vigiles targets Claude Code today but the core is harness-agnostic: every Claude-Code-specific fact lives behind one of five injectable ports — HarnessDialect/PluginLayout/HarnessRuntime/HookProtocol/ModelMock — bundled per harness as a HarnessAdapter. Adding a harness (Codex likely next, then OpenCode/Crush/Gemini) is writing one adapter object and registering it; the boundary rule (core ⊄ adapter) keeps the core untouched. Third-party adapters are a first-class, supported extension point: vigiles/adapter exports the five ports plus a conformance kit, documented in docs/authoring-an-adapter.md. Backwards compatibility is non-negotiable — Claude Code stays the default everywhere (the CLI auto-detects; the library selects by import), so adding adapters never breaks existing consumers. See docs/harnesses.md.
Cross-platform by requirement (macOS + Linux). Most devs are on a Mac, so confinement cannot be Linux-only: it is a native backend per OS behind a vigiles/os-isolation port — bubblewrap (+nft per-host egress) on Linux today, sandbox-exec/Seatbelt for macOS (built-in, the committed next backend), refuse otherwise. Two ORTHOGONAL protections, not one switch: HOST protection (can foreign code read my secrets / escape?) is provenance-keyed — your own code runs direct, foreign plugin/pluginDir code is confined-or-refused; STATE protection (does RUNNING a model-driven skill mutate my world?) is UNCONDITIONAL — a fresh ephemeral run environment (throwaway CWD+HOME, scrubbed env), because the model, not the author, chooses the actions. Ephemerality needs no kernel features, so it is the cross-platform floor that lands on macOS immediately; the per-host egress wall stays a Linux capability and degrades honestly to deny-all-net elsewhere. See docs/safety.md.
vigiles does NOT do architectural linting. Use ast-grep, Dependency Cruiser, Steiger, or eslint-plugin-boundaries for that. vigiles can reference their rules via enforce().
Direction
vigiles is ONE LOOP, not a bag of features: DECLARE what the harness should do (the typed spec) → CHECK reality against that declaration. Four instruments are FACETS of the same loop, never a menu — VERIFY (lint/cross-ref/compile, author-time, free), GATE (compiled hooks, loop-time, free), MEASURE (evals on your subscription), OBSERVE (a local record of what the instruments saw). A feature belongs here only if it is a facet of declare→check.
DECIDABILITY DECIDES WHICH INSTRUMENT FITS WHICH SURFACE. On deterministic surfaces (a hook decision, a subagent's tool contract, a file/rule reference, a declared effect) the "supposed to" is DECLARED by the spec, so "did reality match?" is a deterministic check — precise, free, no model. On behavioural surfaces (does a skill FIRE here?) that question is undecidable statically, so it needs an authored expectation measured on a real model (measureTriggerRate) — never claim a passive record catches a MISS. Prose like "write clean code" is neither and stays prose. The rule underneath: you can check a DECISION, you cannot check "does this fire" without running it.
Architecture
Three rule types in specs:
enforce()— delegated to external tool (linter, ast-grep, dependency-cruiser). vigiles verifies the rule exists and is enabled.guard()— a path→command guard (e.g.*.spec.ts→npx vigiles compile), compiles to**Guard:**and wires spec-driven automation into hook engines.guidance()— prose only, compiles to**Guidance only**in markdown.
Architectural linting (file pairing, import boundaries, AST patterns) belongs in external tools — reference them via enforce().
Template literal types ensure linter names (eslint/, ruff/, etc.) are type-safe. Branded types (VerifiedPath, VerifiedCmd, VerifiedRef) distinguish verified references from raw strings.
Compilation: spec.ts → compiler reads spec, validates references (file paths via existsSync, npm scripts via package.json, linter rules via linter APIs), generates markdown with SHA-256 integrity hash.
Core modules: src/core/spec.ts (types + builders), src/core/compile.ts (compiler), src/core/linters.ts (11-catalog cross-referencing engine), src/core/effects.ts (static effect-surface / purity ladder), src/core/bash-effects.ts (deterministic Bash-effect classifier), src/core/generate-types.ts (type generator), src/core/proofs.ts (proof algorithms for self-evolving specs), src/core/evolve.ts (evolution engine).
Harness-adapter layout (hexagonal — see docs/harnesses.md). The tree is split so a second harness (Codex likely next) sits beside Claude Code without touching the core:
src/core/— the harness-agnostic reference-verification DOMAIN (spec, compile, linters, proofs, …). Knows nothing about how an agent runs.src/adapters/claude-code/— the Claude Code ADAPTER: the swappable ports + bundle + harness glue (theHarnessDialect/PluginLayout/HarnessRuntime/HookProtocol/ModelMockport impls, theHarnessAdapterbundle, agent/skill runtime, run-scripts, and a thin plugin-loader wrapper that defaults the CC layout). A futuresrc/adapters/<other-harness>/mirrors it.src/root — the application/composition layer AND the harness-testing LIBRARY: cli, scan, the harness-agnosticplugin-loader(layout-injected; the CC wrapper + every other adapter delegate to it, so no adapter imports a sibling), the deterministic runners (harness-test/run-hook/eval+mock-model/sandbox/egress/judge) that default to Claude Code via an injected{ adapter }, and thetesting/unit/integration/e2ebarrels that route through the runners and NEVER import an adapter directly.
Two boundary rules are enforced by eslint-plugin-boundaries (rule boundaries/dependencies, error-mode, classified by directory) and dogfooded via enforce("boundaries/dependencies"): core ⊄ adapter (the domain stays harness-agnostic), and the agnostic-surface (src/{testing,unit,integration,e2e}.ts) ⊄ any adapter (the public agnostic entries route through the composition-root runners, so "agnostic" is enforced, not just named) — the architecture invariant is a verified reference, not a convention. Consumers select a harness by import (vigiles/claude-code beside the agnostic vigiles/vigiles/eval), not a runtime config key; the CLI selects per repo — auto-detect, a --harness= override, or a harness key in .vigilesrc.json written by init (the project-level declaration, distinct from the library's import-time selection; an array for a repo targeting several harnesses also drives a byte-identical CLAUDE.md⇄AGENTS.md mirror when no sync tool fans it out).
Layout
What each ROOT directory is (the per-file map is Key Files below; this is the per-dir index). This repo is FIVE things at once — a TS library, a shipped Claude Code plugin, two auxiliary packages, a benchmark suite, and its own dogfood harness — so the root has a dir per concern, grouped here by concern.
THE LIBRARY (the one compiled thing):
src/— all TypeScript source, compiled todist/. The ONLY thingeslint src/lints, vitest sweeps (src/**/*.test.ts), and the 100% coverage gate covers. Hexagonal:src/core/(domain) ·src/adapters/<harness>/(ports) ·src/*.ts(composition root + library). Alsosrc/schemas/= DEPRECATED mdschema presets, parked (seesrc/schemas/DEPRECATED.md).dist/— compiled JS output (gitignored; the published artifact + the audit-report template).
THE SHIPPED PLUGIN (root-level by Claude Code plugin convention — these MUST sit at the repo root beside the manifest; they are markdown/shell consumed verbatim by the harness, never compiled):
plugin.json(repo ROOT) — the AGENT PLUGINS 1.0.0 manifest (the vendor-neutral packaging standard, agent-plugins.org). Pins$schemato the 1.0.0 identifier; carries metadata only, since skills are discovered at the spec's fixedskills/<name>/SKILL.mdpath and the 1.0.0 schema has NO home for hooks/subagents. So the CC manifest below is NOT redundant — the two coexist by design (neutral = portability, CC = what only CC understands), andsrc/agent-plugins-manifest.test.tsfails CI if their name/version/description/license drift..claude-plugin/— the CLAUDE CODE plugin manifest (plugin.json, thehooksblock) +marketplace.json. The repo root IS the plugin root.skills/— the SHIPPED consumer skills (published; both manifests point here — the CC one explicitly, the neutral one by the standard's fixed path).hooks/— the SHIPPED plugin's hook scripts (pre-edit/post-edit/refs-nudge/eval-lock-nudge/session-start; published, referenced byplugin.json).
THIS REPO'S OWN HARNESS (how Claude Code behaves when a CONTRIBUTOR works in-repo — NOT shipped; .claude/ is not published):
.claude/—settings.json+hooks/+skills/..claude/skills/holds the CONTRIBUTOR-only dev skills (generate-logo, pr-to-lint-rule, enforce-rules-format, audit-feedback-loop, audience-check, code-quality) + vendored deep-research + review-docs. (The olddev/second plugin was folded here 2026-07-14.)
AUXILIARY PACKAGES (separate npm packages with their OWN package.json, NOT part of src/):
rule-enforcer/—@vigiles/rule-enforcer, the opt-in rule-SYNTHESIS engine + its two-stage blind-gold TRUST GATE (gate.js). Folded in from a former standalone repo; CI runs its gate (dogfood). Thepr-to-lint-ruleskill's engine.report/—@vigiles/report, the Vite + Tailwind audit-report app that CONSUMES@vigiles/report-viewand builds it to one self-contained HTML template the CLI fills. Kept out of the CLI's runtime deps.packages/report-view/—@vigiles/report-view, the SHARED source-only report view (presentational components + AuditReport schema + band tokens + theme.css) rendered byreport/(and latersite/+ the in-browser demo) from the same AuditReport JSON. Wired as npm WORKSPACES (packages/*,report,site) — deps hoist to root, the published package's CI stays green.
DOCS (see the doc-tiers rule):
docs/— PUBLIC user docs (what a user needs to act).
TESTS + FIXTURES + BENCHMARKS:
test/— test DATA + tier config, NOT the unit tests (those aresrc/**/*.test.ts). Holdstest/dogfood/(SHA-pinned vendored real plugins — see the dogfood-vendoring-policy rule),test/fixtures/(the E2E example-project),test/{e2e,runners,types}/.examples/— user-facing copy-paste*.harness.mjs/*.eval.mjsdemos + compiled example specs; the CLI-fallback on-ramp.bench/— the real-model A/B benchmark corpus, deliberately OUTSIDE the vitest sweep (run by node, on your sub). Its freecorpus/verify*.mjsself-checks are CI-gated.
BUILD + TOOLING + GENERATED:
scripts/— the BUILD pipeline only (api-extractor.mjs,build-report.mjs), invoked bynpm run build/CI.tools/— HUMAN-run maintenance scripts, never in CI. Each documented intools/README.md(what + WHEN-run + live/dead status): live corpus-maintenance (refresh-vendor.sh,dogfood-sweep.sh), pre-launchfp-sweep.sh, and the demo-asset tooling (demo.sh,make-demo-gif.py) for the demo assets.api-surface/— the committed public-API surface snapshots (*.api.md), one per exported entry point. A generated CONTRACT (lockfile-class), reviewed as a diff, never hand-edited: CI diffs the live.d.tssurface against these (the API surface gate) and fails a PR that changes a public export untilnpm run api:reportregenerates + commits it. (Renamed 2026-07-14 frometc/, which was Microsoft API Extractor's default output dir; the report folder is set inscripts/api-extractor.mjs.) Distinct from the human API REFERENCE (TypeDoc → GitHub Pages)..github/— CI workflows (ci.ymlis the gate)..vigiles/— vigiles's own runtime output (gitignored: hook observations, eval locks, the runs ledger).
Key Files
src/core/spec.ts— Type system and builder functions (enforce, guidance, claude, skill, agent, file, cmd, ref, symbol, dir, glob — dir() verifies a path exists AND is a directory, glob() verifies a pattern matches ≥1 path, both compile-time-checked via compile.ts validateDirRef/validateGlobRef, the lightweight architecture-floats-free authoring helpers; result/railway/delegate for railway-oriented subagents (agent.output: an OutputContract); agent color + disallowedTools (the deny-side contract, side-effect separation); skill context:'fork' + output (a forked skill runs as a subagent so it MAY carry a result() outcome — compile errors on output without fork, the category-error gate); purity floor — pure|bounded|dangerously-unrestricted — on skill/agent, the AuthoredPurity escape-hatch named loud at the declaration site; effect() — the EffectRegion InstructionFragment tagged-template marking a side-effect BOUNDARY inside a body, compiled to markers the position-aware runtime gate keys on; SUBAGENT-only — compileSkill errors on effect() in a skill (no call→return region), which declares a purity floor + context:fork instead; plus TYPED PURITY (compile-time half of the purity floor): agent()/experimental_skill() are generic over a ToolVocabulary defaulting to OpenToolVocabulary (any tools, harness-agnostic, backwards-compatible) — the mechanism is the harness-neutral AllowedAt<P,V> conditional (pure→V['readOnly'], bounded→V['bounded'] admitting Bash, else string); the CC-bound typed agent/skill (src/adapters/claude-code/typed-spec.ts, exported on vigiles/claude-code) pin the vocabulary derived from claudeCodeDialect, so purity:'pure'+'Bash' is a tsc error at edit time — a STRICT ADDITION to purityViolations (compile) and decidePurityGate (the runtime backstop that still owns the command-level bounded-Bash decision)); plus TYPED COMPOSITION — agent()/result() now PRESERVE the result() ok/err field shapes as types (result<const Ok,const Err> → OutputContract<Ok,Err>; agent() returns TypedAgentSpec<Ok,Err> = AgentSpec & TypedOutcome via a phantom carrier, additive/still an AgentSpec), powering pipe(producer, pipeStep(agent, needs({…}))) (+ the start/andThen fold) which cross-references at tsc time that step N's ok SUPPLIES step N+1's needs (Supplies<> — a shallow per-field check, TS2589-safe) so a missing-field/wrong-type/out-of-order handoff is a COMPILE error ('your multi-agent pipeline doesn't compile if the handoffs don't line up'). Strictly additive over the UNCHANGED string-based delegate('name')/railway()/compileRailway/validateRailway backstop (a typed Pipeline still carries an underlying railway for the compiled orchestrator); the fold combinator is andThen NOT then (a then export makes the module a thenable). Type-proof in test/types/composition.ts, docs in docs/railway-subagents.md). Also exports KnownAgentName<Target,Names,From> — the SHALLOW per-edge type (O(N), no recursion) the whole-harness registry (generate-harness.ts) emits to make a dangling delegate a tsc error). Also delegate(agent, task?, needs?) carries a railway edge's needs() input contract (additive 3rd arg; omitting it = the unchanged string path), with OkOf reading a TypedAgentSpec's result().ok shape past the module-private __outcome phantom (the registry's producer-side reader) + Handoff<Producer,Consumer> — the per-edge CROSS-FILE handoff check (one shallow wrap over Supplies<> mirroring KnownAgentName; {__handoff_error} names the field on mismatch), dogfooded in examples/railway/ship-pr.md.spec.ts)src/core/compile.ts— Compiler: spec → markdown with SHA-256 hash, linter verification, reference validation; compileClaude/compileSkill/compileAgent (subagents: frontmatter + verified tool contract + body marks + result-contract Output section) + compileRailway/validateRailway (orchestrator command over flat workers; delegate-target resolution + bounded recovery) + purity-floor enforcement (purityViolations: a pure/bounded contract rejects a tool looser than its floor; absent tools = inherits-all = checked as the '*' wildcard, never trivially pure) + emits a marker on a compiled agent OR skill (dangerously-unrestricted → the neutral runtime level unrestricted) so the runtime PreToolUse gate can read+enforce the declared floor (parseAgentPurity/parseSkillPurity → decidePurityGate). renderFragment/validateRefs also handle the effect() EffectRegion fragment — rendering its body wrapped in … markers (inside the integrity hash) and recursing to verify inner file()/cmd() refs. Compile gates added 2026-06-20: a generous DEFAULT_MAX_SECTION_LINES=200 guard on every named prose section (claude + agent), overridable via maxSectionLines (TS types can't bound string length); agent disallowedTools verified via disallowedToolIssues (a close typo blocks nothing); a forked skill's output renders the SAME ## Output contract via renderOutputContract, and output without context:'fork' is the output-without-fork error; effect() in a skill body is the effect-in-skill error (effect() is a SUBAGENT primitive — a skill has no call→return region to scope; it declares a purity floor + context:fork instead)src/core/hook-program.ts— COMPILED HOOKS core (the GATE instrument; harness-neutral, pure). A hook authored as a PURE typed(event) => Decisionagainst a CLOSED vocabulary, compiled to the harness protocol — so whole classes of hook bugs are UNREPRESENTABLE. A role FAMILY keyed by output type: defineHook/defineFileGate→Decision (allow/deny/ask; deny→exit 2), definePromptGate→Decision (UserPromptSubmit; reads the prompt TEXT via PromptEvent.prompt, deny blocks/erases the prompt — a security filter), defineStopGate→Decision (Stop/SubagentStop; deny BLOCKS the agent from stopping — gate-until-tests-pass — and StopEvent.stopHookActive is the loop guard), defineInject→Injection (inject(text)→additionalContext, NO deny), defineReact→Reaction (run(cmd) effect-classified at construction / notice / nothing, can't block; ReactEvent now also carries response: ResponseView via responseView() — isError()/contains() so a react can branch on whether the tool FAILED). Every GATE takes mode:'enforce'|'observe' (HookMode): gateAction(decision, mode) is the pure decision→action map — enforce blocks on deny (exit 2), observe records the would-be deny/ask + allows (exit 0), the SHADOW/rollout primitive; harness-NEUTRAL (exit 0 + a local record), hookMode(hook) reads it. The AST-backed CommandView (commandView via bash-effects.leafCommands): runs(program,{force}) sees the leaf however wrapped (catchescd x && git push -f, #30519), touches(prefixes) (secret-read, e.g. ~/.ssh/.env), pipesToShell() (curl|sh — a BARE shell leaf, neversh script.sh), isSideEffecting(). PathView.under() for file gates. CONTEXT PROVIDERS: a gate may decide on EXTERNAL STATE by declaring needs:[...] (typed over ProviderName from hook-providers.ts) — the trusted runtime gathers those read-only facts into e.ctx (HookCtx, undeclared access = a tsc error; the gate builders are generic over the declared needs), so the hook still does zero I/O (the Cedar/OPA/Gatekeeper pattern; gatherHookContext injects the real execSync in cli.ts). checkHookImports rejects any import outsidevigiles/hook(+ eval/Function/dynamic-import) so capability = API surface; compileHookProgram(source, hook, CompileHookOptions) emits the settings block + a tamper-evident SHA-256 stamp (stampHook/verifyHookStamp, integrity.ts pattern); hookRouting/dispatchKind drive emit + the runtime (prompt-gate/stop-gate/inject fire on a whole EVENT — no tool matcher). MULTI-HARNESS emit (harness-neutral by injection, core ⊄ adapter): CompileHookOptions {dialect, hookProtocol, settingsFormat} default to Claude Code (JSON settings + exact matcher) and switch to Codex (TOML [[hooks.]] via @iarna/toml + anchored-regex matcher keyed on HookProtocol.matcherStyle) when the CLI threads the resolved adapter through; verifyHookEvents validates hook.on against the dialect (a typo won't compile). The gate runtime is SHARED (Codex vetoes via exit 2 identically); inject/ask output JSON is the one deferred per-harness piece. HONEST: fixes AUTHORING+LOGIC not DELIVERY (#34692 subagent-bypass). Public surface = src/hook.ts; CLI = compile (--harness=, discovers .vigiles/hooks/)/hook-runtime run-program; guide = docs/compiled-hooks.mdsrc/core/hook-program.test.ts— Compiled-hooks core suite (vitest): the gate decision is pure + AST-matched (compound bypass denied, grep false-positive allowed), compiles to a CC block, an out-of-vocabulary import does NOT compile, the stamp is tamper-evident, touches()/pipesToShell() high-signal (secret-read + curl|sh, notsh script.sh), and the inject/react roles CANNOT express a block (two @ts-expect-error — category mistake is a tsc error). Plus: gateAction enforce-vs-observe (observe records-not-blocks), prompt-gate over e.prompt + stop-gate's loop guard, prompt/stop gates compile (no matcher) on CC AND Codex, and react's responseView (isError()/contains())src/hook.ts—vigiles/hook— the public closed vocabulary for authoring a compiled hook (re-exports core/hook-program.ts): defineHook/defineFileGate/definePromptGate/defineStopGate/defineInject/defineReact, allow/deny/ask, inject/notice/run/nothing, responseView, tool/tools, commandView/pathView, gateAction/hookMode (the observe-mode mapping), the runtime decode (decideProgram/decideFileGate/decidePromptGate/decideStopGate/runInject/runReact/dispatchKind) + runHookProgram (the in-process role-dispatcher → a normalized HookProgramOutcome, the cheapest test tier — no subprocess; its asserts assertHookDenies/assertHookAllows live in harness-assert on thevigilesroot), and compile + integrity (compileHookProgram/checkHookImports/stampHook/verifyHookStamp). Header carries the honest #34692 delivery-floor note. Exported as./hook; api-extractor tracks api-surface/vigiles-hook.api.mdsrc/hook.test.ts— Compiled-hooks E2E (vitest): drives the REAL built CLI runtimenode dist/cli.js hook-runtime run-program <fixture.mjs>over runHook — a gate denies force-push (exit 2) + the compound bypass and allows benign, a file-gate confines Write/Edit, an inject emits additionalContext, a prompt-gate denies a secret-bearing UserPromptSubmit + a stop-gate blocks stopping (loop-guarded), an observe-mode gate records-not-blocks (exit 0 + .vigiles/hook-observations.jsonl), compile rejects an out-of-vocabulary import (exit 1), and a compiled-then-hand-edited artifact is REFUSED at runtime via the stamp (fail closed)src/hook-install.ts— Hook installation — the bridge folding hook compilation intovigiles compile(no straycompile-hookverb; cohesive-cli-surface). The typed hook is harness-NEUTRAL, so its SOURCE lives in the agnostic, committed HOOKS_DIR (.vigiles/hooks/), never a harness's.claude/. discoverHookFiles sweeps it; mergeHooksJson/mergeHooksToml idempotently MERGE the compiled block into the active harness's native config (.claude/settings.jsonJSON /.codex/config.tomlTOML) keyed by the hook PATH (recompile updates in place, never clobbers the user's own hooks — and one dir makes basenames unique, retiring the stamp basename-collision edge); serializeConfig writes it back. Pure + unit-tested (src/hook-install.test.ts)src/hook-install.test.ts— Hook-install suite (vitest): mergeHooksJson adds to an empty/existing settings object, preserves the user's own + a sibling vigiles hook for a different file, is idempotent (recompile replaces, never duplicates), preserves non-hook top-level keys; mergeHooksToml flattens to Codex's {matcher,command} + round-trips; discoverHookFiles finds JS/TS under .vigiles/hooks excluding stamps, [] when absentsrc/hook-dogfood.test.ts— OSS dogfood (vitest, model-free): proves a COMPILED hook closes the gaps a hand-written guard leaves, using the DISASTER_CATALOG as the oracle — a faithful substring guard (the widely-copied disler shape) misses 5/7; the compiled rewrite (examples/harness/safe-bash-guard.mjs) blocks 7/7. The runnable 'prove worth' artifact behind the compiled-hooks pitchsrc/hook-oss-comparison.test.ts— Aggressive OSS comparison dogfood (vitest, model-free): compiled hooks vs the hand-written shapes the ecosystem ships, DISASTER_CATALOG as oracle. Isolates the NON-CIRCULAR structural wins one per test — EVASION (a substring/prefix force-push guard misses the compoundgit push -f; the AST catches both), PRECISION (a grep guard false-positives on a benign echo; compiled allows it), PROTOCOL (an exit-1 guard is false confidence; compiled emits exit 2) — plus the breadth headline (2/7 blocklist vs 7/7 compiled). Originals are faithful reconstructions (unlicensed sources → shape not file).src/guardrail-check.ts— VERIFY feature — 'prove your safety hook ACTUALLY blocks' (onvigilesroot). Feeds a curated DISASTER_CATALOG (force-push incl. compound, reset --hard, rm -rf, --no-verify, SSH-key read, curl|sh) to a hook via runHook and checks the decision is BLOCK. verifyGuardrail + unblockedDisasters + assertBlocksDisasters (the CI gate — throws 'false confidence' on a miss, the ONLY place intent is asserted) + formatGuardrailReport (NEUTRAL coverage map — reports allows WITHOUT judging, since a hook may simply have a narrower scope). Deterministic, no key; sidesteps the #34692 delivery bug (verifies LOGIC). Distinct from compiled hooks (author) — this AUDITS any hook, hand-written or compiledexamples/harness/safe-bash-guard.mjs— The compiled-hook dogfood artifact — a Bash safety gate authored againstvigiles/hookexpressing a real guard's full intent (force-push/reset --hard/--no-verify/forced-rm/secret-read/curl|sh) as a pure typed function. In-repo it imports the built dist (runs via hook-runtime run-program in src/hook-dogfood.test.ts); external users authorfrom "vigiles/hook"+vigiles compilesrc/core/guards.ts— EXPERIMENTAL prototype (the GATE axis of the reliability runtime) — typed safe-by-construction harness GUARDS: declare guard.block / requireBefore (the ORDER axis — destroy-after-plan, enforced live across hook invocations) / confine from a closed vocabulary; vigiles GENERATES the PreToolUse hooks block pointing at its OWN gate (vigiles hook-runtime guardCLI, runGuardHook), so the enforcement is user-shell-free. Live gate RUNS over a session ledger (.vigiles/guards.json + guard-ledger.json). WIRED into the CLI but NOT part of the shipped public story — superseded in practice by COMPILED HOOKS (src/core/hook-program.ts, which AUTHORS a correct gate) + the VERIFY feature (guardrail-check.ts). Kept for its ORDER/FLOW/REPLAY handling; GATE is capped by the same #34692 delivery bug.src/core/guards.test.ts— Guard prototype suite (vitest) — pure block/requireBefore/confine decisions + the session-ledger round-trip + the runnable hook-runtime guard gate. EXPERIMENTAL (see src/core/guards.ts)src/core/tool-contract.ts— Tool-contract verification — the cross-referencing engine applied to a subagent's tools: rail. verifyToolContract(tools, dialect) flags never-available (denylist) + unknown (with did-you-mean ≤2) tools; confidentToolIssues is the HIGH-PRECISION subset (never-available + close typo only) that scan/lint act on, so a bare unrecognized plugin/MCP tool is never a false alarm (the TaskCreate/TaskGet lesson). disallowedToolIssues is the DENY-SIDE mirror (the disallowed-tools-contract rule): a disallowedTools: entry that's a close typo of a real tool blocks NOTHING — close-typo only (a never-available tool is harmless to list, a bare unknown is likely a plugin tool). ONE detector reused by compileAgent (strict — every unknown errors), scan, and the subagent-tool-contract lint rule (one-detector-no-drift). Dialect injected (core ⊄ adapter)src/core/tool-contract.test.ts— Tool-contract detector suite (vitest): clean contract, never-available flagged, close typo → did-you-mean, far unknown → no suggestion + suppressed by confidentToolIssues, Tool(restriction) strip, closestTool ≤2 bound; disallowedToolIssues flags only a close typo of a real tool (block-list mirror)src/core/hook-events.ts— Hook-event verification — the cross-referencing engine applied to the EVENT a hook registers under (a typo → the hook never fires). verifyHookEvents(events, dialect) flags unknown events; confidentHookEventIssues keeps only close typos (≤2). NOT a closed set — frameworks extend it (han's TeammateIdle/WorktreeRemove), so audit (scan/lint) flags typos only, never a custom event. Shared by scan + the hook-events lint rule; dialect injectedsrc/core/hook-events.test.ts— Hook-event detector suite (vitest): real events pass, close typo → did-you-mean, framework/custom event (TeammateIdle) suppressed by confidentHookEventIssuessrc/core/mcp-config.ts— MCP-config verification — verifyMcpServers flags a declared MCP server with neither a command (stdio) nor a url (http/sse): it can't start. Pure + FP-safe (an unambiguous requirement, not a catalog); shared by scan + the mcp-config lint rulesrc/core/mcp-config.test.ts— MCP-config detector suite (vitest): stdio (command) + http (url) pass, neither → flagged, non-object entry flagged, empty command string doesn't countsrc/core/agent-plugins.ts— Agent Plugins (agent-plugins.org) 1.0.0 — the VENDOR-NEUTRAL packaging standard, recognized as an ADDITIONAL manifest source, NOT a HarnessAdapter. Why not an adapter: it is a PACKAGING format with no tool catalog / hook events / runtime, so a plugin shipped this way still runs inside CC or Codex — it COMPLEMENTS a harness (a repo commonly carries both manifests; vigiles itself does), and an adapter would need a FAKE HarnessDialect (conformance requires builtinAgentTools.length > 0) plus a detect() that would fight claudeCodeAdapter on our own repo. What it closes: skills already work for free (the standard's skills//SKILL.md is the layout every adapter reads), but the standard puts MCP servers in a ROOT mcp.json that NO harness layout names — so mcp-config / mcp-tool-resolves / mcp-hook-target-resolves silently checked NOTHING on such a plugin. isAgentPluginsManifest detects by$schemaprefix (NOT filename — plugin.json/mcp.json are generic names other tools use); agentPluginsMcpSources(readText) returns [mcp.json] only when the root manifest declares the standard, so a sibling mcp.json is unambiguous even without its own $schema. Pure (injected read, no node:fs) so the browser engine passes a map-backed reader. Consumed by BOTH collectMcpServers implementations (scan.ts fs-backed + scan-files.ts map-backed) — which the same change de-hardcoded from the.mcp.jsonliteral to layout.mcpConfigFile (they violated adapter-aware-lint-rules)src/core/mcp-tool.ts— MCP-tool resolution — the cross-referencing engine applied to an MCP tool reference's SERVER (the MCP half of the tool-reference check; subagent-tool-contract checks the built-in half but passes any mcp__* unchecked). verifyMcpToolServers(tools, declaredServers, dialect) flags an mcp__server__tool whose server isn't in the plugin's declared mcpServers. HIGH-PRECISION via three sweep-grounded guards: (1) GATE on a declared set — no .mcp.json → reaches global/project servers, flag nothing (the ananddtyagi mcp__ide__* shape); (2) ALLOWLIST harness built-ins via dialect.knownMcpServers (Claude Code'side); (3) SKIP the plugin-namespaced mcp__plugin____ form (han's playwright-mcp — ambiguous join, the plugin's own server). mcpToolServer extracts the segment. ONE detector reused by scan + the mcp-tool-resolves lint rule; dialect injectedsrc/core/mcp-tool.test.ts— MCP-tool detector suite (vitest): a declared server resolves, an undeclared one is flagged, GUARD 1 (no declared set → nothing), GUARD 2 (built-inideallowlisted), GUARD 3 (plugin-namespaced form skipped), Tool(restriction) strip, repeated tool de-duped, mcpToolServer extractionsrc/core/description-overlap.ts— Description-overlap — a DETERMINISTIC proxy for a behavioral risk (the showpiece): two model-invocable skills whose descriptions are near-identical can't be told apart by the selector → the wrong one fires (precision collision). findDescriptionOverlaps(surfaces, cutoff) reuses proofs.ts ncd (the findSimilarRules engine) to catch a model-tier-class bug with NO model. Calibrated HIGH-PRECISION: OVERLAP_NCD_CUTOFF=0.2 sits below the sweep's most-similar legitimately-distinct pair (create-issue/create-pr at 0.25; nothing of 4678 real pairs is below it), so only basically-identical text flags. ONE detector reused by scan + the description-overlap lint rule; bridges the deterministic↔behavioral columns, a check no other plugin linter hassrc/core/description-overlap.test.ts— Description-overlap detector suite (vitest): fires on a copy-paste near-dup, stays quiet on a parallel-but-distinct pair (the create-issue/create-pr shape at NCD ~0.25), the calibrated cutoff sits below 0.25, and a single/empty surface yields no pairssrc/core/mcp-hook.ts— MCP-hook target verification — the cross-referencing engine applied to atype: mcp_toolHOOK action (CC hooks support command/http/mcp_tool/prompt/agent; mcp_tool calls a tool on a connected MCP server and REQUIRES server+tool). verifyMcpHookTargets(hooks, declaredServers, dialect) flags (1) an incomplete action missing server/tool (unambiguous, always, like mcp-config) and (2) a server not in the declared set (gated on the plugin shipping mcpServers + built-ins likeideallowlisted, mirroring mcp-tool-resolves). collectHookActions walks the canonical {event:[{matcher,hooks:[action]}]} shape. ONE detector reused by scan + the mcp-hook-target-resolves lint rule; the regex matcher surface (mcp__server__.* naming an undeclared server) is left to a future hook-matcher rulesrc/core/mcp-hook.test.ts— MCP-hook detector suite (vitest): complete action on a declared server passes, a command hook is ignored, missing tool → incomplete, undeclared server flagged (with a declared set), GATE (no declared set → quiet), built-inideallowlisted, non-object/empty hooks → nonesrc/core/effects.ts— Static effect-surface analysis + the RUNTIME purity gate — the deterministic, model-free purity ladder over a declared tools: contract (an agent's side effects ARE its tool calls; the read-only/side-effecting split is a published catalog on dialect.sideEffectingTools). classifyToolEffect(tool, dialect) → read-only|side-effecting|unknown; effectSurface(tools, dialect) → {readOnly, sideEffecting, unknown, purity} where SURFACE purity is pure (∅ effects) / bounded (decidable effects, no Bash/unknown) / unrestricted (Bash, unknown-MCP, or inherits-all — the unbounded cells, conservatively). purityViolations(tools, dialect, level) is the level-aware FLOOR enforced by compile's purity: contract (pureContractViolations = the pure case); bounded now ADMITS Bash (its effect is decidable at the COMMAND level, confined by the runtime gate), barring only MCP/unknown/wildcard; pure still bars Bash entirely. decidePurityGate(declared, tool, command, dialect) is the RUNTIME half — the per-call gate reusing classifyToolEffect + isReadOnlyBash (bash-effects.ts): read-only Bash command allowed (observation), mutating/undecidable Bash denied; Write/Edit allowed under bounded, denied under pure; MCP/unknown denied. SURFACE vs FLOOR is intentional: the static surface still reports any Bash as unrestricted (can't see the command); the runtime gate is what admits+confines it. Reused by scan's effect-surface column + the agent PreToolUse rail (one-detector-no-drift). Dialect injected (core ⊄ adapter).src/core/effects.test.ts— Effect-surface detector suite (vitest): tool classification (read-only/side-effecting/unknown), Tool(restriction) strip, effectSurface bucketing + de-dupe + the three purity rungs (incl. inherits-all → unrestricted), purityViolations per level (bounded ADMITS Write/Edit AND Bash, bars only MCP/unknown/wildcard; pure still bars Bash; unrestricted never violates; pure == pureContractViolations) + decidePurityGate (the runtime gate: read-only Bash allowed, mutating Bash denied incl. Bash(restriction) forms, Write/Edit bounded-only, MCP/unknown denied, unrestricted allows all)src/core/bash-effects.ts— Deterministic Bash-effect classifier (no LLM) — the no-model answer to 'is this Bash command read-only or side-effecting?' (Claude Code uses an LLM permission classifier; this is the deterministic alternative). classifyBashCommand(cmd) → read-only|side-effecting|undecidable + isReadOnlyBash(cmd). Sound by construction: a real shell AST (mvdan-sh) + a 41-head read-only catalog + flag-sensitive guards (git subcommand allowlist, find -delete/-exec, sed -i, sort -o, tee, awk) + 'output redirection ⇒ write'; FAIL-CLOSED on the undecidable residue (eval, $VAR/$(…) head, sh -c, xargs, pipe-to-shell, process substitution, background) and unknown heads. NEVER returns read-only unless every leaf is provably read-only. Full decidability is impossible (Rice's theorem) — this is the high-precision decidable subset; the sandbox stays the runtime backstop. NOW WIRED into the runtime purity gate: isReadOnlyBash is the refinement inside decidePurityGate (core/effects.ts), reached via the Claude Code agent PreToolUse rail (agent-runtime.ts) — so a bounded agent'sgit statusis allowed andgit pushdenied at the live call (its real home: the hook sees the actual command, where effectSurface/scan only see a Bash(git:*) pattern).src/core/bash-effects.test.ts— Bash-classifier suite (vitest, 95 tests): read-only corpus (cat/ls/grep/git status/pipes), side-effecting (rm/git push/redirects/find -delete/sed -i/tee), undecidable (eval/$CMD/sh -c/xargs/pipe-to-shell) + the load-bearing zero-false-read-only SOUNDNESS fixture over 43 dangerous/undecidable commands asserting isReadOnlyBash === false for eachsrc/core/linters.ts— Cross-referencing engine (ESLint, Stylelint, Ruff, Clippy, Pylint, RuboCop, Cedar, detekt, ktlint, Checkstyle, golangci-lint)src/core/dialect.ts— HarnessDialect — the format/dialect PORT (hexagonal format axis): the harness-specific vocabulary the compiler needs (built-in subagent tool catalog, never-available tools, side-effecting tools via sideEffectingTools — the effect-surface/purity basis, optional/additive — MCP tool shape, built-in MCP servers via knownMcpServers — the mcp-tool-resolves allowlist, optional/additive, e.g. Claude Code'side— hook events, instruction targets, plugin-root token), behind one interface instead of literals hard-coded in compile.ts. core/dialect.ts defines ONLY the interface — the concrete dialects live in the adapters (claudeCodeDialect in src/adapters/claude-code/dialect.ts), symmetric with the other four ports; the compiler takes one by injection (compileAgent requires options.dialect, no core default). A second harness adds a sibling HarnessDialect in its adapter and injects it.src/core/dialect.test.ts— Dialect-port test suite (vitest): the Claude Code dialect has the expected shape, compileAgent verifies the tool contract against the injected CC dialect (built-in ok, typo → did-you-mean), and an INJECTED alt dialect swaps the catalog (the Codex-prep seam — a tool valid under the alt dialect is flagged under CC)src/adapters/claude-code/dialect.ts— The Claude Code adapter's HarnessDialect surface: DEFINES claudeCodeDialect (the concrete dialect lives in the adapter, symmetric with claudeCodeLayout/Runtime/HookProtocol/ModelMock; core holds only the HarnessDialect interface). The composition root (CLI/conformance) injects it into the compiler via compileAgent(spec, { dialect }); a Codex adapter defines its own. Also exports the as-const tool tuples (claudeCodeBuiltinAgentTools/SideEffectingTools — the dialect object references them, one source of truth) + the derived literal-union types (ClaudeCode{Builtin,SideEffecting,ReadOnly,Bounded}Tool) that the typed-purity vocabulary keys onsrc/adapters/claude-code/typed-spec.ts— Typed Claude Code authoring surface (exported on vigiles/claude-code): the CC-bound agent/skill that pin the generic ToolVocabulary to ClaudeCodeToolVocabulary (readOnly/bounded unions derived from claudeCodeDialect's as-const catalogs), so purity:'pure'+'Bash' is a tsc ERROR at edit time. The bare core agent()/experimental_skill() (vigiles/spec) stay open (any tools) for backwards compat — typed purity is a select-by-import addition, never a replacement for the runtime decidePurityGate backstop (which owns the command-level bounded-Bash decision the type can't see). Type-tested in test/types/purity.tssrc/core/layout.ts— PluginLayout — the plugin/repo LAYOUT port (filesystem half of the format axis): where a harness's instruction file / skills / agents / commands / hooks / settings live on disk + the plugin-root token + the settingsFormat (json|toml), behind one interface so loadPlugin reads them from a descriptor instead of hard-coding Claude Code's .claude-plugin//.claude/ JSON conventions. A Codex adapter supplies its own PluginLayout (TOML config.toml [hooks]) and reuses the same loadersrc/adapters/claude-code/layout.ts— claudeCodeLayout — the PluginLayout port's Claude Code reference impl (.claude-plugin/plugin.json, .claude/settings.json, skills/agents/commands surfaces, ${CLAUDE_PLUGIN_ROOT}); loadPlugin defaults to itsrc/adapters/claude-code/layout.test.ts— Layout-port test suite (vitest): claudeCodeLayout is the loadPlugin default; an alternate Codex-shaped PluginLayout (AGENTS.md, prompts/ surface, .codex/ settings, ${CODEX_PLUGIN_ROOT}) loads through the SAME loadPlugin — and the default CC layout sees none of it (the swap seam a second harness plugs into)src/core/runtime.ts— HarnessRuntime — the runtime/transport PORT (transport axis): the facts the test tiers need to drive a harness (the agent binary to spawn + the env a no-key mock is reached through: base-URL var, API-key var, dummy key) + wireMock(baseUrl) → { args, env } (how to point the spawned binary at the mock — env for CC, -c flags for Codex) + versionKey(raw) → the behaviorally-significant--versiontoken the cache/lock key on, PER-HARNESS (CCmajor.minor~quarterly, Codex''since its minor is patch-cadence ~weekly — the reduction lives on the port, NOT a universal major.minor rule), behind one interface instead ofclaude/ANTHROPIC_*literals in harness-test/eval/sandbox. A Codex adapter supplies its own HarnessRuntimesrc/core/harness-driver.ts— HarnessTestDriver — the layer-2 deterministic-runner PORT: how runHarnessTest builds a harness's argv (buildArgs), starts its scripted wire-format mock (startMock → HarnessMockHandle), parses its stdout into the common trace (parseRun), and detects its binary (available). Carried on the HarnessAdapter bundle (harnessTestDriver) so the runner dispatches per-harness without cross-importing a sibling adapter; claudeCodeDriver + codexDriver implement itsrc/adapters/claude-code/runtime.ts— claudeCodeRuntime — the HarnessRuntime port's Claude Code impl (spawnclaude, reach the mock via ANTHROPIC_BASE_URL + dummy ANTHROPIC_API_KEY; versionKey → major.minor) + mockModelEnv, the pure env-builder the runners use (the testable seam of the otherwise v8-ignored real-subprocess path); harness-test, eval and sandbox read the binary + env from heresrc/adapters/claude-code/runtime.test.ts— Runtime-port test suite (vitest): claudeCodeRuntime values, mockModelEnv layers mock URL + dummy key over the base env, and an alternate runtime maps the URL onto its own env var (OPENAI_BASE_URL) — the Codex transport seamsrc/core/hook-protocol.ts— HookProtocol — the hook-wire PORT (transport axis): how a harness signals a hook block/deny (block exit code + deny decision values + event env vars) PLUS injectableEvents — the events that honor additionalContext injection (so 'can this harness deliver an inject/nudge hook?' is a TESTED contract, not prose: conformance fails a shellHooks adapter that declares an empty list, closing the gap that let Codex inject sit unverified). Thin by design — CC and Codex hooks are near-identical at the wire level (the finding); decideHook reads the block code + deny values from it. claudeCodeHookProtocol is the implsrc/adapters/claude-code/hook-protocol.ts— claudeCodeHookProtocol — the HookProtocol impl: blocks via exit 2 or permissionDecision:deny / decision:block; injectableEvents = [SessionStart, UserPromptSubmit, PostToolUse]src/core/model-mock.ts— ModelMock — the model-mock PORT (transport axis): the mock model's wire format (anthropic-messages vs openai-responses) + the turn-consuming endpoint + optional count-tokens endpoint. startMock reads the endpoints from it; the SSE renderer stays per-harness. claudeCodeModelMock is the implsrc/adapters/claude-code/model-mock.ts— claudeCodeModelMock — the ModelMock impl: anthropic-messages SSE at /v1/messages (+ count_tokens). A Codex codexModelMock sets openai-responses + /v1/responsessrc/core/adapter.ts— HarnessAdapter — the bundle that makes a harness a single addable unit: groups the ports (dialect/layout always; runtime/hookProtocol/modelMock OPTIONAL, present iff the matching capability is declared) + a detect(root) predicate + an AdapterCapabilities descriptor {referenceVerification (always true), harnessTesting (mockable → layer 2), shellHooks (shell-process hooks → the runHook tier)} that makes the capability matrix executable — a closed harness is reference-verification-only, a code-module-hook harness (OpenCode) has no shellHooks, and the conformance kit relaxes port requirements accordingly instead of forcing a fake transport. Also carries harnessTestDriver (the layer-2 runner seam, present iff harnessTesting) so runHarnessTest dispatches per-harness off the bundle. Adding a harness = writing one object; the library stays import-named, the bundle is what the CLI auto-detects and the conformance kit checks. See docs/authoring-an-adapter.md and the capability matrix in docs/harnesses.mdsrc/adapters/claude-code/adapter.ts— claudeCodeAdapter — the Claude Code HarnessAdapter: the five CC ports bundled + a detect that recognizes a .claude-plugin/ manifest, .claude/settings.json, or CLAUDE.md. The reference adapter a second harness mirrorssrc/adapters/claude-code/adapter.test.ts— Adapter-bundle test suite (vitest): claudeCodeAdapter bundles all five ports, passes the conformance kit, the kit catches a broken adapter, detect recognizes a CLAUDE.md / .claude-plugin repo (and not an empty dir), detectAdapter falls back to Claude Code, getAdapter looks up by namesrc/adapters/codex/adapter.ts— codexAdapter — the OpenAI Codex HarnessAdapter (the five Codex ports in src/adapters/codex/{dialect,layout,runtime,hook-protocol,model-mock}.ts). SHIPPED: registered in ADAPTERS (the CLI auto-detects a .codex/config.toml or AGENTS.md repo) and exported asvigiles/codex. Layer 2 (harness testing/evals) is full and proven against the real codex binary; layer 1 (compile) is format-correct for the surfaces that map — AGENTS.md (plain markdown) + minimal SKILL.md via dialect.skillFrontmatter, CC output byte-identical. the loader's manifest/MCP read is format-aware (detects Codex's TOML [mcp_servers]) and the generic loadPlugin lives at the composition root (no cross-adapter import). Subagents are a deliberate non-goal (a Codex subagent is an [agents] TOML concurrency table, not a tool-contract file — model mismatch), the one remaining difference; otherwise no functional gap.src/adapters/codex/codex.test.ts— Codex adapter validation (vitest): codexAdapter passes assertAdapterConformance + assertAdapterLoadsHooks (TOML config.toml [hooks] round-trip), the compiler verifies a subagent contract under codexDialect (shell ok, Read flagged), loadPlugin reads a real Codex-shaped plugin via codexLayout (AGENTS.md + skills + ${PLUGIN_ROOT} TOML hooks), and it asserts codex IS registered in the public registry (shipped) — proves the format+layout axes generalize with zero core changessrc/adapters/codex/mock-model.ts— Codex transport, BUILT + PROVEN against the realcodexbinary (closes the deferred ModelMock-renderer gap): renderResponsesSSE emits the OpenAI Responses 9-event SSE sequence (response.created → … → response.completed) captured from live codex traffic; parseResponsesRequest extracts the last user input_text + model + tool names (malformed-JSON tolerant); startCodexMock is the in-process http server serving rendered SSE on POST /v1/responses. The wireMock recipe lives in runtime.ts as codexMockArgs/codexMockEnv (the keyless-c model_provider=mockflags — corrects the earlier OPENAI_BASE_URL guess).src/adapters/codex/mock-model.test.ts— Codex mock test suite (vitest): pure unit tests for renderResponsesSSE (event order + completed-carries-text) and parseResponsesRequest (prompt/model/tool extraction + malformed-JSON tolerance) + the in-process server round-trip; PLUS a gated integration test that drives REALcodex exec(async spawn — the in-process mock shares the event loop, so a sync spawn deadlocks) against startCodexMock over the keyless recipe and asserts the turn completes (stdout has the scripted reply, the mock recorded the prompt). Runs when codex is on PATH (installed in CI), skips otherwisesrc/adapters/codex/driver.ts— codexDriver — the Codex HarnessTestDriver: buildArgs (codex exec flags + the keyless -c model_provider mock flags via runtime.wireMock + prompt last), startMock (startCodexMock — the Responses SSE server), parseRun (trimmed stdout → output; tool/hook trace deliberately minimal, not parsed from codex JSONL), available (codex on PATH). The seam runHarnessTest({ adapter: codexAdapter }) dispatches through — thin by design vs the CC harness-test.ts machinerysrc/adapters/codex/harness-test.test.ts— Codex harness-test suite (vitest): pure unit tests for buildCodexArgs (exec flags, mock flags after exec, prompt last) and parseCodexRun (trimmed stdout, empty tools/hooks) + a GATED test that drives REAL codex through the PUBLIC runHarnessTest(spec, { adapter: codexAdapter }) and asserts the scripted turn completes — proves layer 2 is usable for Codex via the same entry as Claude Code. Runs when codex is on PATH, skips otherwisesrc/adapters/codex/eval.ts— Codex EVAL-tier transport (exported on vigiles/codex), the seam measureTriggerRate/runEval dispatch to via the ModelOutputParser. SCHEMA CONFIRMED against real codex exec --json (codex-cli 0.139.0; pin it — 0.141 regressed the keyless mock): parseCodexEvalRun reads the thread/item JSONL → common Trace (agent_message→item.text; command_execution→item.command + aggregated_output/exit_code; usage on turn.completed incl. cached_input_tokens→cacheReadTokens; counts item.completed ONLY since item.started repeats the id mid-flight). codexEvalRunner spawns codex exec --json (-C cwd, stdin=/dev/null or it blocks; needs auth+egress). KEY FINDING: Codex has NO discrete skill-selection event (no Skill tool) — a trigger surfaces as the model READING skills//SKILL.md via a command_execution, so codexSkillFired(run,name) detects that read (best-effort; pair with judged). codexRunError flags an errored/rate-limited turn so it's excluded from the trigger denominator (not counted a clean miss). Live-validated end-to-end; the {evalDriver} dispatch (codexEvalDriver = runner+parse+runError) is now wired into measureTriggerRate + audit --harness=codex, fake-tested with an injected driver — only a live native eval run remains (gated on Codex quota)src/adapters/codex/eval.test.ts— Codex eval-parser suite (vitest): fixtures are REAL codex exec --json captures (plain / tool-calling / skill-activating turns) — parseCodexEvalRun reads agent_message + usage, dedups item.started/completed for one command_execution, codexSkillFired detects the skill via its SKILL.md read, codexRunError flags a usage-limit/errored turn (so it isn't read as a clean miss), installCodexSkills materializes a plugin's skills into /.codex/skills, and codexEvalDriver wires runner+parse+runError; + tolerance/contract casesrc/adapters/opencode/adapter.ts— opencodeAdapter — an EXPERIMENTAL, internal-only prototype HarnessAdapter for OpenCode (sst): dialect/layout/runtime/model-mock in src/adapters/opencode/, but NO hook-protocol — it declares capabilities {referenceVerification, harnessTesting: true, shellHooks: FALSE} because OpenCode hooks are in-process TS plugin modules, not shell processes. Built to VALIDATE the capability tier (a mockable-but-no-shell-hooks harness is a first-class adapter, no fake hookProtocol). NOT registered, NOT exported.src/adapters/opencode/opencode.test.ts— OpenCode prototype validation (vitest): opencodeAdapter passes assertAdapterConformance WITHOUT a hookProtocol (shellHooks:false relaxes it), assertHarnessTestable returns its runtime+modelMock (it IS mockable), hookProtocol === undefined (blocked port made concrete), the compiler verifies a subagent contract under opencodeDialect (bash ok, NotebookEdit flagged), loadPlugin reads a real OpenCode-shaped plugin via opencodeLayout (AGENTS.md + .opencode/agent surface), and it's NOT in the public registry — proves the AdapterCapabilities tier does its jobsrc/adapter.ts—vigiles/adapter— the harness-adapter authoring kit (the documented small lib third parties use to build their own adapter): re-exports the five port interfaces + HarnessAdapter + the conformance kit + the registry. See docs/authoring-an-adapter.mdsrc/codex.ts—vigiles/codex— the OpenAI Codex adapter surface (sibling of vigiles/claude-code): re-exports the Codex ports + codexAdapter + codexDriver + the proven Responses mock (startCodexMock/renderResponsesSSE + codexMockArgs/codexMockEnv). Layer 2 is full and usable via runHarnessTest({ adapter: codexAdapter }) against the real codex binary; layer 1 compile is format-correct for instructions (AGENTS.md) + skills (minimal SKILL.md), subagents excluded by design (model mismatch)src/adapter-registry.ts— Adapter registry (composition root): ADAPTERS = [claudeCodeAdapter, codexAdapter] + detectAdapterResult/detectAdapter (highest detect() specificity wins, reports ambiguousWith for a repo that matches several, else Claude Code — backwards-compatible) + resolveAdapter(root, harness?) honouring a --harness override + getAdapter(name) (alias-aware: claude → claude-code) + resolveHarnessSelection({root,flag,configHarness}) — the pure compile/lint picker with explicit precedence (flag → single config harness → first-of-many with a loud notice → auto-detect + ambiguity warning), the deterministic replacement for cwd-sniffing. The CLI auto-detects through this (scan prints the harness + an ambiguity warning); the library selects by importsrc/adapter-conformance.ts— Adapter conformance kit: checkAdapterConformance/assertAdapterConformance (each port populated + cross-port invariants — port names agree, instructionFile is a declared target, plugin-root tokens match — + the dialect accepts its own built-in tool through compileAgent) and assertAdapterLoadsHooks (the behavioural settings round-trip that catches the JSON-vs-TOML layout trap). Third-party adapter authors drop both in their testssrc/adapter-contract.test.ts— Adapter CONTRACT suite — the STRUCTURAL enforcement of test-both-harnesses: runs the conformance kit (src/adapter-conformance.ts) over the WHOLE registry in a loop (for adapter of ADAPTERS) instead of ad-hoc per-adapter, so registering a harness auto-subjects it to every contract (can't forget a new one), a capability an adapter lacks is a VISIBLE it.skip(n/a) not a silent pass (capability-gated on shellHooks/harnessTesting), and a meta-test fails the build when a src/adapters/ exists but isn't registered (or a declared prototype like opencode). Catches ADAPTER-level gaps structurally; the judgment 'is this assertion harness-facing → put it in the contract' stays the test-both-harnesses rulesrc/core/cedar.test.ts— Cedar policy resolution tests — filesystem-based @id() lookup with filename fallbacksrc/core/generate-types.ts— Type generator: scans linters/package.json/filesystem → emits .d.tssrc/core/generate-schema.ts— JSON Schema generator: emits .vigiles/schema.json from real linter config so YAML LSP autocompletes frontmatter rule namessrc/core/generate-harness.ts— Whole-harness codegen (harness-agnostic core, the THIRD generated artifact beside generate-types/generate-schema):vigiles generate harness [dir] [out]emits ONE harness.gen.ts registry over every *.spec.ts so a singletsc --noEmitcross-checks the WHOLE harness as one program (the repo-scale form of the per-file pipe/Supplies typed composition; think TanStack's routeTree.gen.ts). First increment: (1) DANGLING delegate → a tsc error at edit time — each railway() delegate target (steps/recover/onError) is asserted against the literal AgentName union via a SHALLOW per-edge KnownAgentName<target, AgentName, from> (O(N), no recursion — the helper in spec.ts), so delegate('ghost') is a tsc error naming the missing target + its railway (); (2) DUPLICATE agent names → a generator non-zero exit (findDuplicateName, O(N) JS) — NEVER a set-uniqueness MAPPED TYPE (the measured TS2589 wall ≈ N=1000, per the per-edge-type / cardinality-in-JS encoding rule); (3) the whole-harness CAPABILITY LATTICE (computeHarnessCapabilities) — the O(N) union of every agent's effectSurface(tools, dialect) + the loosest purity, the substrate the future repo-scale capability-diff reads. Pure core (generateHarness — string in/out, fully testable) + an fs/scan wrapper (loadHarnessModel, INJECTABLE load). Dialect INJECTED by the CLI (core ⊄ adapter; no CC literal). tsconfig consumer needs allowImportingTsExtensions. Cross-file typed composition is now SHIPPED (the #1 follow-up): loadHarnessModel builds a handoff per consecutive success-track step pair whose consumer declares needs (delegate's 3rd arg), and the gen file emits one shallow Handoff<OkOf<typeof registry[producer]>, needs> per pair (O(N), naming the field on mismatch) — the repo-scale generalization of per-file pipe/Supplies; scoped to the linear success track (recover/onError err-edges are the noted follow-up). See docs/cli.mdsrc/cli.ts— CLI: init, compile, lint, test, eval, scan (primary commands + generate-types plumbing) +--version(prints the version, not the help banner). The two layers are named Lint (verify references —vigiles lint) and Test (vigiles test/eval).initis the onboarding wizard — interactive at a TTY, non-interactive for agents/CI (or --yes); sets up BOTH layers by default (spec+types, a vigiles.harness.mjs starter, a zernie/vigiles@v1 workflow), scoped via --lint/--test (auto-detects the harness; --harness= overrides); adds vigiles to devDependencies (moving a staledependenciespin); installs skills/hooks per-harness via the pure planPluginInstall decision — Claude through the plugin MARKETPLACE (claude plugin install vigiles@vigiles → ~/.claude/plugins/), Codex through the cross-agent skills CLI with -g -y (npx skills add zernie/vigiles -a codex -g -y → the global store ~/.agents/skills/, verified against the real CLI + a gated e2e test) — skills install GLOBAL; Codex's proactive NUDGE hooks (eval-lock + refs) ARE wired into the repo's .codex/config.toml via codexPluginHooks/applyCodexPluginHooks+wireCodexHooks (Codex has no global plugin store, so config.toml is the idiomatic repo-committed place; directnpx vigiles hook-runtimecommands emit the additionalContext shape Codex honors on PostToolUse — only an intentional exit 2 blocks; idempotent merge preserves the user's own Codex hooks; SessionStart-summary + compile-on-edit/pre-edit guards stay manual on Codex — no harness-neutral entrypoint yet). An existing instruction file gets a scaffolded spec but is never compiled over (and the spec target is harness-native: CLAUDE.md for Claude, AGENTS.md for Codex); bareinitALSO sweeps every existing skill (SKILL.md→experimental_skill()) and subagent (agents/x.md→agent()) into a spec via discoverAdoptableSurfaces + adoptSkill/adoptAgent (surfaceKind routes the scaffolder by path), andinit --target=skills/x/SKILL.mdadopts ONE surface — the per-surface command the audit report points at — soinitcreates all the specs it can, all non-destructive; a CLAUDE.md⇄AGENTS.md MIRROR (symlink or byte-identical via rulesync/Ruler, detected by detectInstructionMirror) collapses to ONE canonical spec, and a detected sync tool REDIRECTS the spec into its source slot (.ruler/AGENTS.md) via composeCollisions so two specs never collide on the integrity hash (findSpecs uses dot:true so the .ruler spec is discovered); only files actually written are listed in the commit hint; a stale old-API CI workflow is flagged loudly, not silently skippedsrc/setup-plan.ts— Purevigiles initdecision logic (parseSetupArgs/shouldPrompt/resolvePlan/planPluginInstall): turns CLI flags + whether a human's at a TTY into a SetupPlan {lint,test,gha,plugin,strict}. The two layers are selected with --lint/--test (a positive flag selects only it; --no-* drops one from the default-both). Owns the rule-GROUP taxonomy as named constants — STRUCTURAL_RULES (the 9 FP-safe correctness rules, the default gate at error), WORKFLOW_RULES (require-instructions-spec + untested-*, the --strict opt-in), NUDGE_RULES (frontmatter-valid/skill-frontmatter/unmarked-refs/prefer-compiled-hooks, never gated) — written by mergeProjectConfig, which takes reportOnly to write the whole gate at warn (the --report-only dial). planPluginInstall is the per-harness install DECISION (Claude marketplace vs Codexskills -g, both global/non-vendoring) — pure so a CI test asserts WHICH commands run with no network or real binary. Interactive when a human runs it, non-interactive (defaults: both layers) for agents/CI/piped — so 'set up vigiles' from a Claude Code/Codex prompt never hangs. Unit-tested in src/setup-plan.test.ts; the IO (prompts, scaffolds, workflow, running the install plan) stays in cli.tssrc/core/adopt.ts— Faithful markdown → typed-spec ADOPTION — the deterministic half ofinitauto-adopt. adoptMarkdown(md, target) converts an existing CLAUDE.md/AGENTS.md into aclaude()spec SOURCE that compiles back to ~the same file (byte-identical below the integrity header for a clean##-headed file): every heading becomes a verbatim prose section, NO rule is inferred (guidance-only; cross-referencing is strengthen's later job), nothing dropped. Always compiles because the compiler only rejects#/##INSIDE a section body, so we split on every top-level heading (fence-aware) and###+ ride along; reserved lowercase keys are capitalized (safeKey); the title h1 is consumed (the compiler re-renders it from the filename); a heading-less/intro-bearing file falls back to a synthesized Overview section (tier 'raw'). adoptToSpec is the shared parse the source-renderer + the round-trip tests reuse (compile the spec object directly, no module eval). ALSO adopts SKILLS + SUBAGENTS (the closed harness-parity gap): adoptSkill(md, dir) → aexperimental_skill()spec (verbatim body, since SKILL.md is freeform) and adoptAgent(md, fileBase) → anagent()spec (lead prose → body, each##heading → a named section, since agent sections reject##in the body) — best-effort + non-destructive: standard frontmatter + body round-trip, an unmappable key (a customlevel:/skills:) is preserved in a// NOTEcomment never silently dropped, a never-available tool in the source surfaces on compile (not hidden); both return the parsed spec object so adopt-surface.test.ts round-trips via the real compileSkill/compileAgent + dogfoods the vendored plugins. Wired into init() (adopt-or-scaffold) + setupPillar1 (adopted targets are compiled). The agentic path (the adopt-spec skill) is the model-driven siblingsrc/core/adopt.test.ts— Adoption suite (vitest): structural unit checks (1:1 heading→section mapping, title-h1 consumed, ### kept inside a body, reserved-key capitalized, fenced ## not split, duplicate headings deduped, heading-less/intro → raw Overview, long-section maxSectionLines lift) + generated-source checks (target line, empty rules, backtick/${} escaping) + the load-bearing ROUND-TRIP block: adopt a file, compile the spec via the REAL compileClaude, assert the original content is reproduced with zero compile errors (incl. backtick-heavy + raw-tier files)src/scan.ts—vigiles audit <dir>— deterministic, no-model report of what a plugin/repo ships and what's broken: the top-level instruction file (CLAUDE.md/AGENTS.md) presence + spec-managed-vs-hand-written (so a plain instruction-only cc/codex repo isn't reported empty — informational, NOT the require-instructions-spec gate), per-skill description + user-invoked, per-agent tool contract (incl. the no-tools-line inherits-all footgun + tool-contract VALIDATION via verifyToolContract/confidentToolIssues — a never-available or typo'd tool is flagged ✗, the cross-referencing engine applied to scan), hook scripts resolved across braced/unbraced ${CLAUDE_PLUGIN_ROOT} (ok/missing/unresolved) + hook-EVENT validation (verifyHookEvents — a typo'd event name that never fires, close-typo only), command + MCP detection + subagent-frontmatter validation (frontmatterIssuesFor — a SUBAGENT missing name/description won't register; skills are NOT required to have frontmatter per CC docs — name←dir, description←first body paragraph; caught ananddtyagi's prose-only agents — PLUS frontmatterValueIssuesFor: a model:/color: that's a close typo of a real alias/color silently falls back, high-precision close-typo-only, a full dated model id left alone; both folded into the subagent-frontmatter rule) + skill-frontmatter RECOMMENDATION (skillMetaIssuesFor — a skill SHOULD declare explicit name+description for a reliable trigger surface, soft ℹ note not a defect; the skill-frontmatter lint rule) + MCP-config validation (collectMcpServers/verifyMcpServers — a declared server with no command/url can't start) + MCP-hook targets (verifyMcpHookTargets — a type:mcp_tool hook action incomplete or targeting an undeclared server, the cross-referencing engine on the hook surface) + MCP-tool resolution (verifyMcpToolServers — the MCP half of the tool-reference check: a per-agent mcp__server__tool whose server isn't in the plugin's declared set ✗ can't resolve; high-precision — gated on a declared set, built-ins allowlisted, plugin-namespaced form skipped) + per-agent disallowedTools typo check (disallowedToolIssues) + agent model/color value validity + description-overlap (descriptionOverlapsFor — the deterministic NCD precision proxy over model-invocable skill descriptions, a reported risk not a structural defect) + malformed-frontmatter note (malformedFrontmatterFor — a --- block that isn't valid YAML, INFORMATIONAL only since js-yaml is stricter than some loaders; the frontmatter-valid warn rule), untested-surface count, loader warnings. Hardened against false positives found by sweeping real marketplaces: a multi-line QUOTED description (value on the next indented line, trailofbits/react-pdf) is parsed not mislabeled no-desc; a relative hook path (./hooks/x.sh, ananddtyagi) resolves against the PLUGIN ROOT not cwd; an existence-guarded hook ([ ! -f x ] || x, gmickel/flow-next) is an optional one-liner, not MISSING. inspectMarketplace classifies a marketplace.json's members on-disk-vs-external + DEDUPES name-aliased dirs (TheBushidoCollective/han aliases 338 names onto 159 dirs) so the leaderboard doesn't double-count; a CURATED marketplace whose members are all external git/url plugins (obra/superpowers-marketplace, anthropics/claude-plugins-community) is reported honestly, not as an empty machine. Re-aims loadPlugin + parseAgentTools + findUntestedSurfaces; the deterministic substrate under the leaderboard + harness-aware scansrc/scan.test.ts— Scan test suite (vitest): instruction-file reporting (spec-managed vs hand-written vs none), skill description/user-invoked flags, agent tool-contract incl. inherits-all, hook resolution ok/missing/unresolved across $CLAUDE_PLUGIN_ROOT forms, command + MCP detection, report formatting, inspectMarketplace (on-disk vs external classification + dedup of name-aliased dirs + curated-all-external), and the false-positive-hardening regressions (multi-line quoted description, relative ./hooks resolution, existence-guarded optional hook)src/scan-cli.test.ts— Scan CLI e2e suite (vitest, free unit tier): drives the REAL builtnode dist/cli.js auditover the repo-shape matrix — DOGFOOD on pinned OSS plugins (test/dogfood/*) for the cc surface + ARTIFICIAL tmp fixtures for Codex (AGENTS.md + TOML [mcp_servers] + skill), mixed (CLAUDE.md+AGENTS.md → detection ambiguity warning + --harness override), CC instruction-only (the instruction-file line), a marketplace (leaderboard), and a curated-all-external marketplace (honest 'all external' report, not 'empty'). Covers the CLI WIRING (auto-detect, override, leaderboard branch, --json, exit 0) the library-level scan.test.ts can't reachsrc/scan-vendor.test.ts— Golden scan-VERDICT conformance over REAL SHA-pinned vendored plugins (test/dogfood/*) — the rules complement to vendor.test.ts (loader invariants), offline + model-free. Two directions: FP-GUARD (a well-formed plugin — oh-my-claudecode, wshobson-accessibility, superpowers — must stay CLEAN, every high-precision rule field zero; the regression that catches a rule going noisy, the don't-cry-wolf bet) and TRUE-POSITIVE (a slice vendored BECAUSE it reproduces a real bug — madappgang-frontend@6097ad4's tester.md, MIT — must keep firing subagent-tool-contract's AskUserQuestion-never-available + frontmatter-valid's malformed-YAML). Grounds the rules in the wild, not just synthetic tmp fixtures. See test/dogfood/README.md for provenance/licensing (only MIT upstreams vendored; ananddtyagi has no license so its files aren't committed)src/scan-behavioral.ts—vigiles auditmodel trigger tier — the BEHAVIORAL column (the model-gated half of audit, the deliberate exception to one-detector-no-drift): probePluginTriggers measures whether a plugin's model-invocable described skills actually FIRE (recall) and stay quiet on unrelated prompts (precision via irrelevant), reusing measureTriggerRate. probePluginTriggersWith is the injectable-driver core (no binary). A HarnessProbe (buildProbe(dir, harness)) selects the EvalDriver + the per-harness 'fired' predicate + stub/available — Claude (skillResolved over a namespaced skill, stubbed bodies) or Codex (codexSkillFired, no stub) — so audit --harness=codex routes through the Codex driver. Probe prompts are auto-generated from skill descriptions (src/audit-prompts.ts) when no --prompts file is supplied. A too-thin/undiverse prompt set is caught per-skill (the diversity gate throw → an unmeasured note, not a crash). formatBehavioralReport renders measured/unmeasured/unavailable. ALSO the SELECTION-COLLISION matrix (the behavioral confirmation of description-overlap): buildSelectionReport folds per-run fired-skill sets into an N×N matrix (diagonal=recall, off-diagonal=collision — skill j hijacking skill i's prompt); measurePluginSelectionWith/measurePluginSelection drive it (CC-only — Codex has no skill-selection event). measureSelectionMatrix is the PROMOTED first-class assertable primitive (zero-setup — auto-derives prompts from descriptions via autoTriggerPrompts, or takes an explicit set; measureSelectionMatrixWith = injectable core) + assertNoCollision({maxOffDiagonal, maxPluginCollision}) the throwing gate (defaults to ZERO collision; throws on a green-that-tested-nothing) — BOTH exported on vigiles/claude-code (not the agnostic the agnostic surface, since selection-collision is CC-only).src/scan-behavioral.test.ts— Behavioral-column test suite (vitest): builds a tiny real plugin dir and drives probePluginTriggersWith with an injected fakeProbe (HarnessProbe, no model) — only model-invocable+described skills are probed (user-invoked excluded), recall/precision aggregate, a candidate with no prompts → unmeasured, a thin prompt set is surfaced as an unmeasured note (not a crash), and the formatter renders measured/unmeasured/unavailablesrc/leaderboard.ts— Plugin health leaderboard: scoreReport turns a ScanReport into a 0–100 structural-health score + A–F grade from concrete facts (missing hook -15, no-description skill -10, agent-without-tool-contract -5, untested surface -3); deliberately ignores the loader's free-text warnings (doc-mention false positives) so the ranking is defensible. A command-only (commands/*.md) or MCP-only (.mcp.json) plugin is a real surface and scores on its own health — only a dir with NO surface at all scores 0. rankPlugins scans+scores+sorts a set;vigiles audit <dir...>(≥2 dirs) renders it. Behavioural columns (trigger-rate/egress/safety) need a model and stack on topsrc/leaderboard.test.ts— Leaderboard test suite (vitest): pure scoreReport penalty weights + clamp + empty-machine=0 + command-only/MCP-only-is-a-real-surface, rankPlugins ordering (healthy above broken) over tmp fixtures, formatLeaderboard renderingsrc/score-explainer.ts— Score-explainer (C4 of the measurement-authority pivot, the strongest pairing): the deterministic WHY behind a low measured score. Measurement finds the behavioral SYMPTOM (a skill underperforms); the cross-ref engine already detects the deterministic CAUSE. explainScore(report) bridges them — pure over the ScanReport the linter already computes (one-detector-no-drift, free, model-less) it maps each finding to the symptom a benchmark observes (wrong-skill-fires from description-overlap, skill-never-fires from a no-description skill, agent-underperforms from a never-available/typo'd tool or unresolved MCP server, hook-never-runs from a typo'd event or missing script, subagent-never-dispatches from missing frontmatter) + a one-line actionable fix, with likely (hard dead-end) sorted before possible (high-precision proxy). explainSurface filters to one underperforming surface (incl. the a↔b overlap pairs); formatExplanations renders cause+detector+fix. The diagnostic vigiles optimize (A2) prints beside each drop/swap recommendation — 'underperforms BECAUSE its description overlaps X', not just 'drop it'.src/score-explainer.test.ts— Score-explainer test suite (vitest): pure over a hand-built ScanReport (no fs/model) — each cross-ref finding maps to the right symptom + an ACTIONABLE fix (the fix is the product, tested as much as the verdict): overlap→wrong-skill-fires (differentiate), no-description→skill-never-fires, tool issue with/without a suggestion→swap vs remove fix, undeclared MCP server, hook event typo with/without suggestion, missing hook script, subagent (not skill) frontmatter→subagent-never-dispatches, likely-before-possible ordering, explainSurface name filter incl. overlap pairs, formatExplanations rendering + the no-cause behavioral fallthroughsrc/optimize.ts— The per-repo harness optimizer's DETERMINISTIC spine, folded INLINE into the defaultvigiles auditreport (each finding carries its fix via formatRecommendations — NOT its ownoptimizeverb, and no longer a--fix-plan/--explainflag: until the measured A/B half exists, an optimizer that only re-prints audit's findings doesn't earn a separate surface; see roadmap §P2 'reconsider an optimize verb'). optimize(report) reuses scoreReport (structural-health score 0–100 + grade, via the exported gradeFor) + explainScore (one-detector-no-drift, never re-detects) to emit a typed, prioritized Recommendation[] — each a {surface, action ('fix'|'differentiate'), rationale=cause, fix, detector, confidence}, likely dead-ends before possible proxies. The whole-repo ADOPTION view — health score + ranked free fixes + the hand-off to the MEASURED behavioral delta (the audit model trigger tier, real-model on the sub — the next layer). formatOptimize/formatRecommendations report an EMPTY machine (no loadable surface) as empty, never 'clean'. The 'linting as a free pre-filter to measurement' thesis. See roadmap §P1/P2src/audit-score.ts— Category scoring forvigiles audit— the Lighthouse RINGS. Buckets the SAME deterministic findings the report already computes into five categories, ordered Truthfulness (refs resolve) / Triggering (skills fire / don't collide) / Structure (tool contracts, MCP, frontmatter) / Safety / Tested (coverage, advisory/last). SAFETY IS a ring, fed by the STATIC lethal-trifecta check (lethalTrifectaIssues → report.trifectaFindings): a unit holding all three capability legs is a prompt-injection exfil path detectable from the tool-SET alone, nothing executes — so it sidesteps the cross-platform-confinement blocker. EVERY unit holding all three legs is GRADED — the HARD (explicit all-three) contract AND the ADVISORY (inherits-all) one alike, because an inherits-all unit holds the three legs implicitly AND every other capability, so it can never cost LESS (grading only the explicit case made the score non-monotone: DECLARING a tools contract, a real risk reduction, could only lower it — measured 2026-08-03 on a 35-skill repo, Safety 70 at 35/35 units inheriting everything → 0 after contracts cut exposure to 17/35). The cost is CAPPED against the SHARE of the surface exposed (W_TRIFECTA=10/unit, W_TRIFECTA_MAX=30 total — a ding, NOT a fail: a trifecta is a capability PATTERN with no exploit code and official plugins ship it, e.g. feature-dev's code-reviewer lists Read+WebFetch+WebSearch → 3-of-3 units = −30 → C, not F); the lint-rule severity is a separate axis. The EXECUTING 'do your hooks actually block?' disaster-battery is STILL NOT a ring (running arbitrary hooks safely needs cross-platform confinement that isn't shipped) — it lives in thevigilestesting API via guardrail-check/assertBlocksDisasters where you opt in explicitly. Safety is a GRADED ring (score = 100 − min(10×exposed, 30×exposed/assessable) via the shared trifectaExposure, the same number summed by reportDeductions so the ring and the headline agree); it scores null=n/a (EXCLUDED from the overall, never a false 0) ONLY when there's NO tool-bearing surface to assess (no subagents AND no model-invocable skills), and clean=100 when there are surfaces but no trifecta. The overall stays the SHARED summed computeIntegrityScore(reportDeductions(report)) — NOT a separate audit computation — so audit-overall == leaderboard-health. auditScore(report) + formatAuditScore (terminal rings). Pure over ScanReportsrc/audit-prompts.ts— Auto-generated trigger probes for theauditmodel trigger tier (zero-setup): derive a diverse probe set from each skill's own description so the model-gated trigger-rate needs no hand-authored --prompts file. topicOf extracts a short action topic (strips boilerplate lead-ins, caps 8 words so frames stay lexically distant), recallPrompts wraps it in distinct frames, autoTriggerPrompts builds the TriggerPromptSet (recall + a shared irrelevant bank). Deterministic (the model is spent RUNNING the probes, not authoring them); the set clears a relaxed AUTO gate (AUTO_MIN_DISTANCE 0.2; measured min pairwise NCD ~0.27). --prompts= still overrides for a curated benchmark + the collision matrixsrc/segment.ts— Tier-A deterministic (no-model) SEGMENTER — the rule-vs-not stage of the audit rule map. segmentInstructions(markdown) splits a CLAUDE.md/AGENTS.md into ATOMIC candidate rules with provenance, biased PRECISION over recall: a thin DISPATCHER over per-block helpers (handleListItem/handleParagraph/gatherListBody/gatherParagraph) whose heart is gate() → {confidence:high|medium} OR {reject:index|description|section|no-signal}. Emits {segments, skipped} where each reject carries its reason so nothing is silently dropped (§3 honesty). The deontic/imperative lexicon it keys on (FORM_HEAD/RULE_PREDICATE) lives in src/rule-signals.ts. Pure, dep-free; tested in src/segment.test.ts. Lives at src/ root (not core/) with its rule-routing siblingsrc/rule-inventory.ts— The NARROW rule inventory + the static INTENT_MAP — 'which prose lines NAME an off-the-shelf lint rule, and is it enabled?'. INTENT_MAP is the ~23 static prose→rule aliases; matchesWholeToken is the hardened whole-token matcher (both reused by rule-routing's intent rescue); buildRuleInventory is the named-rule inventory; LinterName + ConfigState live here. Pure domain (no fs). The broader src/rule-routing.ts segments the WHOLE file; this answers the narrow named-rule questionsrc/rule-routing.ts— The deterministic (no-model) ROUTING stage of the audit rule map — routeRules(text,{availableRules,minConfidence}) segments (via src/segment.ts) then routes each atomic rule into a lane by MECHANISM: reuse(✓ config-line)/hook(⛓)/meta(☰ prose)/semantic(✎ prose)/unrouted(⚙ synthesize). extractMarkedRules consumes explicit Enforced by:/Guard:/Guidance only markers first (definitive, zero-heuristic); buildCatalogLookup turns a merged catalog into a token→{linter,enabled} map (a colliding bare id combines conservatively; a numeric code keeps its own linter's hit); the RESCUE ladder (RESCUE_SOURCES = catalog/pattern/intent) promotes a medium/no-signal bullet that provably maps to a real off-the-shelf rule; partitionCandidates splits into confident/possible/skipped — the LOAD-BEARING no-signal-fold asymmetry: a gate-rejected bullet is promoted ONLY by a real rescue, NEVER the blanket medium opt-in. LANE_META (exported) is the single glyph+label source the CLI summary reads; RuleRouting {segmented,counts,rules,possible,skipped}; mergeRoutings folds per-file routings.src/rule-signals.ts— The shared LEXICAL rule-signal vocabulary — ONE home for the deontic/imperative regexes both detection stages key on, so they can't drift. FORM_HEAD (imperative sentence HEAD — the segment gate's form cue), RULE_PREDICATE (a deontic modal ANYWHERE — segment's description-reject guard), NORM_SIGNAL (a deontic modal ANYWHERE — routing's POSSIBLE-tier recall gate). RULE_PREDICATE + NORM_SIGNAL are twin 'deontic-modal-anywhere' matchers with deliberately-different word lists (different jobs, calibrated separately) — kept adjacent so widening one prompts reviewing the other. Consumed by src/segment.ts + src/rule-routing.tssrc/core/rule-catalog.ts— The DYNAMIC available-rule catalog for the audit rule map — enumerateEslintCatalog/enumeratePylintCatalog EXECUTE the repo's real linter (own-repo + consent) to list every installed rule (plugins included) + enabled-state (Pylint via--list-msgs+ SECTION-AWARE--list-msgs-enabled; matchable by symbol OR numeric code). parseEslintCatalog/parsePylintCatalog are the pure JSON/text→typed parses. mergeCatalogs unions a polyglot repo's catalogs KEEPING every entry (an id shared across linters is two real rules; collision routing is buildCatalogLookup's job, not the merge's). AvailableRule carries per-rule linter + code provenance; the 'documented but OFF' nudge reads off enabled-state. Tested in src/core/rule-catalog.test.ts + the real-binary src/rule-catalog-oss.test.tssrc/audit-report.ts— TheAuditReport— the VERSIONED JSON contract every renderer reads. Everything renders FROM it: the local single-file HTML report andaudit --jsonfor CI. meta{schemaVersion,tool,vigilesVersion,harness,dir,generatedAt?} + score (AuditScore) + recommendations + inventory. buildAuditReport(report, {harness, vigilesVersion, battery?}) assembles it — pure, no clock (the CLI stamps generatedAt at write time so the HTML-embedded form stays deterministic). VERSIONED + additive-only within a schemaVersion (it's the wire format between the CLI and any downstream). Mirrored by packages/report-view/src/schema.ts (the shared report view builds independently)src/audit-html.ts— The shareable HTML audit report — ONE renderer (pure shadcn/Tailwind, no custom-CSS fallback). renderAuditHtml(report) injects the AuditReport JSON into the prebuilt React/shadcn template (dist/audit-report.template.html, built from report/ — see below); the React app runs in the reader's BROWSER, so the CLI stays runtime-dep-light and the output is still ONE offline file. injectReportData(template, report) is the pure testable core; <,>,& escaped on injection (no <script> breakout). The build GUARANTEES the template (build-report.mjs fails loud), so there's no inline fallback; if it's somehow missing the CLI skips the HTML (the JSON + terminal report don't depend on it). templatePath() resolves via __dirname (CommonJS output → no import.meta)packages/report-view/— @vigiles/report-view — the SHARED audit report view (source-only, private, never published): the presentational React components (Report, Ring, RuleInventory, Adopt, Adoptability, Observations, ui/badge, ui/card), the AuditReport schema (src/schema.ts mirrors src/audit-report.ts — wire shape pinned by src/audit-report.test.ts), the band tokens (src/lib/band.ts), and a theme.css consumers import. Rendered by report/ (and later site/ + the hosted demo) from the SAME AuditReport JSON so browser and CLI show the SAME artifact — never a screenshot, never a duplicated/relative-cross-imported component (the landing-site skill's shared-UI invariant). Consumed as an npm WORKSPACE (root workspaces:[packages/,report,site]; report depends on '') so deps hoist to root node_modules and resolve with zero config; the published vigiles package's CI gates (api-surface, coverage) are unaffected (workspaces are private, excluded from its files array). A consumer must @source this package's src (Tailwind v4 ignores node_modules).report/— The audit report UI (@vigiles/report): a Vite + Tailwind v4 app whose src is now just main.tsx + index.css — it CONSUMES @vigiles/report-view (the shared components + schema + theme) and builds it via vite-plugin-singlefile to ONE self-contained index.html the CLI fills with an AuditReport (window.VIGILES_DATA placeholder → injected JSON). Kept out of the published CLI's runtime deps (the frontend toolchain lives here; only the built template ships). scripts/build-report.mjs builds it + copies the template (wired into npm run build; FAILS LOUD if the build can't run — the report is part of the product, no silent degrade; its install-guard checks root node_modules since workspace deps are hoisted). index.css @sources ../../packages/report-view/src + @imports @vigiles/report-view/theme.csssrc/scaffold-test.ts— The deterministic test-gen ENGINE (B1: free-form in, a RUNNABLE starter test out) — SKILL-INTERNAL, no CLI verb (the test-harness skill drives it; the standalone verb was demoted to trim the launch surface). scaffoldTest(input) is pure (ScaffoldInput → {path, content, kind, tier}): the deterministic counterpart to the test-harness SKILL (which picks the tier with a model), it picks the cheapest meaningful tier per kind — hook→unit (runHook), skill→eval (measureTriggerRate recall+precision), subagent→harness — emits at the surface's suggested test path (mirrors test-coverage's suggestedTestPath so a generated file stops the surface being reported untested), wires the real PUBLIC API (vigiles, vigiles/eval, vigiles/spec) + the surface's own metadata, and leaves TODOs only where a human/model must supply judgement (prompts/event/values). THE TYPED-SPEC PAYOFF (the thing markdown can't generate): for a subagent the generator CONSUMES the typed contract — a parsed result() contract (ResultContract from the compiled .md's vigiles:ok/err blocks) → a deterministic assertAgentOk OUTCOME test reconstructing the real fields (NO LLM judge), and the side-effecting tools (effectSurface(tools, dialect).sideEffecting) → a generated SAFETY check (notTool for Bash, didNotWrite for Write/Edit) that asserts the agent's 'hole' stays in its lane (the auto-derive-interceptTools nugget from effect-boundary-design.md, realized at scaffold time). So a spec WRITES its own outcome+safety tests. formatScaffolds renders the summarysrc/scaffold-test.test.ts— Scaffold-test engine suite (vitest): each kind (hook/skill/agent) yields the right colocated path + tier + public import + core call + the surface's metadata (namespaced id, declared tools, the user-invoked caveat, the fallback), formatScaffolds empty-vs-listed, AND a node --check syntax-validity gate over every generated template (a template typo can't ship a broken scaffold)src/optimize.test.ts— Optimize test suite (vitest): pure over a hand-built ScanReport (no fs/model) — a clean loaded repo scores 100/A with no fixes and hands off to the measured layer (interactive/subscription), an EMPTY machine is reported empty (NOT 'clean'), a description-overlap → a DIFFERENTIATE rec (possible), a typo'd tool → a FIX rec (likely) with the swap, likely-before-possible ordering, and the score tracks scoreReport's structural penalties (3 no-description skills → 70/C)bench/corpus/coding-tasks.mjs— The reusable real-task corpus — the substrate the MEASUREMENT layer runs on. 5 NEUTRAL coding tasks (slugify/debounce/bugfix-offbyone/bigO/regex-email), each self-contained (seeds its own input), checkable (a deterministic check(ctx)→1|0 over the written artifact, never an LLM judge), agentic (reads a seed so input+cache dominate, output is a single-digit %), and cheap at N trials. A benchmark/optimizer supplies the treatment (a skill's SKILL.md, a model, a rule set) as the A arm and runs the SAME task with/without it; the signal is the per-task delta. Consumed by the ecosystem benchmark (A1, bench/ecosystem/benchmark.mjs — the faithful, corrected caveman/skill measurement) + vigiles optimize (A2).bench/corpus/verify.mjs— Runnable, no-model self-check for the corpus correctness oracles — proves each task's check DISCRIMINATES a known-good vs known-bad artifact (a check that always returns 1 would silently void the blast-radius column). bench/ is outside the vitest src/ sweep, so this is the corpus's guard: node bench/corpus/verify.mjs, exit 0 all-pass / 1 on any non-discriminating checksrc/adapters/claude-code/run-scripts.ts— Script runner forvigiles test/vigiles eval: discover*.harness.*/*.eval.*(JS+TS), run each as a child node process, classify each result pass/skip/fail (exit 0 / SKIP_EXIT_CODE 77 / else) and tally them SEPARATELY — a⊘ SKIPPEDis loud, never folded into 'passed', and a skip never fails the run (anyFailed). NO blanket claude-gate: unit-tier runHook tests run withoutclaude; a tier that needs it self-reports SKIPPED viaskip()src/adapters/claude-code/run-scripts.test.ts— Script-runner test suite (node:test): discovery, exit-code aggregation, env forwarding, summary formattingsrc/core/inline.ts— Inline-mode parser:<!-- vigiles:enforce ... -->comments in markdown for gradual adoptionsrc/core/frontmatter.ts— Frontmatter-mode parser:vigiles: enforce:YAML frontmatter rules in markdown (Level 1 adoption)src/core/frontmatter.test.ts— Frontmatter parser test suite (node:test)src/core/frontmatter-read.ts— Lenient frontmatter reader — ONE reader for the SKILL.md/subagent --- block, shared by scan + the PreToolUse rail (agent-runtime), replacing three divergent hand-parsers (scan's readField + agent-runtime's regex). Strategy: real js-yaml parse (block scalars / quoted multi-line / flow arrays for free) WITH a regex salvage on malformed input — readFrontmatter never throws and returns malformed:true (the frontmatter-valid signal). frontmatterScalar (string/number/bool, else salvage) + frontmatterList (array | comma-string; absent→null=inherits-all, present-empty→[]=no-tools — the rail semantics). Anchored at file start (+ optional BOM + optional leading vigiles integrity comment) so a BODY --- hr is never read as frontmatter. Distinct from core/frontmatter.ts (the Level-1 vigiles: rule block)src/core/frontmatter-read.test.ts— Lenient-reader suite (vitest): valid YAML scalars + flow array, comma-list split, absent→null vs present-empty→[], block-scalar + next-line-quoted, malformed YAML → malformed:true AND salvages a column-0 field, leading vigiles comment tolerated, body --- hr not read as frontmattersrc/core/hook-normalize.ts— Hook settings normalization — the typed boundary (parse-don't-validate) the audit hook detectors read. normalizeHooks(raw) parses the raw settings.hooksunknownONCE into a typed HookRegistration[] {event, matcher, command}, tolerant of BOTH the Claude Code nested shape ({matcher, hooks:[{command}]}) AND the Codex flat shape ({command}, hooks.Event command=…) — so the hook-block/matcher/event detectors stop re-walking unknown with casts and never silently drop a Codex repo's hooks (test-both-harnesses). hookEventNames(raw) returns the object keys (the closed-event-set check) and [] for an array. Harness-agnostic by TOLERANCE not a port (the shape difference is small enough one reader covers it; rule-of-three not yet met). Consumed by scan.tssrc/core/hook-normalize.test.ts— Hook-normalize suite (vitest): flattens the CC nested shape, reads the Codex flat shape, carries matcher null when absent, drops empty/non-string commands + non-object entries, returns [] for non-object/array/null (never throws); hookEventNames object-keys vs [] for an arrayaction.yml— GitHub Action — a composite action over the publishednpx vigilesCLI (NOT a node20 entry pointing at an uncommitted dist/): maps every input to a real CLI flag, sets thevalidoutput via $GITHUB_OUTPUT, and supportsversion: localso the repo dogfoods it viauses: ./. See docs/cli.md and theprod-grade-gha-clirule..github/workflows/pages.yml— ONE GitHub Pages deploy for the WHOLE site (a repo gets one Pages site), on push to main — REPLACED the old separate api-docs.yml + build-only site.yml (combined in #76). Layout:/→ the marketing landing (site/, Vite + React + shadcn, built with the root engine dist/ via the@engine/*aliases);/api/→ the GENERATED API reference (TypeDoc, config typedoc.json, reads src/ directly, curated to the public AUTHORING entry points — spec/linting/testing/hook/adapter/claude-code/codex, NOT the unit/integration/e2e barrels or the vitest/jest matchers — public-only excludeInternal, members INLINE with sidebar + search + .nojekyll). Chosen over @microsoft/api-documenter (which paged ~1900 flat files — wrong shape for a TS lib; TypeDoc is ~465 pages). Site: vigiles.sh (+ zernie.github.io/vigiles), linked from README + docs. api-reference/ stays gitignored (generate-not-commit); the COMMITTED surface artifact is api-surface/*.api.md (api-extractor, the ci.yml surface gate). REQUIRES the one-time Settings → Pages → Source: GitHub Actions.src/cli-flags.ts— Shared CLI flag → config bridge (applyConfigFlags): --max-rules / --catalog-only override the loaded config so every GitHub Action input maps to a real CLI flag. Pure, unit-tested in src/cli-flags.test.ts.src/cli-commands.ts— The canonical vigiles command surface — the SINGLE SOURCE OF TRUTH (VERBS + HOOK_RUNTIME_KINDS) the self-command-refs dogfood cross-references vigiles's OWN docs/comments against, so a renamed command can't leave a stalevigiles <cmd>ref rotting. A behavioural test asserts the dispatch recognizes exactly these kinds, so the list can't drift from the code.src/dialect-drift.ts— Dialect freshness/drift detection — the read-local backstop for the hand-maintained claudeCodeDialect (CC is a black box). Pure parsers (parseToolInputTypes over sdk-tools.d.ts, eventsMissingFromBundle over a readable JS bundle) + findClaudeCodePackage (locates the user's INSTALLED @anthropic-ai/claude-code via npm-root-g / the claude binary) + findClaudeCodeBundle (the readable cli.js, or null) + ACKNOWLEDGED_TOOL_INPUT_TYPES + VALIDATED_CC_VERSION (the hand-authored baseline, 2.1.187). READS THE LOCAL INSTALL ONLY — ToS-clean, never vendors/ships their types (all-rights-reserved — both @anthropic-ai/claude-code AND @anthropic-ai/claude-agent-sdk are '© Anthropic PBC. All rights reserved.', so we DON'Timport typetheir ToolInputSchemas union despite the clean ./sdk-tools subpath; the acknowledged set is bare identifiers = facts, not their file). NATIVE-BINARY NOTE: CC ≥ ~2.1.18x ships bin/claude.exe (a platform optionalDependency), NOT a readable cli.js, so the event-literal scan degrades to a LOUD SKIP while the sdk-tools.d.ts tool-type alarm keeps working. TWO consumers: the gated CI test (fails loud on tool/event drift; CI PINS @anthropic-ai/claude-code@<VALIDATED_CC_VERSION>, grepped from the source as the single knob, in every real-binary job so the alarm fires only on a DELIBERATE bump — not on every unpinned CC release — and the real-claude tiers stay reproducible) ANDvigiles auditat runtime via checkDialectDrift/formatDialectDrift — a best-effort read-local WARN (reads only the small sdk-tools.d.ts; one-line ⚠ ONLY on real tool-surface drift, never on a mere version bump; never throws/blocks). Deliberately NOT wired into compile (hot recompile path).src/dialect-drift.test.ts— Dialect-drift suite (vitest): pure parser units + a GATED read-local freshness check against the INSTALLED claude-code — fails loud when CC's sdk-tools.d.ts tool-input set drifts from ACKNOWLEDGED_TOOL_INPUT_TYPES or a claudeCodeDialect hook event vanishes from the readable JS bundle; skips LOUDLY when CC isn't installed OR when CC ships a native binary with no readable cli.js (the event scan has nothing to read). Validated against 2.1.187 (38 tool types, 9 events).src/self-command-refs.ts— Self-command-reference verification — the cross-referencing engine applied to vigiles's OWN docs (the cohesive-cli-surface enforcement): everyvigiles <cmd>reference must resolve to a real VERB orhook-runtime <kind>(src/cli-commands.ts). HIGH-PRECISION (don't-cry-wolf): a ref is inspected only in a COMMAND CONTEXT (inlinecode span, ```shell fence, or annpx/Usage:/cli.js/${CLI}invocation — the last is the harness-test conventionnode ${CLI} <cmd>, which previously let a stalerefs-hookref slip through a.harness.mjs) — prose + non-shell fences are never matched; a bare unknown verb is flagged only when hyphenated (every renamed command is) or in an explicit invocation. Pure detector reused by the repo dogfood in src/self-command-refs.test.tssrc/self-command-refs.test.ts— Self-command-refs unit tests + the repo DOGFOOD: scan vigiles's own docs/comments (docs/, README, CLAUDE.md, src/, examples/, hooks/) and assert everyvigiles <cmd>resolves. The deterministic gate that catches the stale refs a manual rename sweep leaks (it found 8 the manual pass missed). Plus a behavioural check that the dispatch recognizes exactly HOOK_RUNTIME_KINDSsrc/doc-command-coverage.ts— Doc-command coverage — the INVERSE of self-command-refs and the deterministic FLOOR under the document-the-why rule. self-command-refs checks docs→code (everyvigiles <cmd>ref resolves); this checks code→docs (every public VERB is MENTIONED under docs/, so a verb shipped without a doc home fails CI). HIGH-PRECISION, biased AGAINST a false 'undocumented' alarm: 'mentioned' is matched generously in a COMMAND context (vigiles <verb>or a backtick-prefixed`<verb>), so a bare English word ('test'/'audit') doesn't count but a documented verb always does. hook-runtime (the hidden runtime umbrella) is exempt. Pure detector reused by the repo dogfood in src/doc-command-coverage.test.tssrc/doc-command-coverage.test.ts— Doc-command-coverage unit tests + the repo DOGFOOD: every public VERB is mentioned under docs/ (the floor under document-the-why). verbMentioned counts a command context not a bare word, findUndocumentedVerbs flags a verb absent from all docs and exempts hook-runtimesrc/core/spec.test.ts— Spec + compiler test suite (node:test)src/core/agent.test.ts— Subagent compilation test suite (node:test): agent() builder + compileAgent — frontmatter, tool-contract verification (built-in/MCP/never-available/did-you-mean), body-ref validation, Rules section, hash, adoptDiff round-tripsrc/adapters/claude-code/agent-runtime.ts— Agent PreToolUse tool-contract rail — the differentiator that closes the declared-vs-enforced gap (#4740/#21460, SDK #172): tools: is documentation, so a PreToolUse hook (vigiles hook-runtime agent) blocks any tool outside the active subagent's contract. parseAgentTools reads the compiled .md frontmatter (the single source of truth the hook enforces) — a thin wrapper over parseAgentToolList(md, key), which now delegates to the shared lenient reader (core/frontmatter-read.ts: real YAML parse + regex salvage) and is reused for disallowedTools: (the disallowed-tools-contract scan/lint), decidePreToolUse is the pure allow/deny, and .vigiles/active-agent.json tracks the dispatched agent — mirrors the skill Stop-hook (src/adapters/claude-code/skill-runtime.ts). ALSO the runtime PURITY gate: parseAgentPurity reads the compiled marker, and evaluatePreToolUse(cwd, tool, command?) now runs BOTH rails in order — the tool-contract allowlist first, then decidePurityGate (core/effects.ts) refining Bash by the live command (isReadOnlyBash): a bounded agent'sgit statusallowed,git pushdenied; the hook-runtime agent CLI passes tool_input.command. Skills have the SAME gate (skill-runtime.ts: parseSkillPurity + evaluateSkillPreToolUse, the hook-runtime skill-tool CLI) — the floor only, no tools-allowlist rail for skills yet. ALSO the position-aware effect-BOUNDARY: when a unit's .md carries a marker (hasEffectBoundary, src/adapters/claude-code/effect-region.ts), the gate tightens to the 'pure' effective floor OUTSIDE the region (read-only only) and the declared floor INSIDE it, where inside/outside is tracked by .vigiles/effect-active.json toggled by the vigiles hook-runtime effect-enter/effect-exit CLI — fail-closed (a missing enter only over-blocks). effect() is SUBAGENT-only, so only the AGENT rail applies it — a default skill has no call→return region to scope (the dogfood that retired the model-emitted boundary); the skill rail is the purity floor only, and compile errors on effect-in-skill. DETERMINISTIC subagent window (no model agent-start/effect-enter): the agent-hook brackets the active period on the harness events CC actually has — PreToolUse(tool=Task or Agent) OPENS it by PUSHING a frame (decideTaskDispatch/resolveDispatchedAgent read tool_input.subagent_type → the agents/.md under cwd or $CLAUDE_PLUGIN_ROOT, fail-open on an unknown agent; the dispatch itself is the parent's action, not gated) and SubagentStop CLOSES it by POPPING back to the parent frame (clears effect) — so a consumer registers agent-hook on BOTH PreToolUse and SubagentStop; CC has NO SubagentStart (only Stop), so the spawn dispatch is the open signal (agent-start/effect-enter/exit stay as manual fallbacks). NESTING-SAFE: .vigiles/active-agent.json is a depth-aware STACK (pushActiveAgent/popActiveAgent; readActiveAgent = the TOP, the gate's source of truth; legacy single-slot {agent} read back-compat) — push on dispatch, pop on SubagentStop, gate on top — closing the CONTRACT-ESCAPE the prior flat single-slot model allowed under CC v2.1.172 depth-5 nesting (an inner subagent's Stop cleared the whole slot → the gate then allowed a tool the OUTER subagent forbade; TLC-certified fix, counterexample Open;Open;Stop;Call(Bash)). Still EXPERIMENTAL — parked P3, do NOT auto-wire: the effect() sub-region it served is dropped as a goal (a deterministic in-flow boundary has no harness signal; the subagent-split is weaker+costlier), realistic path = whole-unit purity floor + a stateful pre-hook; the stack ships for when active-agent contract enforcement under nesting is wanted on its own.src/adapters/claude-code/agent-runtime.test.ts— Agent-runtime test suite: pure parse/decide logic, active-agent round-trip, hook ⇄ allowlist agree (the declared contract IS the enforced rail), parseAgentPurity round-trip + the runtime purity gate (a bounded agent's Bash is command-refined — git status allowed, git push denied; the tool-contract rail still fires before the purity gate), the effect-BOUNDARY gate (outside a vigiles:effect region Write is blocked, inside — after setEffectActive — it's allowed; a runHook e2e: effect-enter → Write allowed, effect-exit → blocked again), the real built CLI hook driven deterministically via runHook (incl. the command-gated Bash block, the unit tier reaches PreToolUse where a live tool call is flaky), the depth-aware STACK (push/pop is a stack — readActiveAgent is the top, pop returns to the parent; legacy {agent} back-compat) + the NESTING CONTRACT-ESCAPE regression (the AgentWindowStack.tla counterexample Open;Open;Stop;Call(Bash) is DENIED — pure + a runHook e2e proving SubagentStop pops to the parent, not a full clear), and grounding on the REAL vendored wshobson ui-visual-validator (ships no tools: line → inherits all; the spec adds the rail it omits)src/adapters/claude-code/effect-region.ts— Effect-region state — the position-aware half of the purity gate. A vigiles:effect BOUNDARY refines the per-call floor by POSITION ('side effects only inside this block'), but a PreToolUse hook sees a tool call, not prose position — so (mirroring active-agent/skill tracking) the agent SIGNALS region entry/exit: setEffectActive/clearEffectActive toggle .vigiles/effect-active.json (vigiles hook-runtime effect-enter/effect-exit CLI), readEffectActive reads it (malformed-tolerant), hasEffectBoundary(md) detects the marker. Fail-closed: a unit declaring a boundary but not inside an active region is treated read-only (the 'pure' effective floor), so a missing effect-enter only over-blocks. Only the AGENT PreToolUse rail consumes it — effect() is SUBAGENT-only (a default skill has no structural region to scope; compileSkill errors on effect-in-skill, and a skill uses the purity floor + context:fork instead). NB the model-emitted enter/exit is itself a known-fragile interim — the deterministic fix is to bracket the region on the harness's SubagentStop (+ Task-dispatch) events, not a model call.src/adapters/claude-code/effect-region.test.ts— Effect-region test suite (vitest): setEffectActive/readEffectActive/clearEffectActive round-trip + idempotent clear + malformed-JSON tolerance, hasEffectBoundary true/false on a markersrc/adapters/claude-code/agent-result.ts— Railway result parser: a subagent with a result() contract ends its turn with a vigiles:ok/err block; parseAgentResult turns that text into a discriminated outcome (ok | err | malformed) and validates it against the contract shape. Pure text→Result<S,E>, the primitive the orchestrator + the assertAgentOk/Err/Result test helpers both reusesrc/adapters/claude-code/agent-result.test.ts— Railway result-parser test suite: ok/err/malformed tracks, last-block-wins, JSON + shape validation across every field type (string/number/boolean/string[]), both success and error trackssrc/core/railway.test.ts— Railway surface test suite: result() Output-contract rendering in compileAgent, delegate()/railway() builders, compileRailway orchestrator output, validateRailway static checks (unknown delegate target, empty railway, bounded recovery — the sub-Turing guarantees)src/core/validate.test.ts— Validation test suite (node:test)src/cli.test.ts— CLI integration + E2E test suite (node:test)src/core/integrity.ts— Integrity check: SHA-256 hash verification for compiled markdown (detects hand-edits)src/core/sidecar.ts— Per-spec sidecar manifests at .vigiles/.inputs.json, used by session auditsrc/core/sidecar.test.ts— Tests for sidecar manifests, per-file hashes, and integrity checksrc/core/coverage.ts— Spec coverage analysis: linter rule coverage + npm script coverage with configurable thresholdssrc/core/coverage.test.ts— Coverage test suite (node:test)src/core/session.ts— Post-session audit: git diff analysis against spec surface areasrc/core/session.test.ts— Session lint test suite (node:test)src/core/hash.ts— Shared SHA256Hash branded type and assertNever exhaustive check helpersrc/core/orphans.ts— Orphan-docs detector (OPT-IN via the .vigilesrc.jsonorphansblock, off unless declared): finds .md files in a configured dir (defaultdocs/) that no other .md references. Deliberately opt-in — an OSS sweep found 'unreferenced' is ~100% false positives on a nav-managed doc site (Docusaurus/MkDocs, where the page graph lives in config), and only signals rot for a hand-cross-linked corpus. The CLI GATE lives in cli.ts step 7 (policy at the call site; the detector stays a pure scanner); the default dir isdocs/only (the dropped dir was vigiles-specific, 0/10 sampled OSS repos had it). See docs/rules/orphan-docs.mdsrc/core/orphans.test.ts— Orphan-docs detector test suite (node:test)src/core/compose.ts— Sync-tool compatibility detector: detectSyncTools/composeCollisions — pure filesystem check that vigiles stays composable with Ruler (.ruler/, ruler.toml) and rulesync (.rulesync/) instead of fighting them for CLAUDE.md/AGENTS.md. Reports the file-ownership collision (a vigiles compile target the tool also regenerates → stales the integrity hash) with the source-slot redirect that fixes it (Topology A: compile upstream, let the tool distribute). Same deterministic-detector shape as orphans.ts/test-coverage.tssrc/core/compose.test.ts— Sync-tool detector test suite (node:test): ruler/rulesync detection (dir + ruler.toml keys), source-slot paths, CLAUDE.md/AGENTS.md collision incl. path-qualified target match by filename, no-tool and non-overlapping no-collision, both-tools-presentsrc/test-coverage.ts— Untested-surface detector (the per-kind vigiles/untested-skill + untested-subagent + untested-hook rules — replacing the old umbrella untested-surface): finds skills/agents/hooks that ship with no test or eval — the third gap detector beside orphan-docs. Two OR'd detectors decide 'tested': colocation (a*.{harness,eval}.mjsnext to the surface) + content-reference (any test, incl.*.test.ts, naming it by path or :namespace). EVERY skill/agent/hook is held to it (each kind gated by its own rule severity) — invocation mode does NOT exempt (a command-only skill still does something worth testing); the only opt-out is an explicit vigiles:ignore-test marker, reported as exempt so the skip is visible. Warning-by-default, surfaced by vigiles lintsrc/test-coverage.test.ts— Untested-surface detector test suite (vitest): colocation + content-reference coverage, command-only skills held to the requirement (no invocation-mode exemption), vigiles:ignore-test opt-out (counted as exempt), agent sibling match, hook-script discovery from plugin.json, kind toggles, report formatting + suggestedTestPathdocs/rules/untested-skill.md— Rule doc: untested-skill — a SKILL.md must ship with a test/eval; config, severity, scope (skills/*), the two coverage detectors, the explicit vigiles:ignore-test opt-out, why. Sibling of untested-subagent/untested-hook (the per-kind split of the old untested-surface)docs/rules/untested-subagent.md— Rule doc: untested-subagent — a subagent (agents/*.md) must ship with a test/eval; the agent's contract (tool-contract / outcome) is what's tested, distinct from a skill's triggerdocs/rules/untested-hook.md— Rule doc: untested-hook — a file-backed hook script must ship with a test/eval (a runHook unit test is the natural one); scope = hooks referenced from plugin.json/settings.jsondocs/rules/unmarked-refs.md— Rule doc: unmarked-refs — the PostToolUse refs-hook that nudges the agent to MARK code-shaped references in instruction files (warn → non-blocking nudge, error → block, false → off); what it checks, opt-outs, where it runs, and the undecidable plaintext floordocs/rules/subagent-tool-contract.md— Rule doc: subagent-tool-contract — cross-reference a subagent's tools: rail against the harness tool catalog (the cross-referencing engine). High-precision: flags never-available + close-typo only, never a bare unrecognized (plugin/MCP) tool; default warn, error gates CI; same detector as scan + compileAgentdocs/rules/hook-events.md— Rule doc: hook-events — cross-reference a hook's event name against the harness event catalog (a typo never fires). High-precision: close typos only (frameworks like han extend the event set); object-keyed hooks only (a hooks array is a non-CC format, skipped); default warn; same detector as scandocs/rules/subagent-frontmatter.md— Rule doc: subagent-frontmatter — two subagent-frontmatter defects, one rule: (1) a SUBAGENT missing name/description (won't register, no fallback); (2) an invalid model:/color: VALUE (a close typo of a real alias/color → silent fallback/ignored, matching Anthropic's ownclaude plugin validate+ cclint). Skills are NOT checked for missing frontmatter: per CC docs a SKILL.md needs none (name←dir, description←first paragraph). High-precision (close-typo only; a full dated model id left alone). Catches ananddtyagi's prose-only agents; default warn; same detector as scandocs/rules/mcp-config.md— Rule doc: mcp-config — flag a declared MCP server that can't start (no command/url); FP-safe unambiguous check; reads .mcp.json + manifest mcpServers (Codex TOML not yet parsed); default warn; same detector as scandocs/rules/mcp-tool-resolves.md— Rule doc: mcp-tool-resolves — the MCP half of the tool-reference check: flag a subagent's mcp__server__tool whose server isn't in the plugin's declared mcpServers. High-precision (gate on a declared set, allowlist built-ins likeide, skip the plugin-namespaced form); default warn; same detector as scan (mcpToolIssues). Sibling of subagent-tool-contract (built-in half) + mcp-config (server can start)docs/rules/mcp-hook-target-resolves.md— Rule doc: mcp-hook-target-resolves — cross-reference a type:mcp_tool hook action against declared mcpServers: flag an incomplete action (no server/tool, always) + a server the plugin doesn't declare (gated on a declared set + built-ins allowlisted, like mcp-tool-resolves). Extends the check to the hook surface; default warn; same detector as scan (mcpHookIssues). The regex matcher surface is a future hook-matcher rule. Sibling of mcp-tool-resolves + mcp-configdocs/rules/hook-script-exists.md— Rule doc: hook-script-exists — flag a hook command referencing a script file missing on disk (silently never runs); matches Anthropic's ownclaude plugin validate, making vigiles a superset on the hook surface. FP-safe (skips unresolved $VARs, existence-guarded one-liners, inline commands); default warn; same detector as scan (hooks status 'missing'). Distinct from untested-hook (script exists but unverified)docs/rules/disallowed-tools-contract.md— Rule doc: disallowed-tools-contract — the DENY-side mirror of subagent-tool-contract: a disallowedTools: block-list entry that's a close typo of a real tool blocks NOTHING (the tool stays available, silently). High-precision (close-typo only; a real tool is legitimately blocked, never-available is harmless to list, a bare unknown is likely a plugin tool); default warn; same detector as scan (disallowedToolIssues). A check no other validator does (poach from SkillCheck)docs/rules/description-overlap.md— Rule doc: description-overlap — flag two model-invocable skills with near-identical descriptions (the selector can't tell them apart → wrong one fires). A DETERMINISTIC NCD proxy for a model-tier-class precision bug, calibrated FP-safe (cutoff 0.2, below the sweep's most-similar distinct pair at 0.25); user-invoked skills excluded; default warn; same detector as scan (descriptionOverlaps). Bridges deterministic↔behavioral; no other plugin linter has itdocs/rules/frontmatter-valid.md— Rule doc: frontmatter-valid — flag a skill/agent --- block that EXISTS but isn't valid YAML (fields may not parse as intended). HONEST caveat: js-yaml is stricter than some loaders, so a one-line description: with a:colon / is flagged though it may still load — calibrated at 7% of sweep blocks, concentrated in 2 repos, 0 in 11 others, NOT empirically confirmed to fail loading. Hence default WARN (verify before error) + scan shows it as an informational ℹ note, not a structural defect; same detector as scan (malformedFrontmatter via readFrontmatter().malformed). Sibling of subagent-frontmatter (parsed-but-missing-field vs this unparseable-block)docs/rules/skill-frontmatter.md— Rule doc: skill-frontmatter — RECOMMEND (not require) explicit skill name+description for a reliable trigger surface; skills load without it (dir/first-paragraph fallback) so it's a best-practice nudge (default warn, set error to enforce your own), distinct from subagent-frontmatter (subagent requirement); same detector as scan (skillMetaIssues)docs/rules/doc-refs.md— Rule doc: doc-refs — validate enforce()/file()/cmd()/ref() calls quoted inside markdown ```ts fences against the real linter catalog / filesystem / package scripts. DEFAULT OFF, and the default is the finding: measured 2026-08-19 across two real repos (2 582 markdown files, 52 refs) it scored 0 true positives, every error it ever raised being a design note sketching an API that does not exist yet or a third-party CLAUDE.md vendored as benchmark data. Structural, not calibration — a fence in prose is a DRAWING of config and the pass read it as config; the consumer repo could only reach a clean lint by excluding a third of itself, after which the pass walked 604 files and found 0 refs. Known gap for whoever improves it: the walker globs withoutdot, so .claude/** (real skills/agents) has never been scanned at allsrc/core/doc-refs.ts— Markdown code-block ref validator (the opt-indoc-refsrule, default off): enforce()/file()/cmd()/ref() calls inside ```ts blocks, with vigiles:ignore opt-outsrc/core/doc-refs.test.ts— Doc-refs validator test suite (node:test)src/doc-refs-rule.test.ts— doc-refs WIRING test — drives the built CLI over a fixture with one broken ref across all three tiers: unconfigured (section absent, walk skipped), "warn" (prints ℹ, exit untouched), "error" (prints ✗, exit 2)src/core/symbols.ts— Cross-language symbol extractor (ast-grep): defines symbols a file declares (functions/classes/methods/constants) across JS/TS/Python/Ruby/Rust/CSS; fileDefinesSymbol with .d.ts/.rbi fallbacksrc/core/symbols.test.ts— Symbol extractor test suite (node:test)src/core/refs.ts— Symbol reference verification: thevigiles:symbol path#namemark (verify the named file defines the symbol) + unmarkedCodeRefs detection. collectRefIssues (shared by thevigiles refsCLI and the PostToolUse refs-hook) + refsHookAction map theunmarked-refsseverity to ok/nudge/block — the hook nudges the agent to MARK unmarked linter-rule references (slash-scoped, no extension; deliberately narrow to stay high-signal — bare identifiers and paths are not flagged) in the loop (warn, default) or blocks the edit (error). The authoring-time half that makes references markable so lint can verify them; see docs/rules/unmarked-refs.mdsrc/core/refs.test.ts— Symbol reference verification test suite (node:test)src/mock-model.ts— Scriptable, dependency-free Anthropic Messages SSE mock (startMock/scriptModel) — point real claude at it via ANTHROPIC_BASE_URL for deterministic harness tests; extractRequest + onRequest capture each request into trace.modelRequestssrc/harness-test.ts— Deterministic harness testing: runHarnessTest(spec, { adapter = claudeCodeAdapter }) runs the adapter's real binary + real hooks/settings against a scripted mock model, dispatching the harness-specific argv/mock/parse through the adapter's HarnessTestDriver (Stop-hooks reliable; tool-event hooks via the eval tier). Defines claudeCodeDriver (the CC driver — wraps the existing argv/startMock/parse, behaviour identical) so a non-CC adapter (codexAdapter) drives real codex through the SAME entry. Safe-by-default — an external plugin/pluginDir is confined per src/sandbox.ts (CC-only path). Also defines runHarness (Phase 2 of testing-api-design.md): the explicit harness-scope entry — the deterministic run (model:'mock', wraps runHarnessTest); model:'real' is non-deterministic so it can't be asserted, throws and points at measure()src/harness-test.test.ts— Harness-test suite (node:test, skips without claude)src/sandbox.ts— Safe-by-default confinement: decideSandbox is the pure policy (untrusted plugin code never runs unconfined unless sandbox:false), runSandboxed co-launches the mock + claude inside one bubblewrap network namespace (loopback-only — mock reachable, egress blocked); specTrusted/bwrapArgs/setenvArgs/parseRequestLog are the pure, tested seams (setenvArgs adds a hook's configured env back after --clearenv, reused by the unit-tier sandbox in run-hook.ts)src/sandbox.test.ts— Sandbox test suite: pure policy/trust/args/log-parse coverage + a gated end-to-end test proving a sandboxed run blocks network egress while the in-sandbox mock stays reachable (skips without bwrap/claude)src/mock-entry.ts— In-sandbox mock entry: run as a subprocess inside the bwrap netns so the scripted mock lives on the isolated loopback; streams captured requests to a file the parent reads back for trace.modelRequestssrc/run-hook.ts— Hook unit tier: runHook pipes a synthesized event JSON to a hook process (no claude, no model) and reports exit code + normalized block/allow decision — the cheap base of the pyramid, and the only tier that reaches every event (Edit/Write, PreCompact, Notification, SessionEnd, SubagentStop); parseHookOutput/decideHook are the pure, testable decision logic; opt-in sandbox: "auto"/"strict" confines an untrusted hook command under bubblewrap (reusing sandbox.ts) via the injectable runHookWith seam (direct/sandboxed/refuse all unit-tested with fakes); egress: { allow } adds the allowlisted real-egress path (src/egress.ts) for hooks whose setup needs a registry and nothing else. Also propertyHook (Phase 5 of testing-api-design.md): invariant-tests a hook's (event)→decision over generated events, reusing proofs.ts propertyTest (seeded, shrinks the counterexample; injectable decide runner, no fast-check dep)src/run-hook.test.ts— Hook unit-tier test suite (node:test): pure decision logic + real shell hooks across exit codes, stdin event passthrough, env injection, JSON permission decisions, runHookWith sandbox + egress routing (fake spawners) + gated bwrap confinement + a gated egress: { allow } integration (allowed host reached, off-list + raw socket dropped) and the OMC session-start dogfood (reaches the npm registry, drops nothing else)src/egress.ts— Allowlisted real egress (egress: { allow }): the in-between between deny-all and recordEgress — let a hook reach ONLY the listed hosts, boundary at the packet layer (an nft policy-drop chain on slirp4netns-provided egress, so a raw socket off-list is dropped too, unlike a proxy allowlist). Pure seams: resolveAllow/parseGetent (host→IPs), buildEgressNft (the ruleset), buildEgressBwrapArgv (caps + info-fd +VIG_*env), parseNftCounters/countersToResult (read-back → r.egress allowed hosts + r.egressDropped), egressAvailable (bwrap+slirp4netns+nft gate). Resolves to IPs at launch; the resolver-pinned dynamic set is the next layersrc/egress-entry.ts— Allowlisted-egress orchestrator subprocess: runHook is sync (spawnSync) but egress needs bwrap + slirp4netns alive at once, so the parent spawnSyncs this entry — it spawns bwrap (--info-fd → child PID), attaches slirp4netns --configure --ready-fd to that netns, touches netready to release the in-netns wrapper (which loads nft, runs the hook, dumps counters before exit), then writes a result file the parent reads back. v8-ignored; the testable logic is in egress.tssrc/egress.test.ts— Egress allowlist test suite: pure helpers — parseGetent family split, resolveAllow (injected resolver), parseResolvers, buildEgressNft (policy drop, DNS allow, per-host v4/ip6 rules, comment sanitize, log+drop tail), buildEgressBwrapArgv (caps/info-fd/VIG_*env/sh -c tail), parseNftCounters (v4+v6 sum, drop aggregate), countersToResult, probeEgressAvailable short-circuitsrc/eval.ts— Harness eval API: runEval drives the real claude CLI across arms x trials and aggregates mean ± se (variance) + cost/latency/token usage; bounded concurrency (runPool) + rate-limit backoff + maxCostUsd budget cap; record/replay cache (cache:readwrite) replays runs so editing measure re-scores for free; measureTriggerRate measures how reliably a skill's description FIRES across varied prompts (recall) and — with irrelevantPrompts — its precision (falsePositiveRate + precision, so a too-broad description that hijacks unrelated work fails too). Trigger-rate keeps the real-model surface THIN by design: skillsDir auto-packages loose .claude/skills (packageSkillsDir) so testing repo-local skills is a one-liner; stubSkillBodies replaces each skill BODY with a no-op (frontmatter kept) so a run stops AT selection — trigger is a property of the frontmatter, decided before the body loads — instead of paying to execute the whole procedure; and a deterministic pre-flight gate (checkPromptDiversity: minPrompts + an NCD near-duplicate check reusing proofs.ts ncd, the same engine findSimilarRules uses) rejects a too-small/copy-pasted prompt set before spending a token. Also the SCORED evaluator (Phases 3-4 of testing-api-design.md): measure(spec, { trials, checks }) scores a check vocabulary across trials → per-check rate ± se + pass^k (measureWith = injectable-runner core, reuses runEvalWith), with assertRates (the scored gate) + checkReportToJUnit (each check a ) so JUnit/baseline fall out — and measure (single-arm) AND measureArms (A/B, per-arm — every arm with a pluginDir repackaged) ALSO take stubSkillBodies (the same body-stub the trigger tier uses, via the shared stubbedPluginDir helper) so a skill-FIRING check (experimental_skill()) costs a fraction of the tokens, the body stopping at selection instead of running its procedure (don't pair with judged/quality checks — the body is gone). Model is part of the MEASUREMENT (lives in the spec, never an env — unlike trials, a run knob): measureTriggerRate defaults to the realistic SELECTOR (sonnet) with a minModel floor (modelTier ranks haiku<sonnet<opus, fail-open on an unknown family) that FAILS a too-weak run before spending a token — haiku under-selects (dogfooded 0.50 haiku vs 0.90 sonnet); model-per-arm (EvalArm.model) makes a cross-model/upgrade comparison a harness A/B (no separate matrix runner). installSet co-installs the user's real skills as the WHOLE-HARNESS tier (vs the isolated-but-honest in-plugin default), and report.competitors = the real selection pool − 1 (so a multi-skill plugin is never mislabeled 'isolated'). The cache key folds in harnessVersion (claude --version) so a CLI upgrade invalidates a stale replay. The Claude stream-json parsing in makeContext is now an injectable ModelOutputParser (parseClaudeRun is the default; measureTriggerRateWith takes one as a 3rd arg) — the seam a second harness's eval parser plugs into WITHOUT touching the Claude path. measureTriggerRate now takes an EvalDriver ({runner, parse, runError?}; claudeEvalDriver default, codexEvalDriver the Codex impl) via the {evalDriver} option — the harness-dispatch seam mirroring runHarnessTest's {adapter}; a trial whose runError fires is EXCLUDED from the denominator (an errored/rate-limited turn isn't a clean miss) and counted as report.errored. Codex's eval transport (src/adapters/codex/eval.ts) is BUILT + live-validated against the real binary: parseCodexEvalRun reads the confirmed codex exec --json thread/item JSONL (agent_message→text, command_execution→command, usage on turn.completed, item.completed-only dedup), codexEvalAgentRunner installs the skill pack + spawns it, and — the key finding — Codex has NO discrete skill-selection event, so codexSkillFired detects a trigger by the model READING skills//SKILL.md via a command_execution (pin codex 0.139.0; 0.141 regressed the keyless mock). The {evalDriver} dispatch is wired + fake-tested (audit --harness=codex routes through it via scan-behavioral's HarnessProbe); only a live native eval run remains (gated on Codex quota). ALSO the eval LOCK seam (src/eval-lock.ts): runEvalWith + measureTriggerRateWith wrap their run in withEvalLock —--checkSHORT-CIRCUITS before the model loop (replay the committed report → the script's own assertRates/assertSignificant re-judge it; NO model call in CI),--updaterecords the lock + prints the per-number delta; the inputs-hash is built from each seam's model-affecting inputs (evalArmsInputs / the stubbed trigger surface) and the lock engages only when the spec has aname. The empirical half of testing your harness (generalizes bench/)src/eval.test.ts— Eval aggregation/formatting + variance + usage/cache test suite (node:test)src/eval-cache.ts— Eval record/replay cache — the LOCAL-SPEED half of eval staleness (gitignored, throwaway; the COMMITTED CI half is src/eval-lock.ts — keep them distinct). cacheKey hashes the model-affecting inputs (task, resolved files+settings, model, tools-as-a-SET, per-run env, pluginDirHash, harnessVersion via the per-adapter versionKey, trialIndex) but NOT measure, so re-scoring replays for free; snapshotDir/restoreDir round-trip the post-run filesystem so ctx.file()/ctx.sh() stay sound on replay; canonical (exported, reused by eval-lock) sorts object keys for a stable key. Invalidation hardened: hashDir folds a native --plugin-dir's CONTENTS (sorted path:contentHash, not the path) into the key so editing a skill invalidates; the tool list is sorted (logical set); a salted CACHE_FORMAT_VERSION makes a format change unreachable (no read-time gate); floating-alias model drift is warned (resolve to a dated id for sound replay)src/eval-cache.test.ts— Eval-cache test suite (node:test): key stability/sensitivity, record round-trip + malformed-record tolerance, filesystem snapshot/restoresrc/eval-lock.ts— Eval LOCK — the CI STALENESS GATE for evals run on a subscription (distinct from the local eval CACHE: a COMMITTED integrity stamp, not a gitignored speed cache — the founder's 'it's more of an integrity hash' made real; the snapshot/lockfile pattern, sibling of core/integrity.ts + core/sidecar.ts).vigiles eval --update(local, on the sub) records each NAMED eval's report to a committed .vigiles/eval-locks/.lock.json;--check(CI) recomputes evalInputsHash over the MODEL-AFFECTING inputs (model + evalApiVersion + the seam's canonical inputs; reuses eval-cache's canonical) and fails STALE on a mismatch WITHOUT a model call (decideLock → run | replay | stale). The CLEAN SPLIT that makes replay sound + measure re-scorable: the lock stores only the model's OBSERVED BEHAVIOR (report), and the script's own assertions re-run live against the replayed report — so an INPUT change is stale but a THRESHOLD-only edit is a valid replay (no model). HONEST SCOPE (no fiction — the nightly-run idea is DEAD, nobody runs it): the lock verifies 'committed results match current INPUTS', NOT 'reflect current model behavior'; model/harness drift is caught when YOU re-run --update. The harness version is PROVENANCE, NOT a hash input (CI's claude is pinned ≠ a dev's local claude → hashing it would false-trip --check; keeping it out keeps --check binary-free). diffReportNumbers prints the per-number delta at --update for the human's git-diff review; anyLocksCommitted makes --check a green NO-OP until the first lock is committed (smooth adoption). EvalLockOptions (mode/dir/evalApiVersion) is additive on EvalSpec/TriggerRateSpec; lockModeFromEnv reads VIGILES_EVAL_LOCK (the CLI sets it). ALSO the AGENT-AWARENESS nudge: isEvalInputFile (SKILL.md / .eval.) + evalLockNudge return a NON-BLOCKING reminder to re-run --update when an eval input is edited AND a lock exists (self-gated — silent until you've committed one), surfaced by the PostToolUse hook-runtime eval-lock-nudge entry (hooks/eval-lock-nudge.sh, wired in the plugin) — the harness delivers awareness via a hook + the test-harness skill, NEVER by editing the user's CLAUDE.md (BOTH CC and Codex inject additionalContext on PostToolUse — confirmed + encoded in HookProtocol.injectableEvents)src/eval-lock.test.ts— Eval-lock unit suite (vitest): evalInputsHash stable/order-independent + sensitive to every model-affecting field, the harness version is NOT in the hash, lockSlug fs-safe, buildLock/writeLock/readLock round-trip + corrupt/bad-version THROWS (not a silent 'no lock'), decideLock run/replay/stale, diffReportNumbers walks numeric leaves across report shapes, formatLockUpdate new/unchanged/moved, the env readerssrc/eval-lock-wiring.test.ts— Eval-lock WIRING suite (vitest): the lock end-to-end through runEvalWith + measureTriggerRateWith with an INJECTED runner (no model) — off drives the runner, update writes a committed lock, check REPLAYS it with the runner NEVER called (the CI-binary-free promise), a missing/stale-input lock fails, a measure-only change still replays, an unnamed eval is a loud skip; trigger-rate hashes the STUBBED trigger surface (a description edit is stale, a body-only edit is not)src/stats.ts— Significance testing for eval A/B arms: Welch's t-test over the per-arm summary stats (mean/se/n, no raw rows) → two-sided p-value + verdict (Numerical-Recipes incomplete beta); compareArms computes the noise floor instead of the assertImproves({by}) hand-fed gap — behind assertSignificant/significantlyBeatssrc/stats.test.ts— Stats test suite (node:test): incomplete-beta vs known closed forms, p-values vs t-table critical values, Welch significant/noise/deterministic casessrc/plugin-loader.ts— Plugin/repo harness loader (harness-agnostic, composition root): loadPlugin(path, layout) + resolveHarness(opts, layout) — layout REQUIRED, injected via the PluginLayout port (src/core/layout.ts), zero adapter imports. Reads real hooks (inline plugin.json, a hooks string path, the hooks/hooks.json convention e.g. obra/superpowers, .claude/settings.json, or a TOML config.toml [hooks]) with the plugin-root token resolved, the instruction file + skills + agents + commands materialized, and a format-aware manifest read (safeReadManifest: JSON or TOML, detects Codex's [mcp_servers]); .warnings flags surfaces the deterministic tier can't drive (subagents/commands/MCP, empty machine, dangling intra-plugin refs). The dangling-ref detector (danglingRefs/missingRefsIn) only flags PLUGIN-rooted paths — isPluginRooted skips a ref nested under a literal dir (.claude/hooks/…) or a project/home var ($CLAUDE_PROJECT_DIR/$HOME), so a runtime/project path in a script isn't a false 'missing' (caught on gmickel/flow-next), while a bare or ${PLUGIN_ROOT}/ ref is still checked. Every adapter (CC wrapper, codex, opencode) delegates here with its own layout — no adapter imports a siblingsrc/adapters/claude-code/plugin-loader.ts— Thin Claude Code wrapper over the harness-agnostic src/plugin-loader.ts: loadPlugin(path, layout = claudeCodeLayout) + resolveHarness(opts, layout = claudeCodeLayout) supply the CC layout as the default, preserving loadPlugin(dir) ergonomics; loadPlugin is exposed on the vigiles/claude-code public surface (the standalone vigiles/plugin-loader subpath was removed in the public-surface trim)src/adapters/claude-code/plugin-loader.test.ts— Plugin-loader test suite (node:test): CLAUDE_PLUGIN_ROOT resolution, CLAUDE.md/skills/agents/commands materialization, surface + empty-machine + MCP warnings, settings merge, in-repo dogfoodsrc/adapters/claude-code/vendor.test.ts— Conformance suite over REAL vendored plugins under test/dogfood/: model-free, in-gate, table-driven loadPlugin invariants (loads a surface, ${CLAUDE_PLUGIN_ROOT} resolves, skills materialize, surface + dangling-ref warnings accurate) — grounded in reality (pinned by SHA, offline, no API key), the shape that caught the superpowers partial-vendorsrc/adapters/claude-code/skills-dogfood.test.ts— Dogfood conformance over vigiles's OWN plugin (.claude-plugin/): loadPlugin materializes every skills//SKILL.md and each has a name + non-empty description (the trigger surface); plus a precise plugin-hooks check — every${CLAUDE_PLUGIN_ROOT}/...script in plugin.json must exist at the plugin root. The free, model-free floor under the paid trigger-rate eval (a skill that won't load can never fire). Caught two real bugs: generate-logo had no frontmatter name, and thehooks/*.shlived under .claude-plugin/ instead of the plugin-root hooks/ (so the installed hooks resolved to nothing). Trigger/precision itself is the eval tier (examples/harness/dogfood/), needs model authsrc/agent-plugins-manifest.test.ts— Agent Plugins conformance dogfood (vitest, offline): vigiles's OWN root plugin.json is held to the agent-plugins.org 1.0.0 schema — $schema pinned to the canonical identifier, the name pattern, the CLOSED top-level + author key sets, repository/homepage as STRINGS (npm's object shape is invalid here) — plus skills sitting at the spec's fixed skills//SKILL.md discovery path, and plugin.json shipping in package.json files[] (an omitted allowlist entry would make the PUBLISHED package non-conformant while the repo looks fine). The load-bearing one is the DRIFT gate: name/version/description/license must match .claude-plugin/plugin.json and marketplace.json's version — two hand-kept manifests drift silently (marketplace.json was a full major behind when this landed), so it's CI, not discipline. The schema RULES are asserted, never fetched, so the gate is offline and bumping the spec version is a deliberate edit heresrc/harness-assert.ts— Runner-agnostic harness helpers: withHarness (auto-cleanup), throwingassert*helpers incl. assertHookBlocked/assertHookAllowed (over a runHook RESULT) + assertHookDenies/assertHookAllows (over a COMPILED hook program directly, via runHookProgram — in-process, no subprocess, the cheapest tier for a vigiles/hook gate) and assertAgentOk/Err/Result (test a subagent's railway outcome via parseAgentResult — the testing-framework payoff of the result contract), and vigilesMatchers for vitest/jest expect.extend (toHaveCreated/toBlock/toBeatBaseline + the Phase-1 check veneer toPass(check)/toPassAll([checks]) — ONE matcher for the whole check vocabulary, carrying each check's own failure message)src/harness-assert.test.ts— Harness-assert test suite (node:test): eval delta helpers + matcher pass/fail logicsrc/check.ts— The declarative check vocabulary (exposed on thevigilesroot surface — exceptjudged, which calls a model and lives onvigiles/evalaspaid_judged; the standalonevigiles/checksubpath was removed in the public-surface trim) (testing-API revamp). A check is DATA not a throwing assert: { kind, eval(target) → {pass, score, message}, toJSON() }, typed over Trace (tool/skill/output/hookFired/received/turns/wrote/didNotWrite/mcp/subagent/judged — wrote/didNotWrite are the symmetric side-effect-boundary write/no-write pair) vs HookRunResult (blocked/allowed) vs usage (cost/latency/tokens). judged() is the model-graded check (LLM rubric via an injectable judge fn — fits the sync eval since judge() blocks via spawnSync); mcp() matches themcp__server__tooltool name. One vocabulary evaluated two ways — strict (assertChecks, throws collecting all failures) + scored (measure, rate ± se across trials) — so a check reads pass/fail on one run AND a rate; serializable so JUnit/baseline/promptfoo-bridge fall out. Harness-AGNOSTIC: every check reads generic Trace fields, never a CC shape, so it evaluates over a Codex-sparse Trace too. Folded into thevigilesroot surface (its hookFired wins over the legacy boolean via an explicit re-export);judgedis the one member that is NOT there, because it calls a model — it lives onvigiles/evalaspaid_judged. Pure + model-free (judged calls a model)src/check.test.ts— Check-vocabulary test suite (vitest): each check's pass/fail + 0–1 score + ACTIONABLE failure message (the message is the product, tested as much as the verdict) + toJSON round-trip; evalChecks order; over fake Trace + HookRunResult, no modelsrc/arg-match.ts— Shared ArgMatcher over a tool call's input (dot-path keys; RegExp = pattern, primitive = exact; AND across keys) + matchesArgs/getPath/stringifyValue/describeArgs/serializeArgs. One matching semantics for both the check vocabulary (toolWith/notTool in src/check.ts) and the tool-interception seam (src/tool-intercept.ts), so 'did the agent call this tool with these args?' means the same whether you ASSERT on a captured call or INTERCEPT one. Pure + serializablesrc/tool-intercept.ts— Tool interception — the eval-tier half of the tool-call spy. A ToolIntercept (tool + optionalwhenArgMatcher + denyReason block message) is intercepted by an auto-wired PreToolUse hook (exit-2 deny) so a real-model run that DECIDES to hit a paid API / git push / spawn a paid subagent is safe + side-effect-free — the call is intercepted (prevented), NOT executed — while the tool_use (with args) still lands in the Trace for toolWith/notTool. INTERCEPT-AND-PREVENT, not a faithful mock: CC surfaces the deny as a BLOCK not a success, so it's for asserting the ATTEMPT (safety/approval-gate), not stubbing a tool to continue a flow. decideIntercept (pure, first-match-wins) + buildInterceptSettings (PreToolUse fragment over the union of intercepted tool names) + serializeIntercepts/parseIntercepts (VIGILES_INTERCEPT_TOOLS env round-trip, RegExp-safe). Thevigiles hook-runtime intercept-toolCLI subcommand runs the decision. Wired onto the eval spec via EvalArm.interceptTools/MeasureSpec.interceptTools (auto-merged into arm settings, cache keyed on env).src/tool-intercept.test.ts— Tool-intercept test suite (vitest): decideIntercept (unconditional/when-scoped/first-match-wins/default reason), interceptHookDecision (PreToolUse event parse + malformed tolerance), buildInterceptSettings (matcher = escaped union of tool names), serializeIntercepts/parseIntercepts round-trip incl. RegExp survival + junk tolerance. Pure, model-freesrc/judge.ts— Thin LLM-as-judge for the eval tier: judge() grades an output against a rubric with a model (synchronous, for use inside measure); parseJudgeOutput is the pure, testable verdict parsersrc/judge.test.ts— Judge verdict-parsing test suite (node:test): result-field unwrap, prose-wrapped JSON, threshold, clamping, unparseable fallbacksrc/vitest.mts— Opt-in vitest integration entry (ESM, since vitest is ESM-only): registers vigilesMatchers + augments @vitest/expect Matchers so toHaveCreated/toBeatBaseline type-check; vitest is an optional peer depsrc/jest.ts— Opt-in jest integration entry (CJS): registers vigilesMatchers + augments @jest/expect Matchers; jest is an optional peer deptest/types/smoke.vitest.ts— Type-level constraint:vigiles/vitestmakes the matchers type-check on vitest's expect (tsc --noEmit via npm run test:types)test/types/smoke.jest.ts— Type-level constraint:vigiles/jestmakes the matchers type-check on jest's expecttest/runners/matchers.vitest.mjs— Cross-runner constraint: vigilesMatchers + helpers register and pass under vitest (proves runner-agnostic;src/*.test.tsexcluded via vitest.config.mjs)test/runners/matchers.jest.cjs— Cross-runner constraint: the same vigilesMatchers register and pass under jest (CommonJS dist required natively; scoped via jest.config.cjs)src/core/test-utils.ts— Shared test utilities: makeTmpDir, makeSpec, cleanupTmpDir, initGitReposrc/core/types.ts— Shared types: RulesConfig, VigilesConfig, FreshnessMode, CoverageThresholdssrc/core/rule-meta.ts— The RULE METADATA registry (the ESLint-metapattern adapted to vigiles's shared-detector architecture): ONE Record<RuleName, RuleMeta> keyed by rule name — NOT scatteredexport const meta, because one detector feeds many rules (one-detector-no-drift). Each rule declares its DECIDABILITY BUCKET (structural-closed=type-preventable / external-decidable=needs-the-world-but-error-capable / heuristic-behavioral=warn-or-measure-only — the CEILING), surface, defaultSeverity (where it sits today; a gap to the ceiling = a promotion candidate), detector (the shared pure fn), and optional upstreamPrevention (the Stage-1/2 construct that makes the SAME defect impossible for spec-authors). Record<RuleName,RuleMeta> makes completeness a tsc error. The answer to 'why is this a warning / can't this be a type?' declared at the source. See the lint-rule-calibration rulesrc/core/rule-meta.test.ts— Rule-meta coverage suite (vitest): the registry's keys EXACTLY match docs/rules/*.md (every rule has a meta AND a doc — the set is one thing), each defaultSeverity matches the real exported DEFAULT_RULES (no drift), every meta has a non-empty surface + known bucket + summary + detector, and a heuristic-behavioral rule never defaults to error (would cry wolf)src/core/proofs.ts— Deterministic proof algorithms (monotonicity lattice, NCD, Bloom filter, Merkle DAG, fixed-point, property testing)src/core/evolve.ts— Evolution engine: mutation operators, fitness function, proof-gated selectionsrc/core/proofs.test.ts— Proof system + evolution engine tests (node:test)CLAUDE.md.spec.ts— This file — the source of truth for CLAUDE.mdexamples/SKILL.md.spec.ts— Example SKILL.md specexamples/railway/ship-pr.md.spec.ts— Dogfood: a railway() over five flat agent() workers (planner→implementer→reviewer, bounded fixer recovery, reporter error track), each with a result() contract. Compiles via the realvigiles compileto ship-pr.md (orchestrator command) + one .md per agent (with vigiles:ok/err Output contracts); every delegate() target is resolved against the sibling agent specs at compile timeexamples/harness/hook-unit.harness.mjs— Canonical hook unit-tier example (runHook): test a hook's logic in isolation with no claude CLI — the cheap base of the pyramid; runs in CI for freeexamples/harness/policy-gate.harness.mjs— Canonical deterministic harness test (runHarnessTest): a PreToolUse Bash policy gate (block-no-verify shape) + a SessionStart setup hook (obra/superpowers shape)examples/harness/refs-nudge.harness.mjs— Real-claude dogfood of the refs-hook (deterministic tier, no API key): the mock model Writes a CLAUDE.md naming an unmarked linter rule, the PostToolUse refs-hook fires, and requestContains asserts the non-blocking nudge reached the model's context — proves the hook fires in an actual session, not just the runHook unit tierexamples/harness/skill-outcome.eval.mjs— Canonical skill-outcome eval (runEval): does a skill change the agent's output? — the question you ask of any SKILL.mdexamples/harness/skill-trigger-rate.eval.mjs— Canonical trigger-rate eval (measureTriggerRate): does a skill's description actually FIRE across varied prompts? — installs a real pinned plugin via pluginDir and reuses the skillResolved predicateexamples/harness/skill-selection-collision.eval.mjs— Canonical selection-collision eval (measureSelectionMatrix + assertNoCollision, on vigiles/claude-code): installs a multi-skill plugin, runs each skill's own prompts against the WHOLE set, and gates the N×N matrix so a sibling hijacking a skill's prompt fails the build. Zero-setup — prompts auto-derived from descriptions. CC-only (reads which skill the selector chose). Real model → real costexamples/harness/dogfood/test-harness.trigger.eval.mjs— Dogfood trigger eval — vigiles's OWN test-harness skill: fires on harness-testing requests (recall) AND stays quiet on unrelated coding (precision via irrelevantPrompts), gated by assertTriggerRate({ min, maxFalsePositive }). One of 4 model-invocable shipped skills (test-harness, strengthen, edit-spec, debug-my-harness — each with a trigger eval here); the user-invoked ones (adopt-spec, linter-docs) are covered by the free load gate in src/adapters/claude-code/skills-dogfood.test.ts. Write-don't-run without model authexamples/harness/dogfood/strengthen.trigger.eval.mjs— Dogfood trigger eval — vigiles's OWN strengthen skill (made model-invocable so the agent reaches for guidance()→enforce() upgrades on its own): fires on strengthen/harden-my-rules requests (recall) AND stays quiet on ordinary linting/coding (precision). Gated by assertTriggerRate({ min, maxFalsePositive }); write-don't-run without model authexamples/harness/dogfood/edit-spec.trigger.eval.mjs— Dogfood trigger eval — vigiles's OWN edit-spec skill (made model-invocable; the skill the pre-edit hook points at when a direct CLAUDE.md edit is blocked): fires on 'change my CLAUDE.md / add a rule' requests (recall) AND stays quiet on ordinary coding (precision). Gated by assertTriggerRate({ min, maxFalsePositive }); write-don't-run without model authexamples/harness/dogfood/debug-my-harness.trigger.eval.mjs— Dogfood trigger eval — vigiles's OWN debug-my-harness skill: fires on 'why did my skill stop firing / why didn't my hook block / debug my harness' requests (recall) AND stays quiet on ordinary coding (precision). Gated by assertTriggerRate({ min, maxFalsePositive }); write-don't-run without model authexamples/harness/checks-dogfood.harness.mjs— Dogfood of the new check vocabulary on the DETERMINISTIC tiers (no key, RUNS in CI): assertChecks over a real runHook (blocked/allowed) + a real runHarness mock run (turns/output). Exercises the revamped strict-check path end-to-end, not just unit-tested with fakesexamples/harness/dogfood/skill-quality.eval.mjs— Dogfood of the SCORED check evaluator on vigiles's OWN strengthen skill: measure({ checks: [skill, judged, cost] }) + assertRates — does the skill FIRE, is its suggestion GOOD (model-graded judged), and is the run CHEAP. The promptfoo-class scored path dogfooded; write-don't-run without model authexamples/harness/dogfood/skill-firing-cheap.eval.mjs— Dogfood of the CHEAP firing-only path: measure({ checks: [skill, latency], stubSkillBodies: true }) on vigiles's OWN strengthen skill — the sibling of skill-quality.eval.mjs that stubs each skill BODY (firing is a frontmatter property decided before the body loads) so the run stops AT selection. Measured ~18x cheaper for the SAME firing verdict (rate 1 either way; ~49s stubbed vs ~889s full-body). Stub for experimental_skill()/firing checks, NOT judged/quality (the body is gone). Write-don't-run without model authexamples/harness/subagent.eval.mjs— Dogfood of the subagent nested-trace check on a REAL subagent — the vendored oh-my-claudecode code-reviewer agent (vigiles ships no subagents). measure drives a task that dispatches it via Task; subagent('code-reviewer', [tool('Read')]) asserts what the SUBAGENT did (recovered via parent_tool_use_id). Write-don't-run without model authexamples/harness/dogfood/generate-logo.trigger.eval.mjs— Dogfood trigger eval — vigiles's OWN generate-logo skill, an INTERNAL contributor-only skill (lives under .claude/skills/, this repo's own harness, NOT shipped to consumers) loaded via skillsDir: a NARROW skill, so the risk is recall collapse not over-firing; checks both recall + precision against logo vs nearby-asset promptsexamples/harness/skill-compression.eval.mjs— Worked eval verifying a token-compression claim (e.g. Caveman telegraphic style): two arms (verbose/caveman) over one task, measure outputTokens (the optimization target) AND correct (the fact that must survive) — proves the saving is real AND didn't regress behaviour. The on-brand framing for the compression-tool cluster: vigiles measures the claim + the blast radius, it doesn't compressexamples/harness/plugin-cohesion.harness.mjs— Canonical cohesion test (runHarnessTest with plugin:): load a whole plugin (.claude-plugin/plugin.json + CLAUDE.md) and assert multiple hooks fire togetherexamples/harness/railway-result.harness.mjs— Worked example of the railway/Result payoff — asserting a subagent's TYPED OUTCOME deterministically (the deterministic-assert-replaces-an-LLM-judge thesis made runnable). A result() contract's vigiles:ok/err block is parsed+validated by assertAgentOk/assertAgentErr/assertAgentResult: Part A runs anywhere (pure text→Result, the ok/err/malformed tracks + a rich predicate), Part B runs the SAME assert over a real runHarness turn driven by a scripted mock model (needs the claude binary, NO key). Surfaces the underweighted result()/railway() primitives for testabilityexamples/harness/effect-boundary.harness.mjs— Worked example of the SIDE-EFFECT BOUNDARY as a deterministic test (rung 2 of the typed-contracts ladder): a unit that declares it writes only out.txt and never pushes is ASSERTED to stay inside that surface via the check vocabulary — wrote()/didNotWrite() (the symmetric write/no-write pair) + notTool('Bash', {command:/git push/}) — no model judge, no key. Part A asserts over a constructed Trace (always runs) incl. that the boundary CATCHES an escape; Part B runs the same checks over a real scripted-mock runHarness turn (needs the claude binary, NO key). The side-effect sibling of railway-result.harness.mjs (which asserts the Result OUTCOME)bench/evals/refs-hook.eval.mjs— Worked eval reproducing benchmark #4 (forcing symbol marks → verifiable references?) as a runEval library callsrc/core/hook-providers.ts— Hook CONTEXT PROVIDERS — lets a compiled gate decide on EXTERNAL STATE vianeeds/e.ctxwithout breaking capability=API-surface: the pure decide() never fetches; the TRUSTED runtime gathers a DECLARED set of read-only facts and hands them in (the Cedar/OPA/Gatekeeper pattern). Closed built-in registry BUILTIN_PROVIDERS (git.branch/git.isDirty/git.root/cwd/os.platform/env.isCI — small by design, justified by a 20+ OSS survey; each gather() TOTAL, defaults on failure, never throws; env.isCI ADOPTS the ci-info lib via ProviderIO.isCI so core stays dep-free, git facts stay read-only shell, platform is process.platform — the prefer-existing-solutions split). THE LIGHTWEIGHT OPT-OUT for the long tail (no whole-provider ceremony): an inline command in needs via provide(name,cmd) (read-only — compile rejects via unsafeInlineProviders if not) or dangerously(name,cmd) (the loud, greppable escape, the dangerouslySetInnerHTML/unsafe convention) → stdout becomes e.ctx[name]; runtime runs it so decide stays pure. NeedSpec = ProviderName | InlineProvider. ProviderName/ProviderResults/HookCtx (the typed ctx — undeclared access is a tsc error; key-remap mapped type over NeedSpec); gatherContext(needs, ProviderIO{exec,cwd,platform}) parameterized over an injected exec (testable with a fake, core depends on no child_process); unknownProviders() flags a typo'd built-in (compile rejects). A SOUNDNESS test asserts every built-inrunis read-only via bash-effects (a provider OBSERVES, never mutates). The gate generics + ctx-threading live in hook-program.ts (decode fns generic over N; AnyHook uses an erased any-needs since decide is contravariant in N); the real execSync IO is injected by cli.ts gatherHookContext. v2 (SHIPPED) = user-declared REGISTERED providers: defineProvider({name,run}) in .vigiles/providers/, referenced by provider('name'); RegisteredProvider/RegisteredRef/ProviderRegistry; compile discovers+validates them (unsafeProvider read-only check) + resolves every provider() ref (dangling → compile error), runtime loads them via loadProviderRegistry. NeedSpec = ProviderName | InlineProvider | RegisteredRef. Deferred hardening: per-provider tamper stampsrc/core/hook-providers.test.ts— Hook context-provider suite (vitest): gatherContext runs ONLY declared providers + parses each fact (branch trim, porcelain→dirty bool, cwd ambient), a clean tree isn't dirty, gathering NEVER throws (defaults on a non-repo), unknownProviders flags a typo, and the load-bearing SOUNDNESS check — every built-in provider command is provably read-only (isReadOnlyBash). Pure: an injected fake exec, no real gitdocs/compiled-hooks.md— Public guide to COMPILED HOOKS: author a hook as a pure typed(event)=>Decisionagainst the closedvigiles/hookvocabulary and compile it. Covers the bug-class table (false confidence / matcher bypass / capability creep / category mistake — each unrepresentable by construction), the roles (tool-gate/prompt-gate/stop-gate/inject/react), observe mode (the shadow/rolloutmode:'observe'), the vocabulary (runs/touches/pipesToShell/under, e.prompt/e.stopHookActive/e.response, allow/deny/ask), compile + hook-runtime run-program + the stamp, the OSS dogfood (2/7→7/7), and the honest #34692 delivery-floor caveat. Sibling of the verify (testing) + lint guidessrc/eval-baseline.ts— Eval regression gating (Phase C): record a run's EvalReports to a committed .vigiles/eval-baseline.json, then flag any arm×metric that moved SIGNIFICANTLY in the bad direction vs that baseline — reusing welchTTest from stats.ts (current vs baseline), so sampling noise doesn't trip the gate. Pure diff/serialize/JUnit (toBaselineFile/parseBaselineFile/diffReports/formatBaselineDiff/diffToJUnit) + the readBaseline/writeBaseline fs helpers; behind assertNoRegressionsrc/eval-baseline.test.ts— Eval-baseline test suite (node:test): baseline round-trip + version/shape validation, regression vs improvement vs unchanged classification, lowerIsBetter direction flip, skip of absent arms/metrics/reports, console + JUnit formatting (counts, failure element, xml escaping), readBaseline null + writeBaseline round-tripdocs/harness-testing.md— Harness-testing guide (harness-AGNOSTIC core): the levels (refs/unit/integration/eval), select-by-import + { adapter } default-Claude-Code, the Trace model + predicates/assertions, runHook/runHarnessTest/runEval shown agnostically, significance + regression gating, the runner-agnostic vitest/jest seam (its own section, tested in CI), CLI fallback, per-level CI, two-layers, coverage, promptfoo. Links the per-harness guidesdocs/harness-testing-claude-code.md— Harness-testing — Claude Code specifics: the oh-my-claudecode Tier 0-3 walkthrough, ${CLAUDE_PLUGIN_ROOT}/hooks.json/plugin/pluginDir/Skill-tool, scriptModel + the Anthropic-Messages mock, modelRequests (did the injected context land?), the reliable-events list, dogfooding pinned plugins, and the safe-by-default bubblewrap sandbox + egressdocs/harness-testing-codex.md— Harness-testing — Codex specifics: runHarnessTest(spec, { adapter: codexAdapter }) against real codex exec (keyless, Responses mock via startCodexMock); the what-maps (AGENTS.md instructions, minimal SKILL.md) / what-doesn't (subagents — deliberate non-goal) table; honest that runEval-for-Codex is a documented follow-on, not shipped. Aligned with docs/harnesses.md footnotesdocs/harnesses.md— User-facing harness-adapter guide: which harness vigiles targets (Claude Code now, Codex likely next) and how a consumer picks one — by importing vigiles/claude-code beside the harness-agnostic vigiles/eval` cores, not a config key; the CLI auto-detects. Covers the two coupling axes (format/dialect vs runtime/transport) and how the boundary is kept honest (eslint-plugin-boundaries + enforce())docs/adapter-api.md— vigiles/adapter API reference: every export of the authoring kit — the five port interfaces field-by-field (with Claude Code AND Codex example values), HarnessAdapter + the detect specificity contract, the conformance functions (checkAdapterConformance/assertAdapterConformance/assertAdapterLoadsHooks — signatures + what each checks/throws), the registry API (ADAPTERS/detectAdapter/detectAdapterResult/resolveAdapter/getAdapter), the programmatic apply path (compileAgent/loadPlugin with your ports), the third-party-adapter status (programmatic supported now; CLI auto-detect of external packages is planned), and the stability/semver notedocs/authoring-an-adapter.md— Third-party adapter authoring guide: the documented small lib (vigiles/adapter) for teaching vigiles a new harness — the five ports to implement, a worked myHarnessAdapter skeleton, validating with assertAdapterConformance, wiring it (library by import, CLI by registry), and what's still behaviour-not-descriptor (renderers/decision-decode/mock HTTP server). Linked from the root README (custom adapters welcome)docs/testing-matrix.md— Testing matrix: every harness-testing use case mapped to its test tier (unit / cross-runner / type / integration-CI) and file, plus why the CLI examples are .mjs and the API is TypeScriptdocs/agent-setup.md— Agent setup & workflows — one guide: whatinitdoes + auto-detection, per-agent recipes (Claude Code / Codex / multi-agent / Cursor), non-interactive setup + fallback hooks, the recommended agent prompt, and CI (absorbs the former agent-workflows.md)docs/spec-format.md— Spec format reference (target, sections, rules)docs/railway-subagents.md— Public guide to railway-oriented subagents: the typed Result<ok,err> outcome contract (result() on an agent()), what it compiles to (vigiles:ok/err blocks), composing flat workers (railway()/delegate()/recover), and asserting the outcome deterministically (assertAgentOk/Err/Result — no model judge). States the scope decision: railway is a SUBAGENT primitive (context boundary = parse-point), NOT skills; context:fork is the bridge.docs/linter-support.md— Linter support details (11 catalogs + generate-types/generate-schema)docs/comparison.md— Before/after tables (Claude Code, Codex), determinism breakdown, flow diagramdocs/rules/require-instructions-spec.md— Rule doc: require-instructions-spec — require a .spec.ts behind each CLAUDE.md/AGENTS.md (NARROW: only a .spec.ts satisfies it, not inline/frontmatter). workflow-group opt-in (--strict); satisfied by construction becauseinitauto-adopts every instruction file. Renamed+narrowed from the old require-specdocs/rules/require-skill-spec.md— Rule doc: require-skill-spec — the consistent require--spec parallel (default OFF). Skills are legitimately hand-written, so requiring a .spec.ts per SKILL.md isn't the default; the coverage that matters is untested-skill (every skill ships a test/eval). Set it explicitly to spec-manage every skilldocs/rules/integrity.md— Rule doc: integrity check (SHA-256 hash verification for compiled markdown)docs/rules/coverage.md— Rule doc: spec coverage thresholds (scripts, linter rules)docs/markdown-mode.md— Markdown mode: the single no-spec on-ramp doc — inline<!-- vigiles:enforce -->comments (the live zero-TS floor; the former inline-mode.md reference is folded in here). Frontmatter mode is parked/disabled (kept in a comment).skills/linter-docs/eslint.md— ESLint reference: plugin table, AST selectors, type-aware rules, auto-fix, edge casesskills/linter-docs/rubocop.md— RuboCop reference: gem table, node pattern DSL, auto-correct, custom copsskills/linter-docs/pylint.md— Pylint reference: plugin table, astroid AST, type inference, custom checkersskills/linter-docs/ruff.md— Ruff reference: 800+ reimplemented rules, rule selection, auto-fix, pyproject.toml configskills/linter-docs/stylelint.md— Stylelint reference: plugin table, PostCSS AST, custom rules, CSS-in-JS, SCSSskills/strengthen/SKILL.md— Strengthen skill (MODEL-INVOCABLE): upgrade guidance() → enforce() by finding existing linter rules. Sharpened description + a dogfood trigger eval so the agent reaches for it on its own without over-firing on general lintingskills/edit-spec/SKILL.md— Edit-spec skill (MODEL-INVOCABLE): change a compiled CLAUDE.md/AGENTS.md by editing its .spec.ts (add/modify/remove a rule, section, command, key file) — the skill the pre-edit hook points at when a direct edit is blocked. Absorbs the former generate-rule skill (rule classification: enforce/check/guidance). Dogfood trigger eval gates recall + precisionskills/debug-my-harness/SKILL.md— Debug-my-harness skill (MODEL-INVOCABLE): diagnose why the harness misbehaved by reading the local flight-recorder ledger (.vigiles/runs.jsonl) — skill fires/collisions, hook decisions (blocked vs wrongly-allowed), subagent contract violations, trigger-rate drift — and recommend a fix (promote-prose / differentiate descriptions), handing off to strengthen/edit-spec/test-harness. The agent-readable payoff of the observe layer; dogfood trigger eval gates recall + precisionskills/linter-docs/SKILL.md— Linter-docs reference skill (user-invoked): a SKILL.md indexing the per-linter reference files (eslint/ruff/pylint/rubocop/stylelint/clippy .md) the strengthen + edit-spec skills read when matching a guidance rule to a real linter rule. Fixes the former packaging smell (a skills/ dir with no SKILL.md)
Commands
npm run build— Compile TypeScript to dist/npm test— Build and run all testsnpm run fmt— Format with prettiernpm run fmt:check— Check formattingnpm run lint— Run ESLint on src/npm run test:harness— Build + run the deterministic harness tests (*.harness.mjs) on the in-repo CLI — needs the claude CLI, no API keynpm run test:eval— Build + run the real-model harness evals (*.eval.mjs) on the in-repo CLI — needs claude + model authnpm run test:vitest— Build + run the cross-runner matcher constraints under vitest (test/runners/*.vitest.mjs)npm run test:jest— Build + run the cross-runner matcher constraints under jest (test/runners/*.jest.cjs)npm run test:types— Build + type-check the vitest/jest matcher augmentation (tsc --noEmit on test/types/)
Rules
No Non Null Assertion
Enforced by: @typescript-eslint/no-non-null-assertion
Why: Use proper narrowing instead of ! assertions.
No Floating Promises
Enforced by: @typescript-eslint/no-floating-promises
Why: Always await or return promises. Unhandled rejections crash the process.
Cognitive Complexity
Enforced by: sonarjs/cognitive-complexity
Why: Keep functions under 15 cognitive complexity. Split complex logic into helpers.
Core Not Adapter
Enforced by: boundaries/dependencies
Why: Hexagonal boundary: the reference-verification domain (verify-core) must not import the Claude Code harness/transport adapter (cc-harness). The core stays harness-agnostic so a future vigiles/ (e.g. Codex) can sit beside the Claude Code one. Dogfoods enforce() over eslint-plugin-boundaries.
Experimental Names Carry The Warning
Guidance only — If we EXPORT it and it is not stable, its NAME starts with experimental_. Declare it once with the @experimental TSDoc tag; the ESLint rule local/experimental-name (eslint-rules/experimental-name.mjs) fails if a tagged, exported declaration lacks the prefix, so the tag and the name cannot drift. It checks EVERY exported declaration, public or not — the earlier version exempted internals, which contradicted its own reason for existing (an internal reader is still a reader who trusts the name). That exemption was also the only thing making the check cross-file; without it the check is jsdoc + name, both in one file, which is why it is a lint rule now and not a script. @internal is a DIFFERENT question and stays a script (scripts/check-internal-tag.mjs, npm run internal:check): it does NOT mean unstable, it answers whether something is part of the API at all, and an @internal symbol appearing in api-surface/*.api.md is a contradiction — deciding that needs the export graph, which no single file knows. Types are out of scope (the convention is about call sites; a type annotation is not one). Opt out on the declaration with vigiles:experimental-name-ok <reason> — the reason is required and must be on the same line. WHY the name and not just a tag: a tag is invisible where it matters, while a name is in every call site, diff, review and autocomplete. Learned the hard way — skill() shipped under a stable name while docs/skills.md opened with "skill() is experimental", and nothing caught it because the convention was a habit rather than a check. There is no vigiles/experimental subpath any more either (deleted 2026-08-21): it marked the import line, which is out of view by the time anyone reads the call. Renaming an experimental_ symbol is NOT a breaking change and gets no major bump; that is what the prefix promises. COROLLARY — ONE EXPERIMENTAL ROOT PER FEATURE (2026-08-21): the prefix says whether THIS name is stable and cannot say whether the name a STABLE symbol depends on is stable. That gap shipped — railway(), delegate() and result() carried stable names while being meaningless without experimental_agent(), and local/experimental-name cannot see it (tag-vs-name on ONE declaration, not the dependency between two). So the vocabulary of an experimental feature hangs off ONE marked root: experimental_agent.result() / .railway() / .delegate() / .pipe() / .pipeStep() / .needs() / .start() / .andThen(), the same chokepoint as experimental_skill.input(). It buys three things, and only the first is unreachable by the prefix alone: no stable name is left to rest on an unstable one; collision-prone words (result, needs, start, pipe) stop owning package-level names; and the warning SURVIVES DESTRUCTURING (const { result } = experimental_agent still names the root at the binding site) — unlike a namespace OBJECT or an import subpath, which is why vigiles/experimental was retired rather than reused. Mechanically Object.assign(fn, {…}), NOT a TS namespace: @typescript-eslint/no-namespace is an error here, and a namespace merges only with function declarations while experimental_skill is a const. Types stay top-level for the same reason the prefix rule excludes them. See STABILITY.md.
Never Skip Tests
Guidance only — All tests must pass. If a test requires a CLI tool (pylint, rubocop, ruff, clippy), install the tool, don't skip the test.
No Silent Skips
Guidance only — A skip must be LOUD, never a silent green. When a test/script genuinely can't run (e.g. the deterministic tier with no claude), it must report ⊘ SKIPPED (its own status, tallied separately as 'N skipped'), not exit 0 and masquerade as a ✓ passed. vigiles test/vigiles eval classify each script pass/skip/fail by exit code (0 / SKIP_EXIT_CODE 77 / else); scripts call skip(reason) (vigiles) to emit it. By default a skip does NOT fail the run — tiers need different capabilities (claude, bubblewrap, model auth) that aren't all present everywhere, so failing-by-default would make the command red in any partial environment (this repo's own non-privileged harness job legitimately skips the egress tier and runs it under e2e). But where you ASSERT the capability is present (a CI job), pass vigiles test --no-skip: a skipped tier is untested surface and FAILS — a green-with-skips is itself a hidden gap. Equally important — DON'T over-skip: there is no blanket claude-gate, so unit-tier runHook tests (which need no model) always run; only the tiers that truly need a capability skip, and they say so. The corollary of never-skip-tests: if you can't make it pass, surface WHY, don't hide it.
Zero Config By Default
Guidance only — vigiles compile should work with just a .spec.ts file. Config exists only for overrides (maxRules, maxTokens).
Dont Reimplement Linters
Guidance only — Architectural linting belongs in ast-grep/Dependency Cruiser/Steiger. Per-file code rules belong in ESLint/Ruff/Clippy. vigiles owns: spec compilation, linter cross-referencing, type generation, stale reference detection, and proof-based spec evolution.
Prefer Existing Solutions
Guidance only — Before building any non-trivial capability — a tool, a check, a runtime, a parser — LOOK FOR EXISTING SOLUTIONS FIRST and PREFER adopting/composing/delegating over reinventing. This is a process step, not a vibe: actually search the landscape (the web, the ecosystem, the stdlib) and write down what you found. The decision order is ADOPT > COMPOSE > BUILD. BUILD is justified ONLY when one of these holds, and you SAY WHICH: (1) no existing solution fits the exact need (after a real look); (2) building DOGFOODS or reinforces the core cross-referencing engine (e.g. self-command-refs reuses the reference-verification engine vigiles already ships, so it's ~120 lines, not a new dependency); (3) every existing option's cost — a heavy dependency, a runtime/env requirement, lock-in, lost narrative control — outweighs a small purpose-built piece. When you DO build, the prior-art comparison IS part of the justification: name the existing solutions and why each was rejected (as the self-command-refs decision did — doctest runs everything/needs a build, doc-generators kill hand-written prose, link-checkers don't cover command names, so a small deterministic cross-ref check that dogfoods the engine won). Never present a home-grown thing as novel without having checked; 'is there an existing solution?' is a question to answer with research, before writing code, not after. This generalizes the project's existing don't-reinvent stance — dont-reimplement-linters (delegate architectural/per-file linting to ast-grep/ESLint/Ruff) and compose-with-sync-tools (compose with Ruler/rulesync, don't absorb their job) — into the default reflex for ALL new work.
Parse Structured Input With A Real Parser
Guidance only — Parse a STRUCTURED language with its REAL parser, never a hand-rolled regex/line-scanner. This repo already lives this everywhere: Bash → mvdan-sh AST (bash-effects.ts), code → @ast-grep/napi (symbols.ts), YAML → js-yaml, TOML → @iarna/toml. Markdown was the LONE holdout — five detectors (adopt/compile/refs/skill-resources/segment) each hand-rolled the SAME inFence = !inFence fence toggle, which is WRONG on nested/unbalanced fences (a 4-backtick block containing a bare ``` line flips the toggle to 'outside' and leaks a ## inside the block out as a real heading — demonstrated to mis-split + drift bytes on round-trip). Fixed by routing ALL FIVE through ONE markdown-it-backed oracle (src/core/markdown.ts fencedLineFlags) — the make-invalid-states-irrepresentable choke-point, so no detector can hand-roll fence state again. THE RULE: block-structure / stateful markdown parsing (fence-awareness, heading boundaries, list/blockquote nesting — anything tracking parser STATE across lines) MUST go through the shared markdown-it helper; a within-line token match (is THIS one line a fence delimiter, does a line contain a link) MAY stay a local regex (it tracks no state, so it can't have the toggle bug). This is a direct instance of prefer-existing-solutions (markdown-it is a small, zero-dep, browser-safe CommonMark parser — ADOPT beat BUILD once the failure was proven to be exactly one construct, fence boundaries) and the same 'delegate parsing to the language's real parser' reflex as dont-reimplement-linters. When you need a NEW piece of markdown structure, extend src/core/markdown.ts (token.map line ranges preserve verbatim slicing, so a faithful round-trip is kept — never AST-reserialize, which reflows whitespace), never a sixth private fence loop.
Dogfood Vendoring Policy
Guidance only — The dogfood corpus — real/vendored artifacts vigiles tests ITSELF against (real OSS plugins, real instruction files, gold sets, task corpora) — follows ONE policy. The rules: (1) SHA-PIN — a vendored upstream lives in test/dogfood/<name>@<sha>/, reproducible + offline. (2) MIT-ONLY — only MIT upstreams are vendored verbatim; a no-license/non-permissive repo is NOT committed. (3) PROVENANCE PER SLICE — every vendored slice ships BOTH a LICENSE (upstream MIT text + copyright) AND a SOURCE (upstream URL/path/commit + why-it's-here + the minimal slice); a slice without both is incomplete. (4) MINIMAL SLICE — vendor the smallest slice that reproduces the behaviour under test, never the whole repo; trim manifests so the loader sees a coherent plugin. (5) CI-ENFORCED OR LABELLED-MANUAL — a dogfood artifact CI never reads is decoration, not a guard: it must be read by a src/**/*.test.ts (→ npm run coverage) or run by a dedicated CI step (the rule-enforcer/ trust gate and the bench/corpus/verify*.mjs self-checks each have one in the check job), OR be explicitly labelled MANUAL with the reason (real-model evals/benchmarks that cost money — examples/harness/dogfood/*.eval.mjs, bench/ecosystem/**). (6) REFRESHABLE — re-pin via tools/refresh-vendor.sh, breadth-sweep via tools/dogfood-sweep.sh (both human-run, never CI — they clone the network). When you add a new vendored corpus, it follows this same shape and gets a row in the index. DON'T CONFUSE THREE THINGS called 'dogfood' — only test/dogfood/ is THIS corpus; examples/harness/dogfood/ are skill-EXAMPLES, and rule-enforcer/gold/ is that package's own gold sets, none governed by this rule. PER-ADAPTER: the ADAPTERS are symmetrically enforced (src/adapter-contract.test.ts loops the whole registry), but the VENDORED corpus is CC-only (real Claude Code plugins) with Codex on artificial tmp fixtures (src/scan-cli.test.ts) — a documented asymmetry (real Codex plugins are rare), tracked in the roadmap, not an oversight.
One Detector No Drift
Guidance only — A DETERMINISTIC structural check has ONE home: a single pure detector reused by BOTH lint (the per-commit, severity-configurable gate) and audit (the read-only report) — never reimplemented in each, so the two surfaces can't drift. The detectors already shared this way: untested-surface (src/test-coverage.ts), dangling-ref (danglingRefs in src/plugin-loader.ts), and description-overlap (findDescriptionOverlaps in src/core/description-overlap.ts, the NCD near-duplicate proxy). When a deterministic signal earns a lint rule, the rule and audit must call the SAME function (plus a docs/rules/<name>.md per the doc-per-rule rule). The model-gated BEHAVIORAL column (the audit model trigger tier / runEval trigger-rate, observed egress) is the deliberate EXCEPTION: it's LLM-based — needs model auth, costs tokens/quota, is non-deterministic — so it lives ONLY in the audit/eval tier and is NEVER promoted to a lint rule (lint stays deterministic, free, runnable on every commit with no key). The dividing line is the tool's core axis: cheap deterministic DETECTION can be a lint rule; expensive model-gated CONFIRMATION cannot. So two near-identical skill descriptions are flagged deterministically (lint/audit), but whether the wrong one actually FIRES is measured only by the model trigger tier (the audit trigger tier, interactive — or measureTriggerRate for automation).
Lint Rule Calibration
Guidance only — Every deterministic check sits at the STRONGEST enforcement its DECIDABILITY allows — and no further, because going further is itself the bug (false confidence or crying wolf). Prevention is a GRADIENT, not a switch: unrepresentable → won't-typecheck → won't-build → error → warning → measured, weakening left-to-right as the property gets less decidable / less self-contained. Three best-practice results FIX where a given defect can sit: 'make illegal states unrepresentable' (Minsky) works ONLY for structural+decidable+closed-vocab properties; 'parse, don't validate' (King) relocates a check for anything touching the external world to one boundary but never deletes it; Rice's theorem makes any behavioral property (does this skill FIRE?) undecidable statically — measurable only. So every rule lands in exactly ONE of three BUCKETS, and the bucket sets the CEILING: (A) structural-closed — decidable from the artifact's own content over a closed vocabulary; a TYPE could make it impossible for spec-authors → ceiling is won't-typecheck, error-capable (e.g. subagent-tool-contract, skill-missing-fence, hook-block-ineffective). (B) external-decidable — decidable but needs the EXTERNAL world (filesystem, linter catalog, another file/server); NO type can read those, so the ceiling is a hard ERROR at compile-cross-ref or lint, NEVER a type (e.g. hook-script-exists, mcp-tool-resolves, integrity). (C) heuristic-behavioral — undecidable or a fuzzy proxy; the ceiling is a WARNING or a model-MEASUREMENT, and an error here CRIES WOLF (e.g. description-overlap's NCD proxy, frontmatter-valid's stricter-than-the-loader YAML, unmarked-refs). The reframe that kills 'why isn't it all types?': bucket A wants a type, but bucket B is type-IMPOSSIBLE (yet still a hard error — equally gating, just later) and bucket C is warn-CORRECT (not a compromise). SEVERITY TRACKS CONFIDENCE, NOT IMPORTANCE — a clean fact (A/B) can be error; a proxy (C) must be warn. The bucket is the CEILING; defaultSeverity is where the rule sits TODAY — a gap is meaningful: an A/B rule at warn is a PROMOTION CANDIDATE (deterministic, rolling out, e.g. lethal-trifecta), a C rule at warn is PERMANENT. This is the basis of the structural(error)/nudge(warn) split in install-enforcement-model. MECHANICALLY ENFORCED: every rule MUST be declared in src/core/rule-meta.ts (the ESLint-meta pattern adapted to vigiles's shared-detector architecture — ONE registry keyed by rule name, NOT scattered export const meta, because one detector feeds many rules) with its bucket / surface / defaultSeverity / detector / optional upstreamPrevention; Record<RuleName, RuleMeta> makes completeness a tsc error, and src/core/rule-meta.test.ts binds the registry to docs/rules/*.md (exact set match) + cross-checks each defaultSeverity against the real DEFAULT_RULES. The honest reframe of the apparent 'spec rules vs markdown rules' fragmentation: NO lint rule reads a .spec.ts's content — the whole rule set is ARTIFACT-targeted (it reads what SHIPS), plus two meta-rules (require-instructions-spec checks adoption EXISTS, integrity checks the artifact still matches its hash). Spec correctness is a SEPARATE pipeline (Stage 1 TYPE = the user's tsc; Stage 2 COMPILE = vigiles compile), which fires earlier and harder; a lint rule that re-checked the spec would just duplicate tsc. So we NEVER move a rule onto specs — the same defect can be PREVENTED at Stage 1 for spec-authors (recorded as RuleMeta.upstreamPrevention) AND DETECTED at Stage 3 for everyone else (defense-in-depth across two populations, not duplication). Adding/calibrating a rule is not done until rule-meta.ts + enforcement-model.md are updated alongside the docs (sibling of rules-docs-in-sync).
Harness Parity And Extensibility
Guidance only — When you design ANY capability — not just a lint rule or a test, but compile EMIT, a runtime, a new port, a new subsystem — two constraints are NON-NEGOTIABLE and apply at DESIGN time, before you write code. (1) FULLY SUPPORT BOTH SHIPPING HARNESSES — Claude Code AND Codex — as equals. Never build CC-first and bolt Codex on after: if a capability maps to both, it must WORK on both and be asserted on both (the test-both-harnesses arm); the emitted artifact must be each harness's native format (CC JSON settings / Codex TOML); the runtime must be the shared neutral path where one exists (e.g. the gate deny→exit 2 is byte-identical). Where a capability genuinely cannot map to a harness yet, that is a LOUD, documented deferral (a compile warning + a gated/skipped test stating WHY — e.g. hook REACT output shape is CC-confirmed-only; inject is now confirmed on BOTH and encoded in HookProtocol.injectableEvents), NEVER a silent CC-only path. 'Both harnesses fully' is the bar, not 'CC plus best-effort Codex'. (2) LEAVE ROOM FOR FUTURE ADAPTERS (OpenCode, Gemini, …) by construction. Every harness-specific fact lives behind one of the five ports (HarnessDialect / PluginLayout / HarnessRuntime / HookProtocol / ModelMock); the core stays harness-agnostic (core ⊄ adapter, eslint-enforced) and reads facts from the injected adapter, never a hard-coded literal. Prefer a PORT + INJECTION over an inline conditional, so adapter #3 is a NEW OBJECT registered in ADAPTERS, not an edit threaded through the core. (3) DESIGN THE NEUTRAL SHAPE FIRST, defer the abstraction until it's earned. Model the capability in harness-neutral terms and let the dialect/port supply the per-harness specifics; do NOT invent a neutral indirection layer for a difference that doesn't exist yet (rule-of-three / YAGNI / leaky-abstraction risk) — e.g. native event names are validated per-dialect now, and a neutral event-alias map is deferred to the dialect seam until a harness with DIVERGENT names lands. The seam must EXIST (the port); the mapping is added when a second divergent implementation needs it. This is the DESIGN-TIME principle; its enforcement arms are adapter-aware-lint-rules (rules), test-both-harnesses (tests), the adapter CONTRACT suite (src/adapter-contract.test.ts, the whole-registry conformance loop), and the core⊄adapter / no-CC-literal eslint boundaries. See docs/harnesses.md. Record-only is a deliberate decision, not a parity gap.
Adapter Aware Lint Rules
Guidance only — A lint/scan rule is scoped to the SURFACE it checks (instruction file, skills, MCP, docs, shell hooks, subagents), NEVER to a harness — vigiles is multi-harness, so a rule must work for WHATEVER harness a repo targets (Claude Code, Codex, a future one), including a repo targeting SEVERAL at once: a byte-identical CLAUDE.md⇄AGENTS.md mirror (one logical artifact, linted once on the source slot) OR INDEPENDENT .claude//.codex/ trees with different instruction files (linted per target). Three hard rules, all enforced/dogfooded: (1) NAME BY THE HARNESS-NEUTRAL CONCEPT, never a harness's vocabulary. The Claude Code 'agent' is a SUBAGENT, so the rules are subagent-tool-contract / subagent-frontmatter / untested-subagent — NOT agent-* (Codex's [agents] is a TOML concurrency table, a different concept that merely shares the word). A harness/ PREFIX (e.g. a future codex/parallel-agents) is RESERVED for a concept only one harness has with no cross-harness analogue — none exist today, so no rule is prefixed. (2) THREAD THE RESOLVED ADAPTER; never default to Claude Code. Each check resolves the harness ONCE (resolveHarnessSelection — honouring --harness= / the .vigilesrc.json harness key / auto-detect) and passes adapter.layout + adapter.dialect into the shared detector (scanPlugin, findUntestedSurfaces). A harness-agnostic detector MUST NOT hard-code a Claude Code literal — no ${CLAUDE_PLUGIN_ROOT}, no .claude/, no agents//skills//commands/ surface dir — but read them from the layout (pluginRootToken, skillDir/agentDir/commandDir, materializeRoot, manifestPath) and the dialect (tool + hook-event catalogs). This is MECHANICALLY ENFORCED: an eslint no-restricted-syntax rule bans CC string literals (plain + template quasi) in src/core/** + the detectors (src/scan.ts, src/test-coverage.ts, src/plugin-loader.ts), and eslint-plugin-boundaries bans the core importing an adapter. (3) GATE BY CAPABILITY and report n/a (the no-silent-skips corollary). A surface rule runs only where the active adapter HAS the surface (AdapterCapabilities.subagents / shellHooks); where it doesn't, it reports n/a — <harness> has no <surface> via reportNotApplicable — LOUD, never a false pass, never a crash (CC + OpenCode have subagents; Codex does not). DOGFOOD MUST BE NON-CLAUDE-CODE-SHAPED: a Claude-Code-shaped fixture STRUCTURALLY CANNOT catch a CC-hardcoding regression, so the regression tests for the agnostic path use a non-CC layout/harness — the Codex lint e2e (src/scan-cli.test.ts), the custom-agentDir classification test (src/scan.test.ts), and the custom-token surface-discovery test (src/test-coverage.test.ts). The ONLY places a CC literal legitimately lives: src/adapters/claude-code/, the CC eval transport (src/eval.ts), and init onboarding (it installs the CC plugin). The per-rule applicability is documented as the surface→harness 'Applies to' matrix in docs/verifying-instruction-files.md. See docs/harnesses.md.
Test Both Harnesses
Guidance only — vigiles is MULTI-HARNESS, so a test of a HARNESS-FACING capability must cover BOTH adapters (Claude Code AND Codex), never just Claude Code by default. This is the test-layer companion to adapter-aware-lint-rules (which governs lint/scan RULES): it applies to compile EMIT (a spec/hook → the harness's native format — assert the CC JSON AND the Codex TOML output), the hook/agent/skill RUNTIME, the layer-2 harness-testing runners, and the scan/lint detectors. Three sub-rules, mirroring no-silent-skips. (1) TEST WHAT MAPS ON BOTH — if a capability exists for both harnesses, assert it on both: compile --harness=codex beside the CC default, the agnostic detector over a NON-CC-shaped layout (a CC-shaped fixture structurally can't catch a CC-hardcoding regression — the dogfood-must-be-non-cc point from adapter-aware-lint-rules). A genuinely harness-NEUTRAL path that does not branch on harness (e.g. the compiled-hook GATE runtime — deny→exit 2 is byte-identical on CC and Codex, and react run() just spawns) is covered ONCE, but a comment MUST say WHY one run suffices, so 'only CC is tested' is never left ambiguous (was it neutral-on-purpose, or a CC-only oversight?). (2) GATE/SKIP LOUDLY where a harness genuinely can't run — its binary isn't on PATH (the real-codex tests skip when absent), or the capability is DEFERRED for it (Codex inject/ask/react OUTPUT shape is CC-confirmed-only) — via skip(reason) or a gated test that states WHY, NEVER a silent CC-only pass. (3) DON'T DOUBLE-TEST AGNOSTIC CORE — a pure detector / Trace check that reads only harness-neutral fields needs ONE run, not a redundant per-harness loop; the both-harnesses bar is for the harness-FACING surface, not every unit test. The src/adapters/<harness>/* suites are per-harness by construction; the agnostic suites (src/*.test.ts) must not silently assume Claude Code. STRUCTURALLY ENFORCED (not just guidance) by the adapter CONTRACT suite (src/adapter-contract.test.ts): it runs the conformance kit over the WHOLE registry in a loop (for (const a of ADAPTERS)), so registering an adapter auto-subjects it to every contract (can't forget a new harness), a capability an adapter lacks is a VISIBLE it.skip(… n/a …) (gated on shellHooks/harnessTesting), and a meta-test FAILS THE BUILD when a src/adapters/<dir>/ exists but isn't registered (or a declared prototype like opencode). KNOWN GAPS, documented not hidden: (a) the contract catches ADAPTER-level gaps (an unregistered / broken-port / failed-capability adapter), but it CANNOT auto-catch a harness-FACING capability whose assertion was written CC-only OUTSIDE the contract — putting an assertion INTO the iterated contract is the judgment THIS rule governs (the contract is the FLOOR: every registered adapter is held to everything in it; this rule is the CEILING: decide what belongs in it); (b) it can't force OSS-grounded or real-binary coverage — those gate/skip LOUDLY when the binary's absent, never a false green. Complements adapter-aware-lint-rules (the rule half), no-silent-skips (loud gating), dual-language-tests, and prefer-existing-solutions.
Compose With Sync Tools
Guidance only — vigiles is the author+verify layer, not the fan-out layer. Stay composable with the top rule-sync/interop tools instead of reimplementing them: Ruler (intellectronica/ruler, the leading single-source→many-agent distributor), rulesync, and the AGENTS.md cross-tool standard. The division of labour: vigiles owns truth (a typed spec compiled to a verified CLAUDE.md/AGENTS.md whose references are real and enabled); those tools own reach (distributing that canonical file to Cursor/Cline/Windsurf/Copilot/etc.). Concretely: (1) keep emitting the canonical formats they consume — CLAUDE.md and standard AGENTS.md — and never break that contract; (2) be able to verify references in the files they generate (lint-after-the-fact for their outputs). Do NOT add native multi-format emitters (.mdc, .clinerules, …) — compose, don't absorb the per-agent format-maintenance burden. (3) Treat a symlinked-or-synced CLAUDE.md⇄AGENTS.md as ONE artifact, not two: Claude Code reads CLAUDE.md only (it does NOT natively load AGENTS.md — anthropics/claude-code#34235), so users bridge to the AGENTS.md tools via a symlink (ln -s CLAUDE.md AGENTS.md) or a sync tool keeping them byte-identical. vigiles must follow the symlink on read (hash + require-instructions-spec run once on the real file; the mirror is never flagged as a second spec-less instruction file) and stamp the integrity hash only on the compile source slot, not the distributed mirror.
Smooth Adoption
Guidance only — npx vigiles init must work on first run with zero config and set up BOTH layers by default: a typed spec + types (the Lint layer), a vigiles.harness.mjs starter (the Test layer), a zernie/vigiles@v1 CI workflow (created when none exists; a stale old-API one is flagged, not skipped), vigiles added to devDependencies, and the Claude Code plugin installed via the MARKETPLACE (/plugin marketplace add zernie/vigiles + /plugin install vigiles@vigiles, into ~/.claude/plugins/ — never vendored into the repo). Onboarding is interactive at a TTY (asks which layers / CI / plugin) and NON-INTERACTIVE for agents, CI, or piped input (or with --yes) — so 'set up vigiles' from a Claude Code / Codex prompt Just Works without hanging on a prompt; --lint/--test scope the layers (--harness= overrides detection). After install the agent edits specs automatically — no workflow change required. The DEFAULT already GATES broken surfaces (the FP-safe structural rule group at error — a typo'd tool / dead hook / broken MCP / skill collision fails CI, while a clean repo stays green); the WORKFLOW tier (a spec per file + a test per surface) is OFFERED interactively (opt-out, default-yes) or via --strict, and --report-only downgrades everything to warnings for cautious migration. 'Permissive' means vigiles doesn't FORCE specs/TS on you and doesn't CRY WOLF — NOT that it ignores genuine breakage (see install-enforcement-model). Hesitant adopters can use inline mode (<!-- vigiles:enforce ... --> comments) without a .spec.ts — see docs/markdown-mode.md. See docs/agent-setup.md.
Great Agent Flow
Guidance only — The end-to-end experience when an AGENT is handed a short prompt like 'install vigiles and test my skills' must be GREAT — discoverable from the README front door and frictionless, because for many users the agent IS the installer. Concretely: (1) install is one non-interactive command an agent runs without hanging — npx vigiles init auto-detects a non-TTY and applies defaults (both layers); (2) init installs the model-invocable test-harness skill, so a follow-up 'test my skills' FIRES that skill (it picks the tier and writes the test) rather than leaving the agent to flail; (3) the README must make the skill-testing path explicit and high-signal — name measureTriggerRate (does a skill's description actually fire? recall + precision), not just hook examples, and surface the test-harness skill where the task lives, not buried in a list. The README is the agent's front door: an agent reading it for 'install + test skills' must find a clear, actionable path. Dogfood it — the shipped model-invocable consumer skills are test-harness, strengthen, edit-spec, and debug-my-harness (each reaches for itself on the matching request rather than waiting for a slash command — debug-my-harness reads the .vigiles/runs.jsonl flight recorder to diagnose why the harness misbehaved; generate-logo is an internal contributor-only skill under .claude/skills/, not shipped); all are covered by trigger-rate evals (examples/harness/dogfood/) gating recall + precision so making them auto-fire doesn't make them over-fire. The user-invoked skills (adopt-spec, linter-docs) stay disable-model-invocation. See docs/agent-setup.md and docs/harness-testing.md.
Install Enforcement Model
Guidance only — vigiles init's enforcement model is RULE GROUPS keyed by confidence, NOT a strictness-preset MENU — the linter best practice (Clippy/Ruff/Biome; Biome's own design note: 'presets are a poor emulation of rule groups', and ESLint's strict/stylistic preset-piling is 'a sign of a lack of consistent rule grouping'). THREE groups: (1) structural — the FP-safe correctness rules (subagent-tool-contract, subagent-frontmatter, hook-events, hook-script-exists, mcp-config, mcp-tool-resolves, mcp-hook-target-resolves, disallowed-tools-contract, description-overlap, integrity) — DEFAULT error: catches a typo'd tool / dead hook / broken MCP / skill collision, and a clean plugin stays green so it never cries wolf; (2) workflow — the opinionated rules a CLEAN repo can still fail because the work isn't done yet (require-instructions-spec, untested-skill/subagent/hook) — OFF by default, error under --strict (the ONE opinionated opt-in, the Clippy-pedantic / TS-strict analog); (3) nudge — recommendations / acknowledged-noisy (frontmatter-valid, skill-frontmatter, unmarked-refs at warn; prefer-compiled-hooks defaults OFF) — NEVER gate (frontmatter-valid stays warn even under --strict). require-instructions-spec is NARROW (only a .spec.ts satisfies it, not inline/frontmatter) and is satisfied BY CONSTRUCTION because init AUTO-ADOPTS every instruction file into a faithful spec — so opting into workflow never lands a wall of missing-spec failures. --report-only is an ORTHOGONAL severity dial that downgrades the gating groups to warn (the migration / observe on-ramp, like Biome's only-warn) — it is NOT a rule set and composes with the group selection. Presets/flags EXPAND to EXPLICIT per-rule severities in .vigilesrc.json (greppable, editable, downgrade any single rule) — NEVER a runtime preset key (the explicit > magic ethos that put the rules in the config rather than code defaults). DO NOT build a relaxed/standard/strict preset menu. INSTALLATION IS EXPLICIT ABOUT TRADEOFFS: every choice states what you GET and what it COSTS at the point of choosing, and the SAME tradeoff text is reused across the CLI prompt (one terse line per option), the agent's AskUserQuestion (the tradeoff is the option description; the tool's automatic 'Other' takes a custom answer), and the post-install summary (what landed + the implication + how to change it) — one source, three surfaces, no drift. The agentic install presents the GROUP opt-in WITH its tradeoff ('also enforce the workflow group? — fails until you have written specs + tests'), never an abstract strictness band. Mechanism today: STRUCTURAL_RULES (= structural) + WORKFLOW_RULES (= workflow) + NUDGE_RULES (= nudge) in src/setup-plan.ts, written by mergeProjectConfig (which takes reportOnly for the --report-only dial).
Gate First Adoption
Guidance only — The adoption NORTH STAR. Every change touching init, the report, CLI output, onboarding, or SHARING serves these GOALS: (G1) the INTEGRITY GATE is the universal floor — audit (local) + lint (CI) must be valuable AND safe for EVERY repo with zero setup and zero conflict, any stack, existing-harness-or-not, and stand entirely on their own (this is what everyone adopts first); (G2) everything richer (specs, installed skills, rules → enforced, eval) is VALUE ON TOP, INVITED never forced; (G3) FIT THE TEAM, don't fight it — the path branches by stack (supported linter?) + existing-harness, and the tool shows ONLY what's TRUE for THIS repo (no silent rules → enforced on a non-JS stack, no Tested alarm for a team with its own tests); (G4) a result is worth sharing AND sharing is clean — a fair grade spreads on merit (a real report link, an opt-in badge, the PR comment), and a LOCAL CLI result is as shareable as a web-demo one; (G5) every path is judged by whether someone can run npx vigiles audit and get something useful on the first try. The NON-NEGOTIABLE 'not evil' contract binds every adoption/onboarding/sharing surface (breaking one is a DEFECT, not a tradeoff): (1) a READ never writes — audit/lint never mutate/install/phone-home; setup is a separate consented step; (2) DECLINING is free, one keystroke, and REMEMBERED — no nag loops, no default-yes on an invasive choice, no penalized grade, and agent/CI/piped runs never hang on a prompt (they take the safe default + print a one-line invitation); (3) never OVER-PROMISE or measure the wrong thing — honesty over a flattering-but-false signal; (4) grade OTHERS fairly (the Lighthouse contract) — objective, reproducible, one-line-fixable findings, tone is 'worth fixing' never 'gotcha', and only feature a repo vigiles doesn't own if it's MIT-vendored (reuse dogfood-vendoring-policy) or opted-in, methodology published, sorted-not-editorialized (no 'hall of shame'), private-first for real defects; (5) NO dark-pattern sharing — no forced/coerced shares, contact-list spam, 'share to unlock', auto-posting, fake scarcity, or streak/guilt manipulation, and never gate real value behind a viral action (grounded in the LinkedIn-$13M / FB-frictionless / Path / Duolingo / GitHub-name-and-shame backlash corpus in adoption-design.md). THE THROUGHLINE: make the GATE useful to everyone, INVITE the rest, FIT each team, and let a fair result spread on its own merits — adoption is earned by being useful and honest at every step, never extracted by pressure. This is the umbrella over smooth-adoption / great-agent-flow / install-enforcement-model / progressive-adoption / lead-with-easy-adoption (they are HOW; this is the WHY they answer to).
Format Before Commit
Guidance only — Run npm run fmt:check before committing. Inline code spans in markdown need surrounding spaces to render correctly.
Conventional Commits
Guidance only — Commit subjects AND PR titles are Conventional Commits — they drive the release version via @semantic-release/commit-analyzer off main (feat → minor, fix → patch, everything else no bump). Allowed types (enforced by the validate CI job, amannn/action-semantic-pull-request): feat, fix, docs, chore, refactor, test, perf, ci, build. OPERATIONAL GOTCHA — a PR opened from the Claude Code UI (or any non-CLI flow) gets a plain prose TITLE by default, which FAILS the validate job (it checks the PR TITLE, not the commits). Fix the PR title to a Conventional Commit (type + optional ! + subject) as soon as the PR exists — edit it via the GitHub API/UI; validate re-runs on the title edit, no new push needed. The title must carry the SAME breaking-change signal as the commits: if the branch removes/renames a public API, the PR title needs the ! too (the release version comes from the merged title/commits). CRITICAL — signal breaking changes explicitly: a ! after the type (feat!:/refactor!:) or a BREAKING CHANGE: footer triggers the MAJOR bump. The CI lint only checks the type prefix; it CANNOT tell whether a change is semantically breaking, so the ! is on you. A change is breaking when it removes/renames/moves a public API: a package.json exports subpath (e.g. vigiles/claude-code), an exported symbol, a CLI command/flag, a config-file key, or a compiled-output contract. When in doubt whether a change is breaking, mark it ! — under-signalling ships a wrong (too-low) version, which is worse than an extra major. (Pre-1.0 the major stays 0, but the signal must still be correct so the changelog is right and the first 1.0 bump is clean.)
Lead With Easy Adoption
Guidance only — Public docs — and the README's FIRST SCREEN especially — must make CLEAR and UP-FRONT that adopting vigiles is LOW-FRICTION because SKILLS + HOOKS do the mechanical work for you. The reader's first impression must be 'I run one command and the agent takes it from there,' NEVER 'this looks like a pile of setup.' The recurring failure is presenting features as manual chores (hand-wire this hook, write this spec, remember to run --update) and burying the agent-does-it-for-you story — which reads as friction and loses adopters before they try it. Lead with the reassurance, then prove it. WHAT IS ACTUALLY SMOOTH (state it plainly, early): (1) ONE COMMAND — npx vigiles init — installs the plugin (skills + hooks), adopts your existing CLAUDE.md into a spec, and wires CI; non-interactive so an agent runs it without hanging. (2) THE AGENT DOES THE EDITS — model-invocable skills handle the work on request: edit-spec changes your CLAUDE.md by editing its spec, strengthen upgrades guidance into verified rules, and test-harness writes + runs the test/eval AND maintains its artifacts (e.g. it runs vigiles eval --update and commits the lock for you — you don't type it). (3) HOOKS NUDGE AT THE RIGHT MOMENT — the refs nudge, the eval-lock staleness nudge — so the agent is reminded in-loop instead of you remembering. So FRAME EVERY FEATURE'S UPKEEP as agent/skill/hook-driven, not homework: the eval lock is 'the test-harness skill keeps it fresh + a hook reminds you', not 'remember to run --update'. HONEST (don't overclaim): the agent + skills + hooks handle the MECHANICS; you still make the decisions and review the diffs — low-friction, not zero-thought, and the deterministic gates still catch real breakage (install-enforcement-model). The TEST: a skeptical reader skimming the first screen should come away reassured adoption is smooth, with the agent/skill/hook path obvious — not daunted by setup. This is the MESSAGING companion to smooth-adoption (the init mechanics) + great-agent-flow (the agent on-ramp) + cohesive-feature-delivery (every feature ships the agent-awareness surface that makes this claim TRUE); keep it honest with install-enforcement-model (smooth never means it ignores real breakage) and tight with readme-brevity.
Progressive Adoption
Guidance only — vigiles must be adoptable incrementally, like TypeScript — but progressive about ENFORCEMENT DEPTH, NOT about whether obvious breakage fails. The on-ramps: (1) --report-only / inline <!-- vigiles:enforce ... --> comments on an existing CLAUDE.md — see problems as warnings, fix gradually, nothing fails CI, no new files; (2) DEFAULT npx vigiles init — the FP-safe structural rule group gates REAL breakage (a clean repo stays green), zero linter setup; (3) --strict — also require a spec per file + a test per surface (the workflow group). Each level adds value without requiring the next; never FORCE specs/TS to use the basics. The reframe (see install-enforcement-model): 'permissive' = no forced specs/TS + no crying wolf (the default rules are FP-safe), NOT ignoring genuine breakage. README examples show the simplest path first.
No Session Links
Guidance only — This is a public repo. Claude Code session URLs are private and must not appear in commits or PRs.
No Product Strategy Here
Guidance only — This repository is PUBLIC. Anything committed here is readable by anyone, and deleting a file does not remove it from history or from other branches. NEVER commit here, in any directory: roadmap / prioritisation / what-we-build-next; competitive analysis, positioning, poach lists; adoption, distribution, go-to-market, pricing; or design documents for capabilities that are NOT YET SHIPPED. THE LINE IS BUILT VS UNBUILT, not engineering vs business — documentation of shipped behaviour belongs here and is useful, while a design document for something unbuilt is a roadmap however technical it reads. A competitive FACT may appear only as a neutral, public-source technical note; the narrative around it does not belong in this repository at all.
Subagent Model Note
Guidance only — When you launch a subagent (the Agent/Task tool), tell the user in one short line WHICH model you chose for it and WHY — e.g. "spawning the Explore agent on Haiku — cheap fan-out read, no synthesis needed" or "using Opus for this one — it has to reconcile conflicting findings across docs". The user wants visibility into the model-selection tradeoff (cost vs capability) you're making on their behalf. Keep it to a clause, not a paragraph; do it at launch, not after.
Surface Architecture Decisions
Guidance only — The user is often flying BLIND on structure — they can't see WHERE you put a file, whether a thing is CORE vs an ADAPTER vs a PORT, or whether you extended something vs created a sibling. So when you make a non-trivial ARCHITECTURAL decision, STATE IT IN CHAT in a short, scannable block BEFORE or AS you implement it — never bury it in a big diff the user then has to reverse-engineer. What counts as architectural (surface it): WHERE a new file / module / type / detector goes (and why THERE, in the tree); whether it's harness-agnostic CORE (src/core/**) vs an ADAPTER (src/adapters/<harness>/**) vs a PORT (the five injected interfaces) vs the composition/library root (src/*.ts); EXTENDING an existing verb/module/report-type vs creating a new one; a CROSS-CUTTING change touching many files or a shared type; and any placement the CONSTRAINTS make load-bearing. For each decision give three things, tight: (1) WHAT — the file/module + where it sits; (2) WHY THERE — the reasoning against OUR constraints, naming the relevant one (hexagonal core ⊄ adapter / multi-harness-behind-a-PORT + no-CC-literal-in-core / one-detector-no-drift / cohesive-cli-surface / the rule-meta triad / additive-and-lock-safe); (3) the ALTERNATIVE you rejected, in a clause, when there was a real fork (e.g. 'a new driver method vs reuse the parser's existing usage — chose reuse, it's harness-neutral already'). Keep it to a few bullets — a QUICK SCAN, not an essay; the point is the user catches a wrong call (a leaked adapter import, a type in the wrong layer) in seconds, before the diff. This is the STRUCTURE analog of subagent-model-note (which surfaces the model choice): surface the placement + boundary reasoning the same way. Especially load-bearing because vigiles is hexagonal + multi-adapter, where a wrong placement (core importing an adapter, a CC literal in core, a per-harness fact not behind a port) is exactly the mistake the boundaries exist to prevent — so say the boundary out loud as you honor it. TWO type-design principles are THEMSELVES architectural decisions — APPLY them AND surface them when you make a structural call (the mechanical HOW lives in ts-essentials; HERE the point is to NAME the decision so the user can scan it): (a) PARSE, DON'T VALIDATE — turn loose/untrusted input (a string | string[], an unknown config blob, a CLI flag) into a typed, normalized shape ONCE at a boundary, then pass the typed value inward; WHERE that boundary sits is a placement decision, so state it ('parse settings.hooks unknown once in hook-normalize → typed everywhere after', not re-validated at each use site). (b) MAKE ILLEGAL STATES IRREPRESENTABLE — reach for a type that CANNOT express the bad state (a discriminated/tagged union, a branded type) before a runtime check that merely hopes to catch it; when you choose a type shape, NAME the invalid state it now forbids ('a Decision that carries notice only on its kind:"notice" variant, so a caller can't read a null notice'). Both are load-bearing precisely because a wrong call — re-validating the same primitive at ten sites, or a boolean-flag soup that lets an impossible combo compile — is exactly the structural debt this rule exists to catch EARLY, at the decision, not after the diff.
Doc Per Rule
Guidance only — Every validation rule in .vigilesrc.json must have a corresponding doc in docs/rules/.md. The doc covers configuration, severity levels, options, what the rule checks, and why. README links to each rule doc from the rules table.
Rules Docs In Sync
Guidance only — The set of validation rules is a SINGLE SOURCE OF TRUTH that the docs must track, never drift from: the RulesConfig keys in src/core/types.ts (plus any built-in vigiles/* rule like orphan-docs) ARE the rule set, and the canonical rules matrix in docs/verifying-instruction-files.md (the ## The validation rules section — it lives in the LINTING GUIDE, where a reader looking for the rules expects it, NOT buried in the CLI reference) must list EVERY one — each with its default severity and a one-line what it checks, linking its docs/rules/<name>.md. The matrix is grouped by family (spec & integrity, test coverage, reference marking, subagent contracts, hooks & MCP, skill triggers, docs hygiene). So adding, removing, or renaming a rule is not done until the SAME change updates FIVE places together: (1) the RulesConfig type in src/core/types.ts; (2) the per-rule matrix row in docs/verifying-instruction-files.md; (3) the docs/rules/<name>.md doc (the doc-per-rule requirement); (4) the PUBLIC coverage matrix in docs/what-vigiles-catches.md (the benefit-framed, biggest-problem-first list of what vigiles prevents/catches/measures — see public-vs-internal-docs); and (5) the INTERNAL handling-mode matrix (## The handling-mode matrix: prevent vs detect vs measure). The TWO matrices differ in shape and obligation: the per-rule matrix (2) is a 1:1 list that must carry EVERY rule, so a new rule ALWAYS adds a row and a removed rule ALWAYS drops one. The coverage matrices (4) + (5) are organized by USER-FACING PROBLEM, not per-rule — a new rule updates them only when it changes what real-world problem is prevented/caught/measured (a new detector for a problem already covered may sharpen an existing row rather than add one; a genuinely new class of bug adds a row, placed by how badly the problem bites, NOT alphabetically). No rule ships absent from the per-rule matrix, and no row points at a rule that no longer exists. docs/cli.md and the README do NOT re-list the rules (readme-brevity); they LINK to the one matrix in the linting guide, so that matrix is the place that must stay complete. When you touch the rule set, re-derive the per-rule matrix from the RulesConfig keys to catch a missed row, then re-read the two coverage matrices and update any row whose prevent/catch/measure story changed. Complements doc-per-rule (which governs the per-rule doc) — this governs the COMPLETENESS of the shared lists and WHERE they live.
Readme Brevity
Guidance only — README.md is the FRONT DOOR and a marketing asset, not a reference manual — it must land a WOW in the first screen for someone who already lives in agentic-coding tools (Claude Code, Codex, Cursor). Optimize for FAST SCANNING by a human skimming on a phone: SHORT (target ~140 lines, hard cap ~200), NO walls of text (a paragraph is ≤ ~3 lines; break dense prose into bullets, tables, or a one-line + [Details →] link), and GREAT FORMATTING (a punchy one-line tagline, a tight 2-row Lint/Test table, runnable code blocks, bold lead-ins on bullets, em-dashes not semicolons). Lead with the sell, not jargon (no "layer"; footnote the Latin). Push every detail into docs/ and LINK it ([Details →](docs/X.md)) the moment a section runs long — the README states WHAT and WHY, the docs hold HOW. The evals section MUST state the cost model: most questions are answered deterministically (no model, no key), and real-model evals run on YOUR Claude subscription rather than a metered API key — keep it to a sentence or a small table row, not a wall, with [Why it's affordable →] for the rest. Every claim that needs proof links out; nothing load-bearing is buried in prose. Put the INSTALL PATH ABOVE THE FOLD: the slim Quick start (the copy-paste agent prompt + npx vigiles init) sits right after the what-it-does table, BEFORE the deep-dive sections — order is hook → what → get-it-now → why (proof/depth) → reference — so a skimmer can try it in seconds; keep the verbose setup detail in a <details> so the top stays lean.
Docs Quality
Guidance only — The docs/ reference docs are the HOW the README defers to — they are NOT held to README brevity (depth is their job; a long reference doc is correct, a wall of undifferentiated prose is not). Hold each to a DOC-appropriate polish bar instead: (1) open with a one-line "what this doc is" + an UP-LINK to the README in the same breath ("the README has the pitch; this is the full guide") so a reader who landed deep can climb back to the sell; (2) be SCANNABLE — descriptive section headings, a Contents list once a doc runs long, tables and runnable code blocks over prose walls, bold lead-ins; (3) CROSS-LINK siblings (the lint guide and the testing guide point at each other; every doc ends in a "See also") and link DOWN to the deeper doc rather than inlining its detail; (4) stay CONSISTENT with the README and with each other — terminology, claims, and framing must track the front door, never contradict it (the lint/test naming, the promptfoo cost contrast, the deterministic-vs-real-model split), and a doc the README promises must actually deliver that depth (no thin stubs, no stale/renamed commands or dead example paths). The README sells; the docs prove and instruct — both are first-class, neither is a dumping ground.
Prose Clarity
Guidance only — Public prose (README + docs/) must be CLEAR and SCANNABLE for a human skimming on a screen — not dense, jargon-packed, or wall-of-text. The recurring failure is sentences a reader can't parse on one pass: stacked em-dash asides, nested parentheticals, three ideas welded together, internal shorthand used without a plain gloss, and BOLD on every other clause (which emphasizes nothing). Write the opposite. SEVEN HABITS, applied to every public doc you write or touch: (1) LEAD WITH THE POINT (BLUF) — the first sentence of a section says what it is or what to DO, in plain words, before any nuance. (2) ONE IDEA PER SENTENCE, short. If a sentence has two em-dashes or two nested parentheticals, split it. Prefer a period over a dash. (3) DEFINE OR DROP JARGON — gloss a term the first time ('the lock — a committed file CI checks') or replace it; never stack project shorthand ('the seam', 'model-affecting inputs', 'the gate') without a plain-English handle. (4) SCANNABLE STRUCTURE over prose walls — a paragraph is <= ~3-4 lines; break dense explanation into a TABLE (for comparisons / option lists), BULLETS with bold lead-ins, a fenced CODE block (for commands), or an ASCII / Mermaid DIAGRAM (for a flow or before/after). A flow is almost always clearer as a diagram than a paragraph. (5) EMOJI AS SIGNPOSTS, sparingly + consistently — ✅/❌/⚠️/ℹ️ to mark pass/fail/caveat/note, not decoration; one system per doc, never mid-sentence confetti. (6) BOLD ONE PHRASE PER POINT — the single key term, not every clause. (7) CONCRETE BEFORE ABSTRACT — show the command / diagram / example first, then generalize. The TEST: read each section once at skim speed; if you can't tell what it means or what to do, rewrite it. Graphics: ASCII + Mermaid + tables render natively on GitHub with no dependency and stay diffable — PREFER them; do NOT commit generated raster images into docs (binary, non-diffable, a runtime key dependency) — reserve a generated image for a README hero/logo where a raster truly earns it. This governs the WORDS + FORMATTING for readability; complements readme-brevity (front-door LENGTH), docs-quality (structure + polish + cross-links), and public-vs-internal-docs / doc-tiers (which TIER a fact belongs in). Internal docs may run denser, but the same habits still help. When in doubt, cut words and add a diagram.
Public Vs Internal Docs
Guidance only — Match the detail to the AUDIENCE — don't overload public readers. INTERNAL docs (CLAUDE.md) hold the full record: confirmed wire schemas, parsing mechanics (dedup rules, field maps), version regressions, spike narratives, env-validation checklists, the why-behind-the-why. PUBLIC docs (docs/*.md, README.md) state only what a USER needs to ACT — the status, the one or two genuinely useful insights, and how to use the feature — and keep the rest in the internal doc (the confirmed schema + full findings live in the internal doc, referenced from CLAUDE.md, NOT linked from the public page — see no-internal-links-in-public-docs). Do NOT paste a JSONL/event schema, an internal dedup/parse detail, a dependency's internal version quirk, or a spike story into a user-facing guide unless a user genuinely needs it to USE the feature. The test: would a user reading this to ACCOMPLISH a task be helped by this line, or just made to scroll? When in doubt, put it in the internal doc (and don't link it publicly). A reader does not care how a feature positions us; they care what it DOES, so every tier names the user-facing BEHAVIOUR (say 'the cross-referencing engine' / 'runs on your subscription'), never a competitive label. (Complements doc-per-rule + docs-quality + readme-brevity: those govern polish and where rule docs live; this governs which TIER a given fact belongs in.)
No Internal Links In Public Docs
Guidance only — PUBLIC docs (README.md + docs/*.md) must NEVER link to or name-drop an INTERNAL doc (any internal-only file). Internal research is the project's PRIVATE record — referenced ONLY from CLAUDE.md keyFiles, never from a user-facing page. A reader in the README or a docs/ guide should never be sent into internal design notes, half-finished spikes, or design notes for something unbuilt. When a public doc is tempted to link an internal doc, link the matching docs/ guide instead — and if none exists, that's the signal to write the public one (or just drop the pointer; the prose must stand on its own). GOTCHA: stripping a public→internal link can ORPHAN the internal doc (orphan-docs requires every opted-in .md be referenced somewhere), so when you remove the last inbound link, ensure the doc is still cited from CLAUDE.md keyFiles. NOTE: currently GUIDANCE only — the deterministic enforcement (a lint rule failing on an internal-doc link in README.md/docs/) is a P1 roadmap item; until it lands, hold the line by hand. Supersedes the old 'link to the internal doc for the rest' advice in public-vs-internal-docs (keep the FACT internal, just don't LINK it publicly). Complements readme-brevity (front door) + public-vs-internal-docs (tiering).
Doc Consistency
Guidance only — Docs MUST stay consistent with the code. When you ship, rename, or remove a CAPABILITY (a command, flag, exported symbol, key file, config key, or behaviour), update its docs in the SAME change, across BOTH tiers: the PUBLIC docs (docs/, README.md) AND the internal record (this file's keyFiles + positioning). Never let one tier describe a reality the other contradicts, and never leave a renamed command/path/example rotting in either. SPLIT THE WORK BY WHAT'S MECHANIZABLE — the lesson from the hook-runtime rename: a manual sweep leaked 8 stale vigiles <cmd> references EVEN WITH the cohesive-cli-surface rule freshly written, so guidance alone is not enough. The CHECKABLE half is ENFORCED by deterministic CI gates, never trusted to discipline: self-command-refs (every command reference in docs/comments resolves to a real verb or hook-runtime kind — src/self-command-refs.test.ts), orphan-docs (no docs/ file goes unreferenced), the markdown doc-refs validator (enforce/file/cmd/ref marks in fenced TS blocks resolve — OPT-IN since 2026-08-19, {"rules":{"doc-refs":"error"}}, because measured at 0 true positives across 2 582 markdown files: a fence in prose is a drawing of config, not config), and integrity (a compiled CLAUDE.md/AGENTS.md matches its spec). Where a doc-consistency fact CAN become a deterministic check, MAKE it one (analogical-transfer + don't-cry-wolf) rather than adding another guidance rule — the check is the mechanism, dogfooded on vigiles itself. The JUDGMENT half this rule names is what no check can verify: prose accuracy, which TIER a fact belongs in, cross-links, and keeping framing aligned with the front door — governed in detail by public-vs-internal-docs (tier), docs-quality (polish + 'no stale/renamed commands or dead example paths'), readme-brevity (front door), and rules-docs-in-sync (the rule set ↔ matrix). MEASURED LIMIT of the checkable half (don't widen a check into crying wolf): self-command-refs catches the vigiles <cmd> INVOCATION form only — a BARE command name (agent-hook without the prefix) or a BARE file path (src/foo.ts) is NOT auto-flagged, because both collide with legitimate non-references (concept names like 'the refs-hook nudge' in ~10 files, test labels, makeTmpDir names; illustrative paths like the README's src/auth/login.ts; test fixtures). A denylist there would fire on dozens of valid usages, so bare-name/path terminology accuracy is JUDGMENT, not a gate. The deterministic check is the FLOOR; updating both tiers together, every time, is the ceiling.
Document The Why
Guidance only — Docs must give every user-facing DECISION and CONCEPT a discoverable home that answers WHY / WHAT, not only a command-or-API reference for HOW. The repeatable failure this rule names (proven twice in one session: the opt-in 'why' needed two passes; the audit→behavioral→consent story was undocumented): each guide POLISHED the mechanics while the reasoning a user needs to make a decision — why a default is what it is, what audit runs by default (a deterministic READ) vs only under the measure-consent, why a tier is opt-in — lived ONLY in code + a research note, so users (and the next maintainer) re-derive it in conversation. For any user-facing DECISION (a default, a consent gate, the opt-in/strict ladder, the read-vs-run axis) or CONCEPT a user must reason about (a measurement axis like recall/precision, a tier, a safety boundary, selection-collision), there must be a plain-language PUBLIC home — a guide section or an FAQ entry — that states the WHY/WHAT in the user's terms. A named feature dropped WITHOUT explanation (a bare 'selection-collision matrix' mention with no definition) FAILS this: name-dropping is not documenting. THE TEST: could a user answer 'why is it like this / what does it actually do?' from the docs ALONE, without asking a maintainer? If the only answer lives in an internal note or in the code, the rule fails. This is the COVERAGE-OF-REASONING gap none of the sibling rules own: polish rules (readme-brevity / docs-quality / prose-clarity) govern how WELL a fact is written; placement rules (doc-tiers / public-vs-internal-docs / no-internal-links-in-public-docs) govern WHERE it lives; sync rules (rules-docs-in-sync / doc-consistency) govern that docs TRACK the code and don't contradict it — but none require that the REASONING behind a user-facing decision be documented AT ALL. It sharpens cohesive-feature-delivery item (6) from one checklist line into a standing, testable bar. DETERMINISTIC FLOOR (analogical-transfer — prefer a check to guidance where one fits): the inverse of self-command-refs — every public CLI verb is MENTIONED somewhere under docs/ — is mechanizable and catches a verb shipped undocumented (src/doc-command-coverage.ts, dogfooded in its test); the CONCEPT-coverage half (is this a concept that needs a why?) is undecidable and stays judgment. The check is the FLOOR; documenting the why for every user-facing decision is the ceiling.
Ts Essentials
Guidance only — Prefer branded types over plain strings for semantic values (hashes, file paths, rule IDs). Use tagged/discriminated unions freely whenever they help — over boolean flags that gate optional fields, and to MAKE INVALID STATES IRREPRESENTABLE (e.g. a result that carries a notice only on its kind:"notice" variant, never a nullable string the caller can forget to check). Add exhaustive default: assertNever(x) to every switch on a union type. Follow PARSE, DON'T VALIDATE: parse raw/untrusted input (a config value, a string | string[], a CLI flag) into a typed, normalized representation ONCE at the boundary, then pass that typed value around — don't re-validate or re-normalize the primitive at each use site. These patterns convert runtime bugs into compile-time errors.
Prod Grade Gha Cli
Guidance only — The CLI and the GitHub Action are production-grade, first-class surfaces — for most users CI is how vigiles actually runs — and must be kept that way, not treated as afterthoughts. The CLI is the single source of truth: every Action input maps to a real CLI flag (no config-file-only knobs the Action can't reach), commands emit GitHub annotations under GITHUB_ACTIONS, and exit codes are stable and documented (0 clean / 1 warn / 2 error). The Action wraps that CLI and must actually work as a published uses: zernie/vigiles@v1 reference — a composite action over the published npx vigiles CLI, so it reuses the same tested artifact rather than a node20 entry pointing at an uncommitted dist/. It must declare its outputs: and set them via $GITHUB_OUTPUT (never the deprecated ::set-output), pin to a stable floating major-version tag (v1) maintained by the release pipeline, and ship a full copy-paste workflow in the docs (runs-on, checkout, setup-node, permissions:, every input + the valid output, and @v1-vs-@main-vs-pinned-SHA versioning guidance). The Action surfaces results three complementary ways: inline GitHub annotations, a job summary ($GITHUB_STEP_SUMMARY), and an automatic STICKY pull-request comment (found by marker and updated in place — never a new comment per run; gated to pull_request events with pull-requests: write, best-effort so a fork PR without write access degrades to a warning, not a failure). Both surfaces are dogfooded in this repo's own CI (the Action via uses: ./, including the PR comment) and covered by tests, so a regression in the shipped entry points is caught here. See docs/cli.md.
Cohesive Cli Surface
Guidance only — vigiles is ONE cohesive organism, not a pile of stray commands — the CLI, the GitHub Action, and the agentic-coding flow are three FACES of the same capability set and must stay coherent. Four rules. (1) FEW HUMAN VERBS, each a clear mental action; a new capability EXTENDS the verb that already owns that action instead of spawning a sibling. 'Compile a typed authoring artifact into the harness's native format' is ONE verb — compile — whatever the artifact: a .spec.ts → markdown, a hook program → a settings block + stamp. NEVER ship a compile-hook-style stray sibling of a verb that already exists; fold it in. (2) TWO TIERS — VERBS vs RUNTIME ENTRYPOINTS. A verb is typed by a human/agent/CI (init/compile/lint/scan/test/eval). A runtime entrypoint is EMITTED into settings/hooks and invoked BY the harness, never typed by a human (the compiled-hook runtime, the agent/skill PreToolUse rails, effect markers, the refs nudge). Runtime entrypoints live under ONE hidden umbrella subcommand (vigiles hook-runtime <kind>) and stay OUT of the help/verb surface, so the organism reads as ~10 verbs not ~36 commands — a runtime entrypoint is an implementation detail of an emitted block, not a public command. (3) A VERB THAT PRODUCES WIRING WRITES IT. Emitting a paste-this blob is a stray half-step; the cohesive behaviour is to MERGE the result into the harness's native config (.claude/settings.json / config.toml) idempotently — found-by-marker, updated-in-place, never clobbering hand edits (the way init installs) — so the user's harness is actually wired, not handed homework. (4) COHERENT ACROSS THE THREE FACES. The same capability is reachable identically from the CLI verb, a GHA input that maps to that verb's flag (the prod-grade-gha-cli contract — no config-only knobs), and an agentic on-ramp (init wires it; a model-invocable skill reaches for it on the matching prompt — the great-agent-flow contract). A capability is NOT DONE until it is coherent on all three. When you add/rename/remove a command, change the CLI + the GHA input + docs/cli.md + the agent on-ramp TOGETHER (sibling of rules-docs-in-sync); renaming a runtime entrypoint BREAKS the emitted-command contract baked into every settings block + stamp, so it is a BREAKING change (! + regenerate every emitted block). ENFORCED, not just guidance: the self-command-refs dogfood (src/self-command-refs.test.ts) cross-references every vigiles <cmd> reference in the docs + comments against the real command set (src/cli-commands.ts) and FAILS CI on a stale ref — so a renamed command can't leave a rotting reference (it caught 8 a manual rename sweep missed). Complements prod-grade-gha-cli (the GHA/exit-code contract), great-agent-flow (the agent on-ramp), and smooth-adoption (init).
Cohesive Feature Delivery
Guidance only — A capability is ONE organism with MANY surfaces — and 'done' means coherent across ALL of them, not just the one you started with. This is the DEFINITION-OF-DONE CHECKLIST for any non-trivial feature; run it EVERY time, because the recurring failure is shipping the mechanism and missing the flow (the CLI lands but the agent never learns it exists; CC works but Codex is forgotten; the code ships but the docs re-research what was already decided). Before calling a feature done, walk this checklist OUT LOUD and name N/A explicitly (never silently skip a row): (1) CLI — the verb/flags exist, exit codes are stable, docs/cli.md updated (cohesive-cli-surface). (2) GHA — a real input maps to the verb/flag, no config-only knob, dogfooded in this repo's CI (prod-grade-gha-cli). (3) INIT — vigiles init scaffolds/wires it where it belongs (smooth-adoption), and does NOT do the invasive thing (e.g. NEVER edit the user's CLAUDE.md to convey a workflow — that's a hook/skill's job). (4) AGENT-AWARENESS — the agent can DISCOVER + maintain the feature: a model-invocable SKILL documents it (description always-in-context on CC) AND/OR a HOOK nudges at the right moment (great-agent-flow). Awareness is delivered by skill+hook, NOT by editing the user's instruction file. (5) BOTH HARNESSES — CC AND Codex are equals (harness-parity-and-extensibility + test-both-harnesses): the capability works on both, the emitted artifact is each one's native format, and where a harness genuinely can't do a piece YET it is a LOUD, documented deferral with the reason (e.g. hook BLOCK is shared via exit-2, hook INJECT is now confirmed on BOTH and encoded in HookProtocol.injectableEvents, and hook REACT output stays CC-confirmed-only — state the caveat at every site that relies on it), never a silent CC-only path. (6) DOCS — per-adapter specifics live in the per-adapter guide (docs/harness-testing-claude-code.md / -codex.md), the agnostic guide links DOWN to them (no duplication), and the DECISION/MECHANICS are captured ONCE in a research doc cited from keyFiles so the next session doesn't re-derive them (the 'researching the same stuff over and over' failure — capture-once, link-everywhere; obey public-vs-internal-docs + no-internal-links-in-public-docs + doc-consistency). (7) TESTS — both-harness coverage where the surface is harness-facing, 100% on the gated modules, loud skips. (8) DISCOVERY / FRONT DOORS — a user-facing capability is only done when it is FINDABLE where users and agents actually look, not just wired in the code. Sweep EVERY entry point as ONE organism: the CLI --help/usage, the README front door (a one-liner + link per readme-brevity — never buried), the WEBSITE (vigiles.sh / site/ — if it's marketing-relevant, reflect it, held to the landing-site skill), the recommended AGENT PROMPT (docs/agent-setup.md), the model-invocable SKILLS, and the INTERNAL contributor docs (src/CLAUDE.md etc.). The --ci-only flag is the worked failure: it shipped in the CLI but was invisible on the README, the site, the recommended prompt, and the internal doc — so an agent taking the full default never learned it existed (a flag nobody can DISCOVER is not done; the fix added a pointer in the non-interactive summary + help + agent-setup + README). (9) ONE SOURCE / OUTPUT PARITY — where the same information renders in more than one place, both render from ONE source (SSOT / shared renderers — the DRY principle applied to user-facing surfaces), never hand-copied literals that silently drift. The audit HTML report and the site demo already share @vigiles/report-view + the AuditReport JSON (one component library, two variants — summary vs full, the landing-site invariant), so a report change lands in both for free. The audit CLI TERMINAL output is a SEPARATE plain-text renderer that CANNOT share React components — so it is the DRIFT SEAM to watch: a string changed in the terminal (a nudge, a leaderboard header, a ring label) must be checked against the HTML, and shared DATA (the AuditReport / score) is the source both should read rather than duplicating a literal in one. The THROUGH-LINE: when you add a capability, the question is never 'does the code work?' but 'is the whole LOOP closed — a user/agent on CC or Codex can FIND it (README, site, --help, prompt, internal doc), run it (CLI/GHA/init), be reminded of it, and see it rendered CONSISTENTLY across every surface (terminal + HTML + site + docs)?'. If any row is unchecked or unaddressed, the feature is NOT done. This rule is the umbrella over cohesive-cli-surface (CLI/GHA/init), great-agent-flow (the agent path), harness-parity-and-extensibility + test-both-harnesses (CC+Codex), lead-with-easy-adoption + readme-brevity (the README/site front doors), and the docs rules (per-adapter + dedup + tiers); it exists so the INTEGRATION across them is checked as one thing, not missed piecemeal.
Dual Language Tests
Guidance only — Harness/eval test SCRIPTS must run whether authored in JavaScript (.mjs / .cjs / .js) or TypeScript (.ts / .mts / .cts) — neither language is second-class. vigiles test / vigiles eval discover both (the *.harness.* / *.eval.* glob in src/adapters/claude-code/run-scripts.ts) and run a TypeScript script through tsx when installed, else Node's native type stripping (Node >= 22.6), failing with an actionable message when neither is available. The typed vigiles/vigiles/eval API and the zero-setup CLI scripts are two on-ramps to the same tiers. vigiles's OWN unit test suite, by contrast, is uniformly TypeScript (every src/**/*.test.ts, run by vitest), so the project dogfoods the typed path; the .mjs files under examples/harness/ stay JavaScript on purpose as the copy-paste CLI-fallback demos (see docs/testing-matrix.md).
Analogical Transfer
Guidance only — The way to find a non-obvious check in the (young, ad-hoc) agent-harness space is ANALOGICAL TRANSFER: take a mature CS principle and map it onto the harness — even when it looks crazy at first, because the looks-crazy transfers are where the useful checks turn out to be. The organizing thesis they roll up into: MINIMIZE THE HARNESS STATE-SPACE / make invalid states UNREACHABLE — via four instruments (construct=spec/types so invalid states can't be expressed; verify=lint so they're detected+rejected; gate=hooks so they can't be entered in-loop; test=evals so the remaining valid space is proven). The agent-world adaptation: when the author is a model, 'make invalid states UNREPRESENTABLE' (type-system, edit-time, human audience) becomes 'make invalid states UNREACHABLE IN THE LOOP' (gating hook, loop-time, format-agnostic) — which is why the leverage is in the HARNESS, not in the authoring format. FILTER every transfer by don't-cry-wolf: keep only ones that yield a DETERMINISTIC, high-signal check or gate that shrinks the state space; if it needs a model or emits a fuzzy score, it's a cute analogy, not a check. Worked examples: object-capabilities + taint → the lethal-trifecta forbidden-state check; effect systems → declared-vs-observed effects; totality → every hook decides every event; MDL → derivable-content-is-redundant.
Doc Tiers
Guidance only — THIS REPO IS PUBLIC (open-source on GitHub) — every file in it, including this one, is a public disclosure. There are TWO documentation audiences here, and neither carries product strategy. (1) PUBLIC-USER — README.md + docs/*.md: what a USER needs to ACT, written as behaviour and benefit, no internal mechanics, no half-finished spikes. (2) CONTRIBUTOR — CLAUDE.md (compiled from CLAUDE.md.spec.ts) + code comments: how the shipped code WORKS and why it is shaped that way, so someone can change it safely. THE TEST BEFORE WRITING ANYTHING: does this describe something that SHIPS? If yes, it belongs in one of the two tiers. If it describes what we intend to build, how we would win, what a competitor is doing, or how the thing would be sold, it does not belong in this repository — see no-product-strategy-here. Never link a public page into internal notes (no-internal-links-in-public-docs).
Audit Side Effect Free
Guidance only — vigiles audit and every DEFAULT lint/report path MUST be SAFE TO RUN ANYWHERE — it reads, reports, and runs only confined or own-repo-trusted checks; it never mutates the user's world and never executes a STRANGER's code unconfined. The dividing line is provenance + cost, not 'no execution'. A static/deterministic check (structural health, description-overlap, reference resolution) runs directly. Two ORTHOGONAL axes, not one: PROVENANCE protects the HOST (foreign code → bubblewrap-or-skip), CONFINEMENT protects your EXTERNAL STATE (your own hook/server reaching a real Postgres/API; this is the STATE-protection half that provenance does NOT cover). A plain audit EXECUTES NOTHING — it is a deterministic READ (four rings + fixes + report), safe + identical on every OS. The TWO executing checks (live MCP + trigger-rate) are OPT-IN behind ONE consent (decideExecute): at a TTY audit ASKS ONCE (bundled prompt, discloses cost) and REMEMBERS (audit.measure); headless it stays a read + a one-line nudge. There is NO execution flag — audit is a LOCAL report (like Lighthouse), NOT a CI step (CI uses vigiles lint); AUTOMATION tests the harness via the vigiles testing API + skills, never the report verb. On consent: LIVE MCP is own-repo only (never a stranger's server) because STARTING a server connects to a backend and deny-all-net would break the tools/list it performs; the trigger-rate STUBS skill bodies so no skill PROCEDURE ever runs. THE SAFETY BATTERY is DELIBERATELY NOT an audit check (narrowed 2026-06-27, 'no half-made shit pre-release'): running ARBITRARY hooks safely needs CROSS-PLATFORM confinement, and that's parked — bubblewrap is Linux-only, the env-scrub ephemeral floor + macOS sandbox-exec are the unbuilt exit criterion — so rather than ship a Linux-confined/Mac-unconfined ring, the battery lives in the vigiles testing API (guardrail-check/assertBlocksDisasters, confined by src/sandbox.ts) where you opt in EXPLICITLY (a test you wrote, no zero-config-safety promise to break); audit re-promotes a Safety ring only once one confinement works the same on macOS+Linux. The two ORTHOGONAL axes still hold for what audit DOES run — PROVENANCE protects the HOST (live MCP is own-repo only), and the deterministic read touches nothing. So audit is trustworthy and zero-surprise EVEN pointed at a prod-wired repo: the default reaches nothing (a read), and execution happens only on an explicit consent that discloses what it will touch. This is what lets audit be the zero-config gateway.
No Orphan Docs
Enforced by: vigiles/orphan-docs
Why: Every .md in an opted-in directory (vigiles scans docs/, declared via the .vigilesrc.json orphans block) must be referenced from at least one other markdown file — README, a compiled spec's Key Files, or another doc. Orphan docs rot silently because nothing tells the agent they're still load-bearing. The rule is OPT-IN (off unless the orphans block is declared; include defaults to docs/) because 'unreferenced' only signals rot in a hand-cross-linked corpus, not a nav-managed doc site (Docusaurus/MkDocs) where the page graph lives in config — an OSS sweep confirmed ~100% false positives there. Inverse of stale-reference detection: stale-ref catches specs pointing at missing files, orphan detection catches existing files that no spec points at. Mechanical check in src/core/orphans.ts, surfaced by vigiles lint.
Recompile On Spec Change
Guard: *.spec.ts → npx vigiles compile
Why: Recompile instruction files when any spec changes.
Regen Types On Config Change
Guard: eslint.config.*, package.json, pyproject.toml → npx vigiles generate types
Why: Regenerate type definitions when linter configs or package.json change.
Format Check
Guard: **/*.ts → npm run fmt:check
Why: Verify formatting on TypeScript file changes.