# Kyberforge's Vale prefilter ships from the plugin, with `.pre-commit-hooks.yaml` for external git-hook/CI enforcement **Resolves:** ADR-0013's deferred "styles-portability" consequence — `.vale.ini`/`styles/` moving out of the repo root was deliberately deferred there, not fixed. ADR-0013's other content (rule scope, `level: error` model, `SentenceOpenerThereIs`/`VagueQualifier` trial outcomes) is unaffected and remains in force. `skill-audit`/`agent-audit`'s Step 1 called `"$(git rev-parse --show-toplevel)/scripts/vale-wrap.sh" --config "$(git rev-parse --show-toplevel)/.vale.ini"` — which resolves to whichever repo the skill happens to be running in. Inside `ai-development` that's this repo; in any external repo that installs `kyberforge@holocron` as a plugin, it's that repo's own root, which has no `.vale.ini` or `vale-wrap.sh`. The prefilter silently fell back to full LLM judgment every time outside this repo — the exact gap ADR-0013 named and deferred. ## Decision **Runtime (a live Claude Code session):** the Vale config, styles, and wrapper script move into the plugin itself, following the no-cross-skill-path rule already established in `skill-author/references/deployment-modes.md` (a plugin's cache-install only copies each skill's own files; there is no plugin-level shared directory). `agent-audit` needs both `Kyberforge` and `KyberforgeCopilot` (it lints `.agent.md` files), so `plugins/kyberforge/skills/agent-audit/assets/vale/` is the canonical, superset copy. `skill-audit` needs a second, smaller copy (`plugins/kyberforge/skills/skill-audit/assets/vale/`, `Kyberforge` only) since it cannot reference agent-audit's copy across the skill boundary. Both skills' Step 1 now resolve `scripts/vale-wrap.sh`/`assets/vale/.vale.ini` relative to their own directory, the same way `scripts/validate.sh ` already does — no new resolution mechanism, just applying the existing one consistently. **git hooks / CI outside a Claude Code session** have no plugin cache and no `${CLAUDE_PLUGIN_ROOT}` — a CI runner in particular is guaranteed not to have one. The mechanism that works there for any consumer, with or without Claude Code installed, is pre-commit's own hook-repo protocol: this repo now ships a root-level `.pre-commit-hooks.yaml` exposing `kyberforge-vale-audit-skill`, `kyberforge-vale-audit-agent`, and `kyberforge-skill-size-check`. Any external repo adds `repo: , rev: ` to its own `.pre-commit-config.yaml` and gets all three, fully decoupled from Claude Code. CI is the identical `pre-commit run --all-files` call, so the same manifest covers "possibly CI" from the original ask. **This repo's own dev-time gate** consumes the same plugin-bundled copies instead of a third root-level copy — per explicit instruction, this repo should be set up like any other consumer would be, not dogfood a special root-only path. The existing `repo: local` hook is retargeted (not removed): `entry:` now points at `plugins/kyberforge/skills/{skill-audit,agent-audit}/scripts/vale-wrap.sh`. `repo: local` is kept rather than switching to a pinned self-reference (`repo: , rev: `) — a pinned self-reference would lint working-tree edits against the *last tagged release*, not the change actually being made, which is wrong for the repo that *is* the source of the hook. This mirrors standard practice among hook-author repos (pre-commit's own `pre-commit-hooks`, `shellcheck-py`): `repo: local` for self-consumption, `.pre-commit-hooks.yaml` for everyone else, same underlying files and commands either way. **One hook per file-scope, not one combined hook.** The old root `.vale.ini` had both the `[**/SKILL.md]` and `[**/agents/*.md]`/`[**/*.agent.md]` glob sections in a single file, so one pre-commit hook covered both. Splitting the config into two skill-scoped copies means a single hook entry pointed at only one copy would silently 0-file-skip the other file type. Both the local `.pre-commit-config.yaml` hooks and the external-facing `.pre-commit-hooks.yaml` therefore define separate `-skill`/`-agent` hook IDs, each with a `files:` regex matching exactly what its target copy's glob covers. (Confirmed empirically before deleting the root files: retargeting a single hook at agent-audit's copy silently scanned 0 SKILL.md files.) **The hook `entry:` is the wrapper alone; the wrapper self-locates its config.** pre-commit prefixes only `entry[0]` with the hook-repo clone path (`cmd = (prefix.path(cmd[0]), *cmd[1:])`); every later argument is handed to the process untouched and so resolves against the *consuming* repo's root. A `--config plugins/kyberforge/skills/…/assets/vale/.vale.ini` in `.pre-commit-hooks.yaml` therefore named a path no consumer has, and every external run died with `E100 [--config] Runtime error`. The external-consumer contract this ADR exists to establish cannot be expressed as a `--config` argument at all — the config path has to be derived inside the process, from the script's own location. `vale-wrap.sh` accordingly defaults to its sibling `assets/vale/.vale.ini`, resolved from `${BASH_SOURCE[0]}`, whenever no `--config` is supplied; an explicit `--config` from any other caller still wins and still resolves against the caller's cwd, so both audit skills' Step 1 (`--config assets/vale/.vale.ini`) is unaffected. Both manifests now carry the identical argument-free `entry:`. Keeping them identical is part of the decision: the local `repo: local` hook resolved its `--config` correctly only because the consuming repo *was* this repo, and that one difference is why three review rounds exercised a code path no external consumer ever takes. **Vale's `StylesPath` resolves relative to the `.vale.ini` file's own location**, confirmed against `docs.vale.sh/keys/stylespath` — so a config path into the plugin finds that ini's sibling `styles/` regardless of the caller's cwd, whether it arrives as an explicit `--config` or as the wrapper's self-located default. No extra path-juggling is needed beyond `vale-wrap.sh`'s cwd-relative `--config`/path-argument handling and that fallback. **A sync-check catches drift between the two copies.** `scripts/check-vale-style-sync.sh` diffs `scripts/vale-wrap.sh` and `assets/vale/styles/Kyberforge/` between skill-audit and agent-audit (not `.vale.ini` — those legitimately differ, scoped to different glob sections), wired at `pre-push` alongside `check-manifests`. `.vale.ini` itself isn't diffed since divergence there is by design. **External `.pre-commit-hooks.yaml` consumers pin `rev:` to a tag, not a commit SHA.** This repo had no tags before this change; going forward, a `vX.Y.Z` tag is cut whenever hook-relevant files change, matching how every other `repo:` entry in this repo's own `.pre-commit-config.yaml` already pins (`v2.4.0`, `v8.21.2`, ...). ## Considered options **Keep a third root-level copy, dogfooded specially (rejected).** Simpler in that this repo's own hook wouldn't need retargeting at all. Rejected on explicit instruction: this repo should consume the same portability path an external repo would, not carve out a special root-only case that never gets exercised the way external consumers exercise it. **Publish styles as a hosted Vale package via `Packages = ` (deferred, not rejected).** Vale supports fetching a style from a direct `.zip` URL via `vale sync`, fully decoupled from Claude Code and from pre-commit's hook-repo protocol — usable by any repo, even ones that never install `kyberforge` at all. This is a larger, separate investment (a release/versioning pipeline for the package itself) not required to satisfy the current ask; noted here so a future reader doesn't wonder if it was overlooked. ## Consequences - Root `.vale.ini`, `styles/`, `scripts/vale-wrap.sh` are deleted. Two copies remain: `plugins/kyberforge/skills/agent-audit/assets/vale/` (canonical, superset) and `plugins/kyberforge/skills/skill-audit/assets/vale/` (subset, `Kyberforge` only). - `plugins/kyberforge`'s `plugin.json` and `.claude-plugin/plugin.json` both patch-bump for every shipped content change (per ADR-0006's version-parity invariant): `1.2.5` for the relocation itself, `1.2.6` for the self-locating `vale-wrap.sh` that followed. - **`.pre-commit-hooks.yaml` entries are a bare script path and nothing else — a constraint, not a house style, and it binds every future hook here, not just the Vale two.** Since pre-commit rewrites only `entry[0]` into the hook-repo clone, no argument token in any entry can reference a file this repo ships: a relative path resolves against the *consuming* repo and hard-fails, and the absolute path is unknowable at author time. A hook that needs one of its own bundled files must have the script self-locate it from `$0`/`${BASH_SOURCE[0]}`, exactly as `vale-wrap.sh` now does for `.vale.ini`. Anything else rediscovers this as another `E100`. `.pre-commit-config.yaml` stays byte-identical to the shipped manifest on those `entry:` lines so the local gate keeps exercising the same resolution path a consumer does. - `tests/test-vale-wrap.sh` now exercises skill-audit's copy specifically — its fixtures are all `SKILL.md`-shaped, and only skill-audit's `.vale.ini` has the matching glob section. - The first `vX.Y.Z` tag is cut once this change and its tests pass, giving external `.pre-commit-hooks.yaml` consumers something to pin. - **Cutting the tag is not left to memory.** `scripts/check-release-needed.sh`, wired at `pre-push`, hard-fails — but only when `PRE_COMMIT_REMOTE_BRANCH` (set by pre-commit's `hook-impl` for pre-push hooks) is `refs/heads/main` — if any path `.pre-commit-hooks.yaml` exposes changed since the last tag reachable from `HEAD`. It is a silent no-op on every other branch: hard-failing on feature-branch pushes mid-review would force a premature tag on a commit that might not survive a squash-merge, the exact problem `repo: local` (above) already avoids for this repo's own dev-time gate. A tag not existing at all is also a hard fail on `main`, covering the very first release. This is deterministic tooling, not a standing instruction to remember — consistent with `check-manifests.sh`/`check-vale-style-sync.sh` already using the same pre-push, main-agnostic-elsewhere pattern. - **Known limitation, not yet closed:** `check-release-needed.sh` only fires when a human runs `git push` locally with pre-commit's hooks installed — `PRE_COMMIT_REMOTE_BRANCH` is set by pre-commit's client-side `hook-impl` script parsing `git push`'s stdin protocol. A PR merged through Gitea's merge button (server-side, no local push) or a CI runner invoking `pre-commit run --hook-stage pre-push` directly never sets it, so the gate silently doesn't run in either path. This repo has no CI workflow yet (`has_actions` is enabled but unused), so closing this gap needs a server-side job re-running the same script on merge to `main` — deferred as a separate piece of infrastructure, not fixed here. `RELEASE_PATHS` is derived from `.pre-commit-hooks.yaml`'s own `entry:` lines rather than hand-maintained, so at least the set of paths it checks can't drift from the manifest on its own. - **Dropping `--config` moved the release gate's path derivation too.** `check-release-needed.sh` used to reach each hook's bundled assets through the `dirname` of its `--config` target. With no `--config` token left, that loop went dead and silently dropped both `assets/vale/` trees from release coverage — a Vale *rule* change could then land on `main` without demanding a tag, leaving consumers pinned to an old `rev:` running stale rules while the gate stayed green. The script now derives the bundle's `assets/` tree from `tokens[0]` instead (double-`dirname`, guarded on the candidate existing and on not resolving to `.`), which is the only derivation compatible with the argument-free `entry:` contract above. - **Accepted residual in the release gate (closed — see the update below):** deleting a hook's *entire* `assets/` tree is not flagged — the derived candidate path stops existing, so the guard drops it before it reaches the pathspec. Deleting individual files inside a surviving tree is flagged, and tested. **Update (commit `14c2c91`):** the accepted residual above no longer holds and is recorded here only as the state at the time this ADR was written. `check-release-needed.sh` no longer derives release-relevant paths from the worktree alone. It runs `collect_release_paths` twice — once over the worktree's `.pre-commit-hooks.yaml`, once over the manifest read back from `$LAST_TAG` via `git cat-file -p "$LAST_TAG:$HOOKS_MANIFEST"` — and unions the two path sets, so a path the tag exposed stays in the pathspec even after the worktree's `-d` guard drops it. Wholesale deletion of a hook's bundled `assets/` tree is therefore flagged, and `tests/test-check-release-needed.sh` (case 12) asserts exit 1 for exactly that case. The union does not over-fire: any manifest edit that makes the two disagree already touches `$HOOKS_MANIFEST`, itself a release-relevant path. An unreadable tagged tree (shallow clone, truncated fetch) fails closed rather than silently degrading to worktree-only derivation; a manifest simply absent at the tag — legitimate, it was added since — does not.