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
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.share deleted. Two copies remain:plugins/kyberforge/skills/agent-audit/assets/vale/(canonical, superset) andplugins/kyberforge/skills/skill-audit/assets/vale/(subset,Kyberforgeonly). plugins/kyberforge'splugin.jsonand.claude-plugin/plugin.jsonboth patch-bump to1.2.5for the shipped content change (per ADR-0006's version-parity invariant).tests/test-vale-wrap.shnow exercises skill-audit's copy specifically — its fixtures are allSKILL.md-shaped, and only skill-audit's.vale.inihas the matching glob section.- The first
vX.Y.Ztag is cut once this change and its tests pass, giving external.pre-commit-hooks.yamlconsumers something to pin. - Cutting the tag is not left to memory.
scripts/check-release-needed.sh, wired atpre-push, hard-fails — but only whenPRE_COMMIT_REMOTE_BRANCH(set by pre-commit'shook-implfor pre-push hooks) isrefs/heads/main— if any path.pre-commit-hooks.yamlexposes changed since the last tag reachable fromHEAD. 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 problemrepo: local(above) already avoids for this repo's own dev-time gate. A tag not existing at all is also a hard fail onmain, covering the very first release. This is deterministic tooling, not a standing instruction to remember — consistent withcheck-manifests.sh/check-vale-style-sync.shalready using the same pre-push, main-agnostic-elsewhere pattern. - Known limitation, not yet closed:
check-release-needed.shonly fires when a human runsgit pushlocally with pre-commit's hooks installed —PRE_COMMIT_REMOTE_BRANCHis set by pre-commit's client-sidehook-implscript parsinggit push's stdin protocol. A PR merged through Gitea's merge button (server-side, no local push) or a CI runner invokingpre-commit run --hook-stage pre-pushdirectly never sets it, so the gate silently doesn't run in either path. This repo has no CI workflow yet (has_actionsis enabled but unused), so closing this gap needs a server-side job re-running the same script on merge tomain— deferred as a separate piece of infrastructure, not fixed here.RELEASE_PATHSis derived from.pre-commit-hooks.yaml's ownentry:lines rather than hand-maintained, so at least the set of paths it checks can't drift from the manifest on its own.