mirror of
https://github.com/zernie/vigiles.git
synced 2026-09-14 20:53:57 +08:00
fe12c14a30
* 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>
100 lines
3.5 KiB
TypeScript
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 [];
|
|
}
|
|
}
|