Why The two audit skills carried 1,724 lines of byte-identical duplication: the ADR-0020 boundary resolver (1,061), vale-wrap.sh (526), the Vale style rules (44) and the Contributing-files parser (93). Nothing shared them — they were held in sync by a 413-line pre-push gate and its 797-line test suite. Sync-by-gate had already failed once: at484357athe two parser copies drifted into different spellings of the bullet loop while a docstring asserted they were identical. That drift was behaviour-neutral and was re-unified by hand at598a7c3, so the copies were identical at merge time — but nothing had caught it, and the next drift need not be neutral. Implementation Notes Self-containment binds BETWEEN skills, not within one. The agentskills.io spec forbids reaching across skill directories, which is why two separate skills needed embedded copies; two files inside ONE skill may source a third. That is the whole reason the merge removes duplication rather than relocating it. The union of both bodies measured 1,532 words against BODY_MAX_WORDS=900, and only 211 of those words were shared, so SKILL.md is a dispatch body. Step 0 resolves the flow from the target path before any validation, and its table mirrors validate.sh's detection exactly: a directory holding SKILL.md or a SKILL.md file (skill); a *.agent.md, or a .md directly under an agents/ directory (agent); anything else stops without running a validator. Steps 1-3 live in references/skill-flow.md and references/agent-flow.md, and gotchas that apply to one flow live in that flow's file, since it is loaded on every invocation anyway. If validate.sh reports on the other artifact type, the body restarts at Step 0. Named factory-audit rather than forge-audit because forge is a live skill, and a family prefix that matches a live sibling reads as ownership rather than membership. The description carries one arrow per boundary target, because ADR-0020 resolves only the first target after an arrow. It drops the quoted "audit this skill"-style phrases, which restated "audited" in a second register (ADR-0020's duplicate-register rule). 241 characters, Gotchas 16% of the body: no size SUGGESTIONs. The boundary resolver stays embedded in two files rather than imported: a cache-installed plugin cannot read outside its own directory, and the repo-root hook resolves via .pre-commit-hooks.yaml where entry[0] is the only token pre-commit rewrites, so no single file is reachable by both. tests/test-adr0020-contract.sh hashes both copies for byte-identity, and asserts validate.sh sources the resolver and that no third copy exists. The entry scripts classify the target from its resolved parent directory, so a bare agent filename typed inside agents/ works; resolve SCRIPT_DIR CDPATH-safely; and exit 2 when a lib-*.sh is missing, rather than dying with exit 1, the tier the flows relay as real findings. The provenance run functions stash their findings code in KYBERFORGE_PROV_RC and return 0, so validate-provenance.sh calls them UNTESTED. Testing a function's status (`f || RC=$?`) disables errexit for its entire body, and no subshell or `set -e` inside can re-arm it once the call sits in a condition context (measured, both spellings). Their error paths use `exit`, which is unaffected either way; this keeps errexit armed for anything added later. Case 0's readability guard reads the file instead of asking `[[ -r ]]`. `-r` is access(2), which answers yes for uid 0 even on a mode-000 file, and this repo's dev environment is root -- so the guard could never fire where it exists to fire. A read attempt is also the stricter question, catching EIO. This is the reasoning scripts/check-vale-style-sync.sh carried before this commit deleted it; the hazard did not go with it. All three entry scripts are CDPATH-safe, vale-wrap.sh included: both of its cd sites are cleared, the --config resolution and the directory-mirror walk, where an exported CDPATH would otherwise print a decoy path into the -print0 stream and build the mirror from the decoy's files. The two remaining bare cd calls take absolute paths, which CDPATH is never consulted for. Impact BREAKING: skill-audit and agent-audit no longer exist as invocable skills. kyberforge goes to 2.0.0 (catalog 0.4.7). Check logic is unchanged: differential runs of the old and new validators across every skill and agent produced byte-identical stdout, stderr and exit codes, and the reconstructed Python payloads differ only in comments and the references/field-inventory.md -> agent-field-inventory.md rename. One doctrine governs the tiers: exit 0 is audited and clean, exit 1 is audited with findings OR a target present but unreadable, exit 2 is that nothing was audited at all. Edge paths DID change, deliberately (full table in ADR-0025): - a missing target exits 2 (never ran), not 1, under its own "does not exist" message; detection is by path shape, so a shape-matching path that is simply absent used to reach the validator and come back as a FAIL against a file that never existed; - an unshaped target exits 2 under the generic "matches neither" message, and a directory with no SKILL.md under a third, distinct one -- three exit-2 messages, not one; - a dangling symlink or a symlink loop stays exit 1: it is present but broken, which is a finding about the artifact rather than a usage error; - a SKILL.md file path is audited as its skill directory instead of refused; - a .md agent outside an agents/ directory is refused rather than audited; - a missing script library, a missing python3, a missing PyYAML, and no argument at all each exit 2. validate-provenance.sh already exited 2 for the last two; validate.sh now matches it. .pre-commit-hooks.yaml is a published contract consumed by external repos. Both hook IDs and both files: regexes are unchanged; only entry: and description: moved. scripts/check-vale-style-sync.sh (413), scripts/sync-vale-styles.sh (21), tests/test-check-vale-style-sync.sh (797) and agent-audit/scripts/README.md (47) are deleted. The checker made 17 assertions: 6 compared the two Vale copies and are moot; 10 are rehomed into tests/test-vale-wrap.sh (case 0, cases 28-31, and the suite's Vale-absent skip); and the cross-manifest files: agreement check, which selected hooks by entry: and so could not survive both hooks sharing one, is ported as case 33 pairing hooks by id:. Cases 28, 30 and 33 carry mutation self-tests; narrowing the local skill prefilter to 6 of 38 SKILL.md files now fails the suite. Skills go 39 to 38. Pre-push goes 9 repo-authored hooks to 8. ADR: 0025 BREAKING-CHANGE: the skill-audit and agent-audit skills are removed. Both flows are served by factory-audit, which auto-detects whether it was handed a skill directory or an agent file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YR2CjVumUbEGWcMikcoXBD
18 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.
Amended by ADR-0025 (2026-09-15). The reasoning below is not reversed; its precondition is
gone. The two skill-scoped Vale copies this ADR mandates — agent-audit/assets/vale/ (canonical)
and skill-audit/assets/vale/ (subset) — existed because the no-cross-skill-sharing rule made it
impossible for one audit skill to read the other's config. ADR-0025 merges the two skills into
factory-audit, so there is no boundary left to duplicate across: there is now one copy, at
plugins/kyberforge/.apm/skills/factory-audit/assets/vale/, carrying both styles and the
single-file .vale.ini — [**/SKILL.md], [**/agents/*.md], [**/*.agent.md] — that this ADR's
"One hook per file-scope" section had split in two. scripts/check-vale-style-sync.sh, decided on
below and wired at pre-push, is deleted with the copy it diffed. Nothing it asserted about the
config was lost. Its six-row glob-coverage probe table is now tests/test-vale-wrap.sh cases 28-30,
run against the merged config. Case 31 carries across the per-rule override allowlist, and case 0
carries across the "config loads" guards. Its cross-manifest files: drift check is ported as
case 33. The original keyed each hook's record on entry:, which stopped working once both vale
hooks shared one entry, so the port pairs the hooks by id: instead. Of the script's 17 assertion
sites, 6 compared the two copies and are moot, 10 are rehomed and 1 is ported. ADR-0025 gives the
per-assertion mapping; read the "six" here as probe rows, not as a share of those 17.
What does not change: the two exported hook IDs, kyberforge-vale-audit-skill and
kyberforge-vale-audit-agent, keep their IDs and their files: regexes — external consumers pin
them by name — and the argument-free entry: contract is untouched. Read the two-copy table, the
sync-check paragraph, and the tests/test-vale-wrap.sh Consequences bullet below as the state this
ADR established, not as current layout.
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/.apm/skills/agent-audit/assets/vale/
is the canonical, superset copy. skill-audit needs a second, smaller copy
(plugins/kyberforge/.apm/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/.apm/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.)
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/.apm/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. Both audit skills' Step 1 passes no --config either, for the same reason and one more: a
relative --config assets/vale/.vale.ini resolves against the cwd, not against the skill
directory the wrapper path was resolved from, so it yields E100 Runtime error … does not exist
and exit 2 — which both skills' fallback misreads as "vale unavailable" and silently downgrades
to full LLM judgment, the exact failure the self-location exists to prevent. Both SKILL.md Step
1 sections say so explicitly ("Pass no --config"), and 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 = <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/.apm/skills/agent-audit/assets/vale/(canonical, superset) andplugins/kyberforge/.apm/skills/skill-audit/assets/vale/(subset,Kyberforgeonly). plugins/kyberforge'splugin.jsonand.claude-plugin/plugin.jsonboth patch-bump for every shipped content change (per ADR-0006's version-parity invariant):1.2.5for the relocation itself,1.2.6for the self-locatingvale-wrap.shthat followed. Amended 2026-09-14 (ADR-0024): a record of what was done then, not current practice. Both manifests are deleted andapm.yml'sversion:is a plugin's only version field; ADR-0015 retired the parity/patch-bump rule this bullet invokes..pre-commit-hooks.yamlentries 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 onlyentry[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 asvale-wrap.shnow does for.vale.ini. Anything else rediscovers this as anotherE100..pre-commit-config.yamlstays byte-identical to the shipped manifest on thoseentry:lines so the local gate keeps exercising the same resolution path a consumer does.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. (State as of this ADR. Since ADR-0025 there is onevale-wrap.shand one.vale.iniunderfactory-audit/, and that suite exercises all three glob sections of the merged config — see cases 28-30.)- 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. - Dropping
--configmoved the release gate's path derivation too.check-release-needed.shused to reach each hook's bundled assets through thedirnameof its--configtarget. With no--configtoken left, that loop went dead and silently dropped bothassets/vale/trees from release coverage — a Vale rule change could then land onmainwithout demanding a tag, leaving consumers pinned to an oldrev:running stale rules while the gate stayed green. The script now derives the bundle'sassets/tree fromtokens[0]instead (double-dirname, guarded on the candidate existing and on not resolving to.), which is the only derivation compatible with the argument-freeentry: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.
Update — the flattener rewrites no characters. This ADR never recorded it as a decision, but
vale-wrap.sh's flattener carried a lossy last-resort branch: when a description needed quoting
and held an ASCII apostrophe and held a double quote or backslash, it substituted U+2019 (’)
for every ' before writing the scratch copy, on the stated rationale that no verbatim YAML scalar
could carry that combination. The rationale was wrong. A |- literal block with a single indented
content line carries ', ", \ and : byte for byte — a block scalar's body has no escape
syntax at all — and vale's text.frontmatter.description scope still matches and fires rules on it
(verified against vale 3.15.2; it is the same property that makes the | blocks in the wrapper's
header safe to leave unflattened). The branch fired on 12 of the 54 in-scope files in this repo,
silently disabling every rule whose token contains an apostrophe on each of them. The flattener now
emits that literal block instead, so its output is verbatim in all four forms and no Vale rule can
be silently disabled by the prefilter. The |- form is two physical lines where the three inline
forms are one, so the blank-line pad that preserves later line numbers drops by one — reachable
only when the original span is already two or more lines, so the pad count stays non-negative.
tests/test-vale-wrap.sh case 20 asserts an apostrophe-bearing token actually fires on a flattened
description in all three apostrophe-carrying branches, and case 20b pins the pad arithmetic against
a body line's true line number.