Files
holocron/docs/adr/0014-vale-prefilter-ships-from-the-plugin.md
Defame1297 acd2f1d422 fix(lint): harden check-release-needed.sh, script the vale-style sync
A review of PR #85's last two commits (1164f3a, 4d018af) found the new
release-gate script fails open in four separate ways, and the new drift
check for the duplicated Vale styles only ever detects drift after a
human already hand-edited both copies out of sync.

check-release-needed.sh:
- The `-e` existence filter dropped a RELEASE_PATHS entry from the diff
  pathspec once it was deleted from the tree, so deleting a path exposed
  via .pre-commit-hooks.yaml since the last tag passed the gate clean —
  exactly the breakage the gate exists to catch. git diff reports
  deletions fine without an existence check; the filter is gone.
- `git diff ... 2>/dev/null || true` turned any git failure (a shallow
  clone missing the tag's objects, a corrupted ref) into an empty,
  falsely-clean diff. The diff result is no longer swallowed: a failure
  now hard-fails with the underlying git error visible.
- RELEASE_PATHS was a hand-maintained array duplicating
  .pre-commit-hooks.yaml's entry: paths with only a comment holding them
  in sync, and was already over-broad (it swept in validate.sh /
  validate-provenance.sh, which no hook entry references). It's now
  parsed straight from .pre-commit-hooks.yaml's entry: lines at
  runtime, so it can't drift from the manifest and only tracks what a
  hook actually exposes.
- `git describe --tags --abbrev=0` accepted any tag reachable from HEAD
  as the diff baseline, not just release tags. Added
  `--match 'v[0-9]*.[0-9]*.[0-9]*'` so an incidental checkpoint tag
  can't shift the baseline and mask a real release-relevant change.

check-vale-style-sync.sh still only detects drift between skill-audit's
and agent-audit's duplicated vale-wrap.sh/styles/Kyberforge copies
(both copies must exist independently per the plugin's no-cross-skill-
path packaging rule — a symlink would break at install time). Added
scripts/sync-vale-styles.sh to regenerate skill-audit's copy from
agent-audit's canonical one on demand, and pointed the sync check's
failure message at it, so fixing drift is one command instead of a
hand diff across two files.

Also recorded, rather than silently left unfixed: check-release-needed.sh
only fires on a local `git push` through pre-commit's pre-push hook — a
PR merged via Gitea's merge button, or CI invoking
`pre-commit run --hook-stage pre-push` directly, never sets
PRE_COMMIT_REMOTE_BRANCH and skips the gate entirely. Closing that needs
a server-side CI job this repo doesn't have yet; documented as a known
limitation in ADR-0014 rather than papered over.

Separately, LESSONS.md's "a clean check can mean nothing ran" entry was
marked **Graduated** without ever being promoted per the repo's own
graduation rule (3+ instances → a standing doc, marked
`[graduated → target file]`). Actually promoted it into
core/instructions/testing.md and fixed the marker.

tests/test-check-release-needed.sh gained 4 regression tests, one per
check-release-needed.sh fix above, each verified to fail against the
pre-fix script and pass against the current one.

Verification: bash tests/run-tests.sh (11 scripts + 125 bats, all
passing), pre-commit run --all-files, and
pre-commit run --all-files --hook-stage pre-push all clean.

ADR: 0014
2026-08-09 11:20:55 +00:00

8.7 KiB

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 <skill-dir> 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: <this-repo-url>, rev: <tag> 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: <own-url>, rev: <tag>) — 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.)

Vale's StylesPath resolves relative to the .vale.ini file's own location, confirmed against docs.vale.sh/keys/stylespath — so --config <path-into-plugin>/.vale.ini correctly finds that ini's sibling styles/ regardless of the caller's cwd, with no extra path-juggling needed beyond what vale-wrap.sh already does for its cwd-relative --config/file-argument handling.

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 = <zip-url> (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 to 1.2.5 for the shipped content change (per ADR-0006's version-parity invariant).
  • 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.