Files
zernie fe12c14a30 docs: de-duplicate and tighten README intro (#45)
* docs: de-duplicate and tighten README intro

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat!: collapse runtime entrypoints under `vigiles hook-runtime`

The CLI had grown ~15 stray top-level commands that a human never types —
run-hook-program, agent-hook, skill-hook, skill-tool-hook, guard-hook,
action-hook, refs-hook, intercept-tool-hook, agent-start/done,
skill-start/done, run-skill, effect-enter/exit. These are RUNTIME
entrypoints: the harness invokes them via a block vigiles emits into a
hooks config; they are not verbs.

Collapse them under one hidden umbrella, `vigiles hook-runtime <kind>`,
kept off the help surface (verbs are typed; runtime entrypoints are
emitted). Every emitter now emits the new form; `refs` stays a verb.

Add the cohesive-cli-surface rule to CLAUDE.md encoding the verb-vs-
runtime-entrypoint taxonomy and the three-faces (CLI/GHA/agentic)
coherence requirement.

BREAKING CHANGE: the top-level runtime commands are removed. Any
already-emitted settings block referencing them must be regenerated with
`vigiles compile` (which re-emits the `hook-runtime <kind>` form).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat!: fold hook compilation into `vigiles compile`

`compile-hook` was a stray sibling of `compile` doing the same mental
action — compile a typed authoring artifact into the harness's native
format. Fold it in: one verb compiles both .spec.ts (→ markdown) and hook
programs (→ harness config + stamp).

Hook SOURCE now lives in the agnostic, committed .vigiles/hooks/ (the
typed hook imports vigiles/hook and compiles to ANY harness, so it must
not live in a harness's own .claude/). Bare `vigiles compile` discovers
specs AND .vigiles/hooks/*; explicit `compile <hookfile>` works too.

`compile` now WRITES the wiring instead of printing a paste-this block:
it MERGES the compiled block into the active harness's native config
(.claude/settings.json JSON / .codex/config.toml TOML) idempotently —
keyed by the hook path, so recompiling updates in place and never
clobbers the user's own hooks. One source dir also makes basenames
unique, retiring the stamp basename-collision edge.

New src/hook-install.ts holds the pure, tested merge/discovery helpers.

BREAKING CHANGE: the `compile-hook` command is removed; use `vigiles
compile` (it discovers .vigiles/hooks/ or takes an explicit hook path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: align docs, comments, and spec with the cohesive hook CLI

Phase 3 of the CLI cohesion pass — update every reference to the removed
`compile-hook` / top-level runtime commands:

- docs/compiled-hooks.md: rewrite "Compile and run" → "Compile and wire"
  (`vigiles compile` discovers .vigiles/hooks/*, MERGES into the native
  config, writes the stamp) + a new "Where things live" table answering
  where hook source/stamp/wiring live and that they're not auto-discovered
  by location alone.
- docs/cli.md: drop the compile-hook/run-hook-program entries; document
  hook compilation folded into `compile` + the hidden `hook-runtime`
  umbrella.
- README, eval-architecture, skills, verifying, unmarked-refs, e2e/bench
  scripts, research/*: mechanical command rename.
- CLAUDE.md(.spec.ts): keyFiles + positioning updated; add hook-install
  entries; recompiled.
- Source JSDoc + the safe-bash-guard example header point at the new
  commands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: correct 4 stale runtime-command usage strings missed by the manual sweep

Three `Usage: vigiles <kind>` error messages (run-skill, skill-start,
agent-start) and one JSDoc comment still printed the pre-umbrella command
form. They now say `vigiles hook-runtime <kind>`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: dogfood the cross-ref moat on vigiles's own command surface

A guidance rule alone didn't stop the rename from leaking stale
`vigiles compile-hook` / `run-skill` refs into the docs (it leaked 8,
even with the cohesive-cli-surface rule freshly written). Per vigiles's
own thesis — deterministic constraints over probabilistic compliance —
add the deterministic gate.

- src/cli-commands.ts: VERBS + HOOK_RUNTIME_KINDS, the single source of
  truth (a behavioural test keeps it honest vs the dispatch).
- src/self-command-refs.ts: pure detector — every `vigiles <cmd>` in a
  command context must resolve to a real verb or hook-runtime kind.
  High-precision: only inline code spans / shell fences / npx|Usage:|cli.js
  invocations; a bare unknown verb only when hyphenated or explicit.
- src/self-command-refs.test.ts: unit tests + the repo DOGFOOD (scans
  docs/README/CLAUDE.md/src/examples/hooks, not research/ the historical
  record) that fails CI on a stale ref.

Running it caught 3 more stale refs the manual sweep had missed
(tool-intercept + refs-hook test comments) — now fixed. This is what
'enforce updating the docs automatically' means: the cross-reference
moat, pointed at vigiles itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: dogfood the compile-merge against a real plugin's hook config

Close the merge half of the compiled-hooks OSS dogfood gap: compile a
vigiles gate INTO the REAL superpowers hooks.json (vendored, MIT,
SHA-pinned) and assert the merge is non-destructive — the plugin's own
SessionStart hook (incl. its async flag) survives, vigiles's PreToolUse
gate is added, and recompiling doesn't duplicate. Proves the new
settings-merge works on a real-world config, not just synthetic unit
fixtures.

The gate 'golden before' stays a faithful reconstruction: the canonical
disler hook is unlicensed (can't vendor), and an MIT alternative pulls in
a jq + /tmp runtime dependency that would make a committed CI test flaky —
a bad trade vs. the existing faithful reproduction + the documented real
measurement in research/hook-pain-points.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: add the doc-consistency rule + fix a tsc break in the merge dogfood

Add the standalone doc-consistency guidance rule asked for: when a
capability changes, update BOTH public (docs/, README) and internal
(research/, CLAUDE.md) docs in the same change. It names the split —
the CHECKABLE half is enforced by deterministic CI gates (self-command-refs,
orphan-docs, doc-refs, integrity), never trusted to discipline; the
JUDGMENT half (tier, prose, cross-links) is the discipline, governed by
the sibling docs rules. 'Mechanize the consistency fact where you can'
is the principle; the check is the floor, updating both tiers is the
ceiling.

Also fix a tsc error in the Commit-B merge dogfood: the spawnSync helper
annotation picked the Buffer overload (vitest's esbuild run didn't catch
it; npm run build does).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: add the prefer-existing-solutions rule

Codify the reflex this session kept surfacing ('is there an existing
solution?'): before building any non-trivial capability, search the
landscape and prefer adopt > compose > build. BUILD is justified only
when no existing option fits, it dogfoods the core moat, or every
option's cost outweighs a small purpose-built piece — and you must name
the prior art and why it was rejected (as the self-command-refs decision
did). Generalizes the existing dont-reimplement-linters +
compose-with-sync-tools stance into the default for all new work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: E2E dogfood the file-gate + react hook roles; document coverage

Close the two remaining compiled-hook dogfood gaps with real
CLI-runtime E2E tests (mirroring the gate/inject E2E):
- file-gate (defineFileGate + PathView.under): denies a Write under a
  protected path, allows elsewhere.
- react (defineReact): a notice reaches stderr + can't block (exit 0);
  run() executes its effect-classified command.

Document the full per-capability dogfood coverage matrix in
research/hook-pain-points.md (the internal record), incl. the two
deliberate non-coverages with reasons: the gate 'golden before' stays a
faithful reconstruction (the canonical hook is unlicensed; the MIT
alternative drags a jq+/tmp runtime dep), and inject/react have no
deterministic 'prove worth' oracle so they're proven structurally, not
scored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: add the test-both-harnesses rule + annotate the hook E2E harness scope

A test of a harness-FACING capability must cover BOTH Claude Code AND
Codex, never just CC by default — the test-layer companion to
adapter-aware-lint-rules. Three sub-rules: test what maps on both
(compile --harness=codex beside the CC default; agnostic detectors over a
non-CC layout); gate/skip LOUDLY where a harness can't run (no binary on
PATH) or a capability is deferred (Codex inject/react output); don't
double-test agnostic core. A genuinely harness-neutral path runs once but
must SAY WHY, so 'CC-only' is never ambiguous.

Dogfood it: annotate the new file-gate + react E2E with their harness
scope (deny→exit 2 and run()→spawn are neutral, one run covers both;
Codex emit is tested via --harness=codex; Codex notice/react output is
deferred).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: structural both-harness enforcement via a registry contract suite

Make the 'test both harnesses' gap structural, not disciplinary
(hexagonal contract-tests-over-the-registry pattern):

- src/adapter-contract.test.ts: runs the conformance kit over the WHOLE
  registry in a loop, so registering an adapter auto-subjects it to every
  contract (can't forget a new harness); a lacked capability is a VISIBLE
  it.skip(n/a) gated on shellHooks/harnessTesting (no silent CC-only
  pass); and a meta-test fails the build when a src/adapters/<dir> exists
  but isn't registered (or a declared prototype like opencode).
- Document the KNOWN GAPS in CLAUDE.md (test-both-harnesses rule): the
  contract catches ADAPTER-level gaps but can't auto-catch a capability
  assertion written CC-only OUTSIDE the contract (the judgment the rule
  governs), nor force OSS/real-binary coverage (gated loudly). Floor vs
  ceiling, stated not hidden.
- docs/authoring-an-adapter.md: 'how the registry tests your adapter
  automatically' for new-adapter authors.
- docs/testing-matrix.md: fix 5 stale test paths (adapter reorg) + the
  agent-hook->hook-runtime agent rename the prefix-scoped drift check
  couldn't see.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: correct 5 genuine stale path refs; document why bare-name drift can't be auto-checked

Investigating whether to widen self-command-refs to catch bare command
names (the agent-hook blind spot) — the data says no: bare names double
as CONCEPT names ('the refs-hook nudge' in ~10 files), test labels, and
makeTmpDir() names; bare file paths collide with illustrative examples
(README's src/auth/login.ts) and test fixtures. A denylist over either
would cry wolf on dozens of valid usages — so it stays the JUDGMENT half
the doc-consistency rule owns, not a check (measured, documented in the
detector header + the rule).

But the probe surfaced 5 REAL stale refs (fixed by hand):
- src/skill-runtime.ts → src/adapters/claude-code/skill-runtime.ts
  (docs/skills.md, agent-runtime.ts, inline.ts) — moved in the reorg.
- src/adapters/claude-code/mock-model.ts → src/mock-model.ts
  (codex/mock-model.ts, harness-driver.ts) — startMock lives at the root.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(research): hook modes & testing landscape analysis

Step-back analysis of the hook surface (check + run) vs the OSS/AI
landscape (OPA/Rego, Guardrails AI on-fail actions, NeMo Colang, Cedar,
lefthook, Claude Code's new prompt/agent hook types).

Verdicts (corrected from the first pass):
- DON'T restrict the runtime to TS-only: two opt-in lanes (typed TS with
  NO embedded shell + audited/sandboxable hand-written .sh), the
  OPA-coexists pattern; authoring-safety=capability vs
  runtime-safety=confinement are separate concerns.
- Modes collapse to ONE essential new mode "observe"/shadow (evaluate +
  record, never block - the gradual-rollout primitive) on the existing
  "enforce" default, NOT a 4-mode vocabulary (the warn/shadow split was a
  rendering detail, not a mode).
- Testing is tiered already; the gap is COVERAGE (OPA-style) + behavioral
  gate-precision + the model-gated prompt/agent hook types.

Ranked deliverables: observe mode (#1), hook-test coverage, react effect
ceiling, prompt/agent-hook verify+judge. Referenced from CLAUDE.md
keyFiles (orphan-docs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(research): 10-OSS dogfood — does the typed hook lane cover all use cases?

Mapped real hooks from 10 OSS sources (disler/claude-code-hooks-mastery
full event set, alexknowshtml safety hooks, vendored superpowers +
oh-my-claudecode, CC-docs canonical, ruff/ty validators, TTS/notify,
gmickel/flow-next, guide patterns) to the vigiles/hook vocabulary.

Verdict: NO, not all — and that's the two-lane decision working. The
typed lane nails the SAFETY-GATE slice (PreToolUse block: dangerous bash,
secret reads, curl|sh, protected-path edits) + simple react/inject. Gaps
cluster by root cause:
- PURITY (no exec / thin event shapes): dynamic-context inject, TTS,
  backups, structured logging, tool_response reactions -> these NEED I/O,
  so they belong in the shell lane; closing them in typed would break the
  capability guarantee.
- MISSING roles/shapes (closeable, worth it): gate-capable
  UserPromptSubmit (block/rewrite a prompt) + Stop (gate-until-tests),
  and richer event shapes (tool_response, prompt text, non-Bash
  tool_input) so more DECISIONS are expressible.

Refined scope: typed lane = DECISIONS over any event; shell lane = I/O &
lifecycle side-effects. Re-ranks deliverables: event/shape coverage for
decisions rises above the observe mode for breadth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(research): record the type-sourcing + licensing decision

Capture the conclusion from the black-box / Google-v-Oracle research:
CC's only official machine-readable artifact is the settings.json schema;
events/tools/payloads are prose-only, so the dialect catalog is
hand-maintained (and the detectors are conservative because of it). The
installed @anthropic-ai/claude-code (sdk-tools.d.ts + cli.js) is a
read-local freshness/drift source, NOT a dependency.

Licensing (informational): all-rights-reserved -> don't vendor or let
api-extractor INLINE their .d.ts into our shipped artifact (that's
redistribution; import alone isn't, but dts-bundling inlines by default).
Hand-write our own types matching the FACTS (names/fields aren't
copyrightable; DefinitelyTyped norm) + read the local install to warn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: dialect drift-check — read-local freshness alarm for the hand-maintained CC catalog

CC is a black box, so claudeCodeDialect is hand-maintained. Add the
deterministic backstop: read the user's INSTALLED @anthropic-ai/claude-code
(sdk-tools.d.ts tool-input types + cli.js event strings) and FAIL LOUD when
its surface drifts from our catalog — so a CC update that adds/removes a
tool or renames an event turns CI red instead of silently staling us.

- src/dialect-drift.ts: pure parsers (parseToolInputTypes,
  eventsMissingFromBundle) + findClaudeCodePackage (npm-root-g / claude
  binary). READS LOCAL ONLY — ToS-clean, never vendors/ships their types
  (all-rights-reserved). ACKNOWLEDGED_TOOL_INPUT_TYPES is our own list of
  bare identifiers (facts), diffed against the install.
- src/dialect-drift.test.ts: pure units + GATED read-local checks
  (validated vs claude-code 2.1.42: 19 tool types, 9 events); skips loud
  when CC absent.

Also fix 11 pre-existing unsafe-any lint errors the merge dogfood snuck
into hook.test.ts (typed the settings.json JSON.parse) — they'd have
failed CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(scan): use the dialect drift-check at runtime + update internal docs

The drift module was added but only consumed by its own CI test. Wire it
into the product so it's actually USED:

- src/dialect-drift.ts: add checkDialectDrift() (best-effort, read-local;
  reads only the small sdk-tools.d.ts — no cli.js scan on the runtime
  path; never throws) + formatDialectDrift() (one-line ⚠ ONLY on real
  tool-surface drift — a bare version bump emits nothing, no noise) +
  VALIDATED_CC_VERSION (2.1.42).
- src/cli.ts: `vigiles scan` (claude-code only) now prints the freshness
  warning when the installed CC's tool surface drifts from our catalog.
  Deliberately NOT in compile (hot recompile-on-save path).
- 2 formatter unit tests (null on no-drift / version-only; message on
  added+removed).

Docs updated in detail (doc-consistency): research/code-adapter-architecture.md
(the sourcing/licensing section now records the SHIPPED module + its two
consumers + the runtime-warn behaviour) and the CLAUDE.md keyFiles entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: note the scan dialect-freshness warning in cli.md

Public scan reference now documents the read-local Claude Code freshness
check (best-effort ⚠ on tool-surface drift; reads only the user's own
install; silent on a no-op version bump). Completes the doc-consistency
for the drift-check across internal + public tiers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(hook): observe mode + gate-capable UserPromptSubmit/Stop + tool response

Two compiled-hook expansions, both shipping for Claude Code AND Codex.

1. observe mode — every gate takes mode: 'enforce' | 'observe'. observe is the
   shadow/rollout primitive: 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(decision, mode)
   is the pure decision->action mapping the runtime and tests share; hookMode
   reads a gate's mode.

2. gate-capable non-tool events + richer event shapes:
   - definePromptGate (UserPromptSubmit) sees the prompt text (e.prompt) and may
     deny to block/erase it — a security filter.
   - defineStopGate (Stop/SubagentStop) may deny to keep the agent going
     (gate-until-tests-pass), honouring e.stopHookActive as the loop guard.
   - react now sees the tool RESPONSE via e.response (ResponseView:
     isError()/contains()).

Both new gates ride the shared exit-2 gate runtime, so they work on every
harness whose gate vetoes via exit 2 (compile-emit covered for CC and Codex).
The generic tool_input accessors (WebFetch/Task/MCP) stay deferred to avoid a
stringly-typed input bag.

Tests: pure (gateAction, prompt/stop decode, responseView, CC+Codex compile) in
hook-program.test.ts; E2E over the real runtime (prompt-gate deny, stop-gate
loop guard, observe records-not-blocks) in hook.test.ts. Public surface +
api report + docs/compiled-hooks.md + the research record updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(readme): cohesive pillar names — Verify→Lint, Measure→Eval

Align the four README pillar labels with the CLI verbs (and CLAUDE.md's own
"Lint"/"Test" layer names) instead of marketing renames that drifted from them:
Verify→Lint, Measure→Eval (Guard/Test unchanged). Keeps the differentiation in
the subtitles — Lint stays "is this TRUE, not just well-formed", Eval stays "the
eval you can afford" — so the wedge survives the plainer, cohesive labels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(research): design for I/O-dependent hook decisions via declared context providers

Captures the design (no code yet) for letting a compiled hook decide on external
state — git branch, project-specific facts — without breaking capability = API
surface. Grounded in prior art (Cedar entities/context, OPA input/data vs the
"ugly" http.send, Gatekeeper's registered-Provider model that deliberately
rejected http.send): the policy never fetches; the trusted host gathers facts and
hands them in.

Core reframe: the moat is "no UNDECLARED capability", not "zero I/O". decide()
stays pure; the runtime gathers a DECLARED set of read-only facts into e.ctx
(typed needs[], undeclared access = a tsc error).

The opt-out ladder (graceful degradation, never a dead end): Tier 0 built-in
matchers -> Tier 1 built-in providers -> Tier 2 user-declared providers (the long
tail, effect-classified, dangerously opt-out) -> Tier 3 shell lane (Turing-
complete). Proves total coverage by mapping every real-world hook from the 10-OSS
dogfood to a tier.

Cross-linked from hook-modes-and-testing.md; keyFiles + CLAUDE.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(hook): context providers v1 — gates can decide on git state via needs/e.ctx

Lets a compiled gate decide on EXTERNAL state (git branch, dirty tree, cwd)
without breaking capability = API surface. The pattern (Cedar/OPA/Gatekeeper,
research/hook-context-providers.md): the pure decide() never fetches; the trusted
runtime gathers a DECLARED set of read-only facts and hands them in as e.ctx.

- A gate declares `needs: ["git.branch"]` and reads `e.ctx["git.branch"]`. The
  gate builders are generic over the declared needs, so reading an undeclared
  fact is a tsc error, and an unknown provider name won't compile.
- New core/hook-providers.ts: the closed built-in registry (git.branch,
  git.isDirty, cwd — each total, defaults on failure), ProviderName/
  ProviderResults/HookCtx<N>, and gatherContext(needs, io) parameterized over an
  injected exec (testable, core depends on no child_process). A soundness test
  asserts every built-in command is read-only via bash-effects.
- The runtime injects the real execSync (cli.ts gatherHookContext); the hook
  itself still does zero I/O.

Long tail stays covered by the opt-out ladder (v2 user-declared providers, then
the shell lane) — designed in research/hook-context-providers.md.

Tests: pure (hook-providers.test.ts, hook-program.test.ts) + E2E in a real git
repo (hook.test.ts: deny push on main, allow on a branch). Public surface + api
report + docs/compiled-hooks.md + CLAUDE.md updated. Additive — hooks without
`needs` are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(research): 20+ OSS provider survey + the lightweight opt-out design

Answers the "what built-ins do we need + how should the quick opt-out look"
questions, grounded in research.

- Survey of ~21 real hooks (aiorg.dev 20+ catalog, CC docs, the vendored
  superpowers/omcc/wshobson slices, disler/dcg): MOST facts a hook reads are
  EVENT data (path/command/tool/stop-flag/session-type), not providers — so the
  built-in set stays tiny. Verdict: git.branch/git.isDirty/cwd (shipped) +
  os.platform; everything else (env, test/lint status, rate-limit, online) →
  opt-out, not a growing catalog.

- The lightweight opt-out, judged against keeping decide() pure: an INLINE
  declared command in needs — provide(name, cmd) (read-only, compile-rejected if
  not) / dangerously(name, cmd) (acknowledged escape, greppable). Rejected: a
  method-in-decide (breaks purity), @ts-expect-error (grants no capability —
  category error), and a whole-hook I/O mode (that's just the shell lane). Naming
  follows the loud-escape-hatch best practice (dangerouslySetInnerHTML / unsafe /
  http.send / our own purity:'dangerously-unrestricted').

- Event naming: native + dialect-validated, NOT hardcoded to CC (PreToolUse is in
  both dialects); a generic alias layer is deferred until a divergent harness
  lands (rule-of-three; the dialect is the seam). Fixed a stale "raw CC PreToolUse
  event" comment.

Build order updated: v1 built-ins (shipped) -> os.platform -> inline
provide/dangerously -> v2 defineProvider. CLAUDE.md keyFiles updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(research): record the one-source-many-harnesses event-naming decision

A single hook source already targets CC + Codex today (author once in
.vigiles/hooks/, compile emits each native config; the typed program, context
gather, and gate runtime are harness-neutral). Native event names work for both
because the dialects share them; the inject/react OUTPUT shape is the one
CC-confirmed-only caveat (compile --harness=codex warns).

Decision: native + dialect-validated names, NO generic alias layer yet — it would
only pay off for a future harness with divergent event names (none today), so
it's deferred to a small per-dialect map at the dialect seam, additive when needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(claude-md): add harness-parity-and-extensibility design rule

A design-time umbrella over the existing enforcement rules: when designing ANY
capability (compile emit, runtime, ports, subsystems — not just lint/test), (1)
fully support BOTH shipping harnesses (Claude Code AND Codex) as equals, never
CC-first-Codex-bolted-on, with loud documented deferrals where a capability can't
map yet; (2) leave room for future adapters by putting every harness-specific fact
behind a port and threading the resolved adapter (core ⊄ adapter), so adapter #3
is a new object not a core edit; (3) design the neutral shape first but defer the
abstraction until a second divergent implementation earns it (the seam exists, the
mapping is added when needed). Points to its enforcement arms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(hook): os.platform built-in + inline provide()/dangerously() context opt-out

Closes the provider/long-tail story (research/hook-context-providers.md), grounded
in a 20+ OSS survey: most facts a hook reads are already event data, so the
built-in set stays small and the long tail goes to an opt-out, not a growing
catalog.

- os.platform built-in (ambient process.platform) — the one extra fact the survey
  justified (per-OS hooks). Built-ins now: git.branch/git.isDirty/cwd/os.platform.
- The lightweight opt-out: an INLINE command in `needs` — provide(name, cmd)
  (read-only; compile rejects a non-read-only command via unsafeInlineProviders)
  or dangerously(name, cmd) (the loud, greppable escape, the
  dangerouslySetInnerHTML/unsafe convention). The runtime runs it and hands stdout
  in as e.ctx[name]; decide stays pure. Chosen over a method-in-decide (breaks
  purity), @ts-expect-error (grants no capability), and a whole-hook I/O mode
  (that's the shell lane).

NeedSpec = ProviderName | InlineProvider; HookCtx is a key-remap mapped type over
it (built-in name → typed value, inline → string), undeclared access still a tsc
error. Decode fns are generic over N; AnyHook uses an erased any-needs because a
gate's decide is contravariant in N (documented).

Tests: pure (hook-providers.test.ts: os.platform, inline gather, unsafeInline,
unknownProviders; hook-program.test.ts: inline gate + provide-not-read-only
rejection) + E2E (hook.test.ts: an inline provide() fact gathered by the real
runtime drives a deny). Public surface + api report + docs/compiled-hooks.md +
CLAUDE.md updated. Additive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(hook): v2 registered providers + aggressive OSS comparison dogfood

v2 — REGISTERED context providers (the reusable, named tier of the opt-out
ladder). Author a fact once in .vigiles/providers/<name> via
defineProvider({ name, run }) and reference it from any hook by provider("name")
→ e.ctx[name]. The runtime discovers + loads the registry (loadProviderRegistry)
and resolves refs in gatherContext; `vigiles compile` validates each provider is
read-only (unless dangerous:true, via unsafeProvider) and that every provider()
ref resolves to a registered file (a dangling ref fails compile). NeedSpec is now
ProviderName | InlineProvider | RegisteredRef; decode fns are generic over N and
AnyHook uses an erased any-needs (a gate's decide is contravariant in N).
Surface: defineProvider/provider + types on vigiles/hook.

Aggressive OSS comparison — src/hook-oss-comparison.test.ts isolates the
NON-CIRCULAR structural wins over hand-written guards, one failure mode per test
(evasion: the compound `git push -f` a substring/glob misses; precision: a grep
false-positive on a benign echo; protocol: an exit-1 false-confidence guard) plus
the breadth headline (2/7 blocklist vs 7/7 compiled). research/hook-oss-comparison.md
documents the head-to-head, honest about both the labelled-breadth caveat and what
compiled hooks do NOT do better (stateful / broad-I/O / delivery → shell lane).

Tests: pure + E2E (a .vigiles/providers/ file resolved by the real runtime) +
the comparison battery. Public surface + api report + docs/compiled-hooks.md +
CLAUDE.md updated. Additive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(cli-install): loud-skip the codex e2e when `skills add` can't reach the network

The e2e's guard probed `npx skills --help` (reachable via npm) but the real
`skills add` does a GitHub fetch — so in a partial-network sandbox it slipped past
the skip-guard and failed RED, contrary to its own documented contract
("self-skip loudly where the network isn't reachable"). Now a network-class
failure of the real `add` (read from stderr) is re-classified as a loud
ctx.skip(); a genuine install bug still throws, and CI (GitHub reachable) still
runs the full assertions. Pre-existing flakiness, unrelated to the hook work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(hook): add git.root + env.isCI built-in providers (env.isCI adopts ci-info)

The two extra cheap-ambient facts the 20-OSS survey justifies, closing the
built-in set at git.branch/git.isDirty/git.root/cwd/os.platform/env.isCI (small
by design; the long tail stays in the opt-out tiers).

- git.root — read-only `git rev-parse --show-toplevel` (the soundness test
  confirms it classifies read-only); pairs with cwd/path decisions.
- env.isCI — ADOPTS the ci-info library (de-facto standard, ~30+ CI vendors)
  rather than hand-rolling an env check; injected via ProviderIO.isCI so core
  stays dep-free (the dep lives at the CLI composition root). The
  prefer-existing-solutions split: git facts stay read-only shell (a JS git lib
  would bypass the bash-effects soundness check + add a dep for a one-liner),
  platform is process.platform, and the provider-registry architecture has no
  embeddable lib (the OPA/Cedar/Gatekeeper pattern, built here).

Tests, api report, docs/compiled-hooks.md, research/hook-context-providers.md,
CLAUDE.md + package-lock all updated. Additive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: cover registered providers in cli.md + the compiled-hooks "where things live"

Close the two doc gaps the providers work left: docs/cli.md's `compile` section
now documents context providers (built-ins + inline provide/dangerously +
registered .vigiles/providers/ discovery/validation), and the compiled-hooks
"Where things live" table gains the .vigiles/providers/ source row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(docs): publish the generated API reference to GitHub Pages + link it

Adds .github/workflows/api-docs.yml: on push to main, generate the API Documenter
markdown (api-extractor doc model → api-documenter), make it Jekyll-renderable
(inject front matter + a minimal _config with jekyll-relative-links so the
generated ./foo.md links resolve to .html), and deploy to GitHub Pages via the
official configure/build/upload/deploy-pages flow. api-reference/ stays gitignored
(generate-not-commit, ~1900 pages); the committed surface artifact remains
etc/*.api.md (already CI-gated).

Linked the site (https://zernie.github.io/vigiles/) from the README "More" row,
the docs index Reference section, and the compiled-hooks + harness-testing
"See also" (the hook/testing public surfaces).

NOTE: requires a one-time repo setting — Settings → Pages → Source: "GitHub
Actions" — and the links go live after the first main deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(docs): switch the API-reference site from api-documenter to TypeDoc

api-documenter pages EVERY member (one file per interface field) → ~1900 flat
markdown files, the wrong shape for a TS library, and needed a Jekyll/front-matter
hack to render. Switch to TypeDoc (the de-facto TS API-docs tool):

- typedoc.json: curated to the public AUTHORING entry points (spec/linting/
  testing/hook/adapter/claude-code/codex — not the unit/integration/e2e re-export
  barrels or the tiny vitest/jest matchers), public-only (excludeInternal),
  members rendered INLINE.
- ~465 navigable pages with a sidebar + search (vs 1900 flat files), and
  `.nojekyll` (githubPages:true) → Pages serves the HTML as-is, so the
  api-docs.yml deploy drops the Jekyll/front-matter steps entirely.
- ci.yml smoke step + the docs:api script now run typedoc; @microsoft/api-
  documenter removed. api-extractor (etc/*.api.md surface gate) is unchanged.

README + docs links unchanged (same https://zernie.github.io/vigiles/ URL).
Still requires the one-time repo setting: Settings → Pages → Source: GitHub Actions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(api)!: curate testing/linting barrels — drop internal seams from the public surface

Replace the `export *` re-exports in `vigiles/testing` and `vigiles/linting`
with curated named re-exports, so the internal DI seams (`*With` runners),
low-level parsers (`parse*`, `decideHook`), pool/aggregate/model-tier helpers,
and the linter cross-reference ENGINE no longer leak into the public .d.ts
surface, the api-extractor reports, or the TypeDoc site. The CLI imports those
from the source modules directly; they were never meant to be public.

Also demote a few doc-comment `{@link}` refs (`ephemeralRunEnv`, `spawnAgent`,
`runEvalWith`) that pointed at now-internal symbols to plain code spans, so the
generated API site has no dangling links.

Net effect: api report drops ~311 lines across the two surfaces; the TypeDoc
site drops 465 → 407 pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(lint): ban internal imports of the public barrel entry points

Add a no-barrel-imports ESLint rule so internal modules import the LEAF that
defines a symbol, never the package's own public barrel surfaces
(vigiles/{linting,testing,unit,integration,e2e,hook,claude-code,codex}). Barrel
imports pull the whole re-export graph (slow in the test runner / any
non-treeshaking consumer, a circular-import risk) and re-leak the internal
seams the curated barrels deliberately drop — so this locks in the preceding
barrel curation.

Implemented with the built-in `no-restricted-imports` rule rather than
eslint-plugin-barrel-files: that plugin's `avoid-importing-barrel-files` calls
the ESLint-9-removed `context.getFilename()` and crashes on ESLint 10, so per
prefer-existing-solutions a working core rule beats a broken dependency. The
barrels themselves (the e2e→integration→unit tier ladder) and test files are
exempt; the codebase is already clean, so this is pure regression-prevention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(dialect): refresh Claude Code dialect baseline to 2.1.187 + survive the native-binary distribution

The gated dialect-drift alarm fired in CI because the unpinned `npm i -g
@anthropic-ai/claude-code` now installs a much newer CC than the 2.1.42 baseline:

- sdk-tools.d.ts grew from 19 to 38 tool-input types — the agent-PLATFORM surface
  (Cron*, Task* CRUD, Workflow, Monitor, Enter/ExitWorktree, EnterPlanMode,
  Projects, PushNotification, REPL, ReadMcpResourceDir, RemoteTrigger,
  ScheduleWakeup, ShowOnboardingRolePicker, Artifact) was added; Config removed.
  These are HOST/platform tools, not subagent-grantable, so claudeCodeDialect's
  builtinAgentTools (the `tools:` frontmatter catalog) is intentionally unchanged —
  they're acknowledged as facts only.
- CC ≥ ~2.1.18x switched to a NATIVE-BINARY distribution (bin/claude.exe from a
  platform optionalDependencies package) with NO readable cli.js — so the
  hook-event text-scan ENOENT-crashed. Added findClaudeCodeBundle(): the event
  check now degrades to a LOUD SKIP when there's no readable JS bundle, while the
  sdk-tools.d.ts tool-type alarm keeps working.

Refreshed ACKNOWLEDGED_TOOL_INPUT_TYPES (38) + VALIDATED_CC_VERSION = 2.1.187.

We deliberately do NOT `import type` the SDK's ToolInputSchemas union: both
@anthropic-ai/claude-code and @anthropic-ai/claude-agent-sdk are "© Anthropic PBC.
All rights reserved." (proprietary), and vigiles is MIT + multi-harness, so we keep
reading the user's local install (ToS-clean) against a hand-authored list of bare
identifiers. Recorded in the dialect-drift header + research/code-adapter-architecture.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(harness): repair stale refs-hook command in the refs-nudge example + teach self-command-refs the ${CLI} form

The refs-nudge harness dogfood called `node ${CLI} refs-hook`, but the runtime
entrypoint was consolidated to `hook-runtime refs` in the hook-runtime rename —
so the PostToolUse hook hit "Unknown command", emitted no additionalContext, and
the nudge never reached the model (the CI `harness` job's only failure). Fixed the
example to `node ${CLI} hook-runtime refs`; verified the nudge lands in a real
claude session.

The stale ref slipped past the self-command-refs gate because the detector only
recognized `vigiles`/`cli.js` invocation literals — not the harness-test
convention `node ${CLI} <cmd>` (`${CLI}` = a `dist/cli.js` URL). Extended INVOKE
to recognize `${CLI}` (handling that `$` is not a word char, so `\b` can't anchor
it) so this whole class is now caught deterministically — dogfooding the
cross-ref moat on vigiles itself. The repo dogfood + 46 existing `${CLI}` usages
stay green (no false positives); added a unit test for the class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: pin claude-code to VALIDATED_CC_VERSION so the dialect-drift alarm is deterministic

CI installed `@anthropic-ai/claude-code` UNPINNED, so any new CC release fired the
(intentional) dialect-drift alarm on unrelated PRs — the noise that just broke this
branch's CI. Pin every job that drives the real binary (the test, harness, and e2e
tiers) to `@anthropic-ai/claude-code@<VALIDATED_CC_VERSION>`, grepped from
src/dialect-drift.ts so the constant is the SINGLE knob: bumping it updates both what
CI installs and the alarm baseline (the gated test cross-checks them).

Two wins: the alarm now fires only on a DELIBERATE bump (not a random upstream
release), and the real-`claude` harness/eval tiers become reproducible across CC
releases (no surprise behavior breaks like the refs-nudge PostToolUse change).

Runtime mismatch warning already exists and needs nothing: `vigiles scan` calls
checkDialectDrift/formatDialectDrift, which prints a one-line ⚠ ONLY on real
tool-surface drift (a bare version difference with no new/removed tool stays quiet —
the right precision), best-effort and never throwing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: refresh HANDOFF for the API-curation + CC-2.1.187 + CI-pin session

Overwrite the stale compiled-hooks-arc handoff with this session's state on
branch claude/readme-duplication-cleanup-l1wc5n (PR #45): barrel curation, the
no-barrel-imports rule, the CC 2.1.187 dialect refresh (native-binary
distribution), the refs-nudge + self-command-refs ${CLI} fix, and the CI pin —
plus the decisions of record (don't import the proprietary SDK types; bump
VALIDATED_CC_VERSION + ACKNOWLEDGED together).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: fix HANDOFF instructions — say to commit + push (ephemeral, git-tracked)

The header said "overwrite each session" but never that the file is git-tracked
and the container is ephemeral, so an update persists ONLY if committed + pushed
(a local overwrite is lost when the session ends). Spell that out, plus when to
refresh and what to include.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 19:47:06 +07:00

100 lines
3.5 KiB
TypeScript

/**
* vigiles — Action gates (the dynamic-workflow reframe).
*
* A skill gate is bound to a *step* (a fixed position in a plan). When the plan
* is generated at runtime (dynamic workflows), the step is the wrong unit. An
* **action gate** binds a deterministic check to a *tool action type* instead —
* "any time a Write happens to a `.ts` file, eslint must pass on it" — so it
* fires regardless of where in the runtime plan the action occurs.
*
* It is the same deterministic gate primitive (reuses `runGate` + the
* author-time reference resolution), re-anchored from step → action. Delivered
* as a PostToolUse hook (`vigiles hook-runtime action`): exit 2 blocks the action and
* feeds the reason back, exit 0 allows it.
*
* Config: `.vigiles/action-gates.json` → `{ "gates": [ { on, gate, when? } ] }`.
* The gate command may contain `{file}`, substituted with the action's path.
*/
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
runGate,
gateLabel,
type RuntimeGate,
} from "./adapters/claude-code/skill-runtime.js";
export interface ActionGate {
/** Tool name to gate, e.g. "Write" | "Edit" | "Bash". */
readonly on: string;
/** Deterministic check; a `cmd` command may include `{file}`. */
readonly gate: RuntimeGate;
/** Optional substring the (JSON-serialized) tool input must contain. */
readonly when?: string;
}
export interface ActionEvent {
/** The tool that just ran (PostToolUse `tool_name`). */
readonly tool: string;
/** The tool input (`tool_input`), e.g. `{ file_path, command }`. */
readonly input?: Record<string, unknown>;
}
export interface ActionDecision {
readonly allow: boolean;
readonly message: string;
}
/** The file path an action touched, for `{file}` substitution. */
function fileOf(event: ActionEvent): string {
const i = event.input ?? {};
const v = i.file_path ?? i.path;
return typeof v === "string" ? v : "";
}
/** Substitute `{file}` in a cmd gate with the action's path. */
function resolveGate(gate: RuntimeGate, event: ActionEvent): RuntimeGate {
if (gate.kind !== "cmd" || !gate.command.includes("{file}")) return gate;
return { ...gate, command: gate.command.replaceAll("{file}", fileOf(event)) };
}
/**
* Evaluate action gates against a tool event. Runs every gate whose `on`
* matches the tool (and whose `when` substring matches the input); the first
* failure blocks. Plan-agnostic — order in any runtime workflow is irrelevant.
*/
export function evaluateAction(
event: ActionEvent,
gates: readonly ActionGate[],
cwd: string,
): ActionDecision {
const inputStr = JSON.stringify(event.input ?? "");
for (const g of gates) {
if (g.on !== event.tool) continue;
if (g.when && !inputStr.includes(g.when)) continue;
const outcome = runGate(resolveGate(g.gate, event), cwd);
if (!outcome.ok) {
const tail = outcome.output ? `\n${outcome.output}` : "";
return {
allow: false,
message: `Action gate failed after ${event.tool}: ${gateLabel(g.gate)} did not pass.${tail}`,
};
}
}
return { allow: true, message: "" };
}
/** Load action gates from `.vigiles/action-gates.json`. */
export function loadActionGates(cwd: string): ActionGate[] {
const p = resolve(cwd, ".vigiles/action-gates.json");
if (!existsSync(p)) return [];
try {
const parsed = JSON.parse(readFileSync(p, "utf-8")) as {
gates?: ActionGate[];
};
return Array.isArray(parsed.gates) ? parsed.gates : [];
} catch {
return [];
}
}