refactor(kyberforge)!: merge skill-audit and agent-audit into factory-audit

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: at 484357a the 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 at 598a7c3, 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
This commit is contained in:
2026-09-15 18:39:43 +00:00
parent a5962ba773
commit 620f20b0fd
119 changed files with 6308 additions and 5487 deletions

View File

@@ -1,8 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
# Run agent-audit's validate.sh over every REAL plugin-scope agent file in this
# repo (plugins/*/.apm/agents/*.agent.md).
# Run factory-audit's validate.sh over every REAL plugin-scope agent file in
# this repo (plugins/*/.apm/agents/*.agent.md).
#
# Since ADR-0025 merged skill-audit and agent-audit into factory-audit, that
# validate.sh is a single auto-detecting entry point: it reads the target and
# sources the skill-mode or agent-mode check library itself. The invocation
# below is therefore unchanged apart from the path — no mode flag is passed,
# and passing one would be wrong.
#
# Why this exists: validate.sh was previously exercised only by
# scripts/check-scope-walkup-sync.sh, and only against synthetic fixtures built
@@ -31,14 +37,14 @@ if [[ ! -d "$REPO_ROOT" ]]; then
fi
REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
VALIDATE="$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh"
VALIDATE="$REPO_ROOT/plugins/kyberforge/.apm/skills/factory-audit/scripts/validate.sh"
# The validator's own absence is a hard failure, never a skip. If validate.sh
# moves or is deleted, every assertion below evaporates and the hook would
# otherwise exit 0 having validated nothing -- indistinguishable, from
# pre-commit's silent-on-pass output, from a run where all four agents passed.
if [[ ! -f "$VALIDATE" ]]; then
echo "APM agent validation failed: validator not found at $VALIDATE — this script's path has gone stale, so no agent file was checked. Update it to wherever agent-audit's validate.sh now lives." >&2
echo "APM agent validation failed: validator not found at $VALIDATE — this script's path has gone stale, so no agent file was checked. Update it to wherever factory-audit's validate.sh now lives." >&2
exit 1
fi
@@ -47,7 +53,7 @@ fi
# reads like a validation failure rather than a missing dependency. Fail closed,
# but say which it is.
if ! command -v python3 >/dev/null 2>&1; then
echo "APM agent validation failed: python3 not found on PATH — agent-audit's validate.sh is a python3 program and cannot run. Install python3; this gate does not degrade to a pass." >&2
echo "APM agent validation failed: python3 not found on PATH — factory-audit's validate.sh is a python3 program and cannot run. Install python3; this gate does not degrade to a pass." >&2
exit 1
fi
@@ -147,15 +153,15 @@ for f in ${AGENT_FILES[@]+"${AGENT_FILES[@]}"}; do
if [[ -n "$out" ]]; then
printf '%s\n' "$out" | sed 's/^/ /' >&2
else
echo " (validate.sh produced no output — see its exit code above; 2 means script error, e.g. a missing references/field-inventory.md)" >&2
echo " (validate.sh produced no output — see its exit code above; 2 means script error, e.g. a missing references/agent-field-inventory.md or lib-*.sh)" >&2
fi
fi
done
if [[ "$FAIL" -ne 0 ]]; then
echo "" >&2
echo "APM agent validation failed: ${#FAILED_FILES[@]} of ${#AGENT_FILES[@]} agent file(s) did not pass agent-audit's validate.sh." >&2
echo "APM agent validation failed: ${#FAILED_FILES[@]} of ${#AGENT_FILES[@]} agent file(s) did not pass factory-audit's validate.sh." >&2
exit 1
fi
echo "APM agent validation passed: ${#AGENT_FILES[@]} plugin-scope agent file(s) validated against agent-audit's validate.sh."
echo "APM agent validation passed: ${#AGENT_FILES[@]} plugin-scope agent file(s) validated against factory-audit's validate.sh."

View File

@@ -5,19 +5,35 @@ set -euo pipefail
# ports of "walk up from a directory looking for a scope-defining marker" living
# in this repo:
#
# - plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh (Python: detect_scope)
# - plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh (Python: find_plugin_root)
# - plugins/kyberforge/.apm/skills/agent-author/scripts/new-agent.sh (Bash: find_package_root)
# - plugins/kyberforge/.apm/skills/skill-author/scripts/new-skill.sh (Bash: find_package_root)
# - plugins/kyberforge/.apm/skills/factory-audit/scripts/validate.sh (Python: detect_scope)
# - plugins/kyberforge/.apm/skills/factory-audit/scripts/validate-provenance.sh (Python: find_plugin_root)
# - plugins/kyberforge/.apm/skills/agent-author/scripts/new-agent.sh (Bash: find_package_root)
# - plugins/kyberforge/.apm/skills/skill-author/scripts/new-skill.sh (Bash: find_package_root)
#
# Per ADR-0014's no-cross-skill-path rule, these can't be consolidated into a
# shared file (each skill's cache-install copies only its own files), so unlike
# check-vale-style-sync.sh (which diffs literal file copies) this can't be a
# text diff — the four implementations are hand-ported, not copied. Instead
# this builds a matrix of fixture directory trees and asserts the *observable
# behavior* agrees: whatever new-agent.sh/new-skill.sh actually create on disk,
# validate.sh/validate-provenance.sh must classify the same way when pointed at
# the result. Run from repo root or pass REPO_ROOT as arg.
# THIS GATE SURVIVED ADR-0025 AND ITS JOB DID NOT SHRINK. The merge of
# skill-audit and agent-audit into factory-audit moved the first two ports into
# one directory; it did not merge the ports. Two of the four still live in
# agent-author and skill-author, which ADR-0020 deliberately left unmerged, so
# the walk-up still has four independent implementations across three skill
# directories and this is still the only thing comparing them.
#
# It also still cannot become a text diff, and the reason is not the one that
# retired check-vale-style-sync.sh alongside the merge. That gate diffed two
# literal copies of the same file, so collapsing them to one copy left it
# nothing to compare. These four are not copies of anything: they are hand-
# ported reimplementations of the same walk-up in two different languages —
# Python in factory-audit's two validators, Bash in the two authors' scaffold
# scripts. There is no byte sequence common to a Python function and a Bash
# function that agreeing on behavior would preserve. Sourcing cannot close the
# gap either: per ADR-0014's no-cross-skill-path rule a cache-installed skill
# copies only its own directory, which is exactly why factory-audit's two
# validators CAN now source a shared library from their own scripts/ while the
# author skills across the boundary still cannot.
#
# So this asserts behavioral agreement instead: it builds a matrix of fixture
# directory trees and checks that whatever new-agent.sh/new-skill.sh actually
# create on disk, validate.sh/validate-provenance.sh classify the same way when
# pointed at the result. Run from repo root or pass REPO_ROOT as arg.
REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
if [[ ! -d "$REPO_ROOT" ]]; then
@@ -28,8 +44,8 @@ REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
NEW_AGENT="$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-author/scripts/new-agent.sh"
NEW_SKILL="$REPO_ROOT/plugins/kyberforge/.apm/skills/skill-author/scripts/new-skill.sh"
VALIDATE="$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh"
VALIDATE_PROVENANCE="$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh"
VALIDATE="$REPO_ROOT/plugins/kyberforge/.apm/skills/factory-audit/scripts/validate.sh"
VALIDATE_PROVENANCE="$REPO_ROOT/plugins/kyberforge/.apm/skills/factory-audit/scripts/validate-provenance.sh"
# Floor on the four hardcoded `plugins/kyberforge/.apm/...` paths above. A
# missing target is only a legitimate no-op for a repo that has no kyberforge
@@ -41,10 +57,10 @@ VALIDATE_PROVENANCE="$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit/scrip
for f in "$NEW_AGENT" "$NEW_SKILL" "$VALIDATE" "$VALIDATE_PROVENANCE"; do
if [[ ! -f "$f" ]]; then
if [[ -d "$REPO_ROOT/plugins/kyberforge" ]]; then
echo "Scope walk-up sync check failed: $REPO_ROOT/plugins/kyberforge exists but $f does not — this script's .apm/ paths have gone stale, so none of the walk-up fixtures ran. Update them to wherever the agent-author/agent-audit/skill-author scripts now live." >&2
echo "Scope walk-up sync check failed: $REPO_ROOT/plugins/kyberforge exists but $f does not — this script's .apm/ paths have gone stale, so none of the walk-up fixtures ran. Update them to wherever the factory-audit/agent-author/skill-author scripts now live." >&2
exit 1
fi
echo "Scope walk-up sync check: $f not found — kyberforge agent-author/agent-audit/skill-author skills not present, nothing to check." >&2
echo "Scope walk-up sync check: $f not found — kyberforge factory-audit/agent-author/skill-author skills not present, nothing to check." >&2
exit 0
fi
done

View File

@@ -1,413 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Kyberforge's Vale prefilter is duplicated into skill-audit and agent-audit's own
# scripts/assets (per plugins/kyberforge/.apm/skills/skill-author/references/deployment-modes.md's
# no-cross-skill-path rule: a plugin's cache-install copy only includes each skill's own files).
# agent-audit's copy is canonical — it's the superset (Kyberforge + KyberforgeCopilot) that the
# repo root's own pre-commit hook and .pre-commit-hooks.yaml both consume. This fails the build
# if skill-audit's copy has drifted from it, since nothing else would catch a rule fix landing in
# only one of the two. Run from repo root or pass REPO_ROOT as arg.
REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
# A nonexistent REPO_ROOT must fail loudly, not fall through to the "neither
# copy present" no-op below — that guard exists for a repo that legitimately
# has no kyberforge plugin installed, not for a typo'd or stale path, and a
# clean exit 0 here would read as "checked, in sync" when nothing ran at all.
if [[ ! -d "$REPO_ROOT" ]]; then
echo "Vale style sync check failed: REPO_ROOT '$REPO_ROOT' is not a directory." >&2
exit 1
fi
# Absolutized because the glob probe below `cd`s into a scratch tree, where a
# relative --config path would stop resolving.
REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
FAIL=0
err() { echo " FAIL: $1" >&2; FAIL=$((FAIL + 1)); }
SKILL_AUDIT="$REPO_ROOT/plugins/kyberforge/.apm/skills/skill-audit"
AGENT_AUDIT="$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit"
# Floor on the hardcoded `plugins/kyberforge/.apm/...` paths above. Neither copy
# present is only a legitimate no-op for a repo that has no kyberforge plugin at
# all. If `plugins/kyberforge/` IS here and the `.apm/` targets under it are not,
# the paths in this script have gone stale — a rename or relocation of `.apm/`
# would otherwise turn every assertion below into a silent exit 0, which reads as
# "checked, in sync" exactly like the REPO_ROOT case above. That matters most for
# the change that introduced these paths: a path rewrite is precisely the edit
# this would survive unnoticed.
if [[ ! -d "$SKILL_AUDIT" && ! -d "$AGENT_AUDIT" ]]; then
if [[ -d "$REPO_ROOT/plugins/kyberforge" ]]; then
echo "Vale style sync check failed: $REPO_ROOT/plugins/kyberforge exists but neither $SKILL_AUDIT nor $AGENT_AUDIT does — this script's .apm/ paths have gone stale, so nothing was checked. Update them to wherever the audit skills now live." >&2
exit 1
fi
exit 0
fi
# Exactly one present is drift, not absence: the missing copy can't be in sync
# with the surviving one, and treating it as a no-op is how a deleted or
# renamed copy would slip through silently.
if [[ ! -d "$SKILL_AUDIT" ]]; then
echo "Vale style sync check failed: $AGENT_AUDIT exists but $SKILL_AUDIT does not — run scripts/sync-vale-styles.sh to regenerate skill-audit's copy." >&2
exit 1
fi
if [[ ! -d "$AGENT_AUDIT" ]]; then
echo "Vale style sync check failed: $SKILL_AUDIT exists but $AGENT_AUDIT does not — agent-audit holds the canonical copy, so restore it before syncing." >&2
exit 1
fi
if ! diff -q "$SKILL_AUDIT/scripts/vale-wrap.sh" "$AGENT_AUDIT/scripts/vale-wrap.sh" >/dev/null 2>&1; then
err "scripts/vale-wrap.sh differs between skill-audit and agent-audit"
fi
if ! diff -rq "$SKILL_AUDIT/assets/vale/styles/Kyberforge" "$AGENT_AUDIT/assets/vale/styles/Kyberforge" >/dev/null 2>&1; then
err "assets/vale/styles/Kyberforge differs between skill-audit and agent-audit"
fi
# --- .vale.ini coverage ------------------------------------------------------
# The two .vale.ini files are deliberately NOT identical — agent-audit's carries
# an extra [**/*.agent.md] section and the KyberforgeCopilot style — so they
# cannot be diffed like the styles above. Nothing else in the repo read them at
# all, and that is what let a one-character glob typo silently disable the
# prefilter for a whole file type: the hook still MATCHES the file via its
# `files:` regex, so pre-commit reports neither `Skipped` nor an error; vale
# lints zero files, prints `0 errors ... in 1 file` and exits 0, and the hook
# shows `Passed`. So check the parts that must hold in both, not equality.
SKILL_INI="$SKILL_AUDIT/assets/vale/.vale.ini"
AGENT_INI="$AGENT_AUDIT/assets/vale/.vale.ini"
# Counted, not assumed. The summary line at the bottom used to hardcode `2
# .vale.ini file(s) checked` in both branches. That was true on any clean run --
# a missing or unreadable file errs below and the script never reaches the
# summary -- but the line's whole purpose is to say what this run actually
# inspected, and a constant says what the author expected. Nothing asserted it,
# so it would have survived becoming false.
INIS_CHECKED=0
for ini in "$SKILL_INI" "$AGENT_INI"; do
rel_ini="${ini#"$REPO_ROOT"/}"
# `-e`, not `-f`: a path that exists but is not a readable regular file (a
# directory sitting where the file should be, say) is not "missing", and
# reporting it as missing sends you looking for a deleted file. It belongs to
# the unreadable case below, which is the one that describes what actually
# went wrong.
if [[ ! -e "$ini" ]]; then
err "$rel_ini is missing — without it vale falls back to an upward config search and lints with whatever it finds"
continue
fi
# Present but unreadable is its own case: every assertion below is a grep, and
# grep exits 2 on a read error. The override capture swallows that into an
# empty result, which would read as "no findings" rather than "not checked",
# and the two greps above it report "has no StylesPath"/"names no Kyberforge"
# for a file that may well have both — a misdiagnosis, not a missed one.
#
# Decided by ACTUALLY READING the file, not by `[[ -r ]]`. `-r` is access(2),
# which answers "would the permission bits allow it" — and for uid 0 that is
# yes even on a mode-000 file (verified). This hook runs at pre-push, and this
# repo's dev environment is root, so an `[[ ! -r ]]` guard could never fire in
# the one place it exists to fire: it was untestable because it was dead. A
# read attempt is also the stricter question, catching EISDIR and EIO, which
# access(2) reports on neither. `cat`, not a bare `< "$ini"` redirect: opening
# a directory for reading succeeds, only the read fails.
if ! cat "$ini" >/dev/null 2>&1; then
err "$rel_ini exists but could not be read — none of its assertions could run, and an unreadable file cannot be distinguished from a clean one downstream"
continue
fi
# Counted here, past both `continue`s: the file exists and its bytes were
# readable, so every assertion below it really does run against it.
INIS_CHECKED=$((INIS_CHECKED + 1))
# StylesPath is resolved relative to the .vale.ini, which is the only reason
# the bundled styles are found from a consuming repo's clone prefix.
if ! grep -Eq '^[[:space:]]*StylesPath[[:space:]]*=[[:space:]]*styles[[:space:]]*$' "$ini"; then
err "$rel_ini has no 'StylesPath = styles' — the bundled styles/ directory would not be found"
fi
# Matches `Kyberforge` as a whole name, so `KyberforgeCopilot` alone does not
# satisfy it. Avoids \b, which is a GNU grep extension.
if ! grep -Eq '^[[:space:]]*BasedOnStyles[[:space:]]*=.*Kyberforge([[:space:],]|$)' "$ini"; then
err "$rel_ini has no section whose BasedOnStyles names Kyberforge — every rule the audit prefilters on lives in that style"
fi
# Per-rule overrides are the third way to retire a rule without touching a
# style file or a glob. Per ADR-0013, every rule is `level: error` and every
# alert is a FAIL — there is no ignorable tier. Vale's exit
# code keys on `error` alerts alone, so any override that leaves a rule at
# anything other than `error` still lints the file, still exits 0, and still
# shows `Passed` in pre-commit. The glob probe below cannot backstop this: it
# keys on one `Kyberforge.VagueWording` alert, so DescriptionOpener,
# PaddingPhrase, SentenceOpenerThereIs and ProactivePhrase can each be retired
# underneath a passing probe.
#
# Asserted as an ALLOWLIST, not a blocklist of `NO|warning|suggestion`, because
# that is vale 3.15.2's own semantic: only the exact tokens `YES` and `error`
# keep a rule blocking. `warning`/`suggestion` downgrade it (alert still
# printed, exit 0 — invisible, since pre-commit swallows a passing hook's
# output); every other value — `NO`, `false`, `0`, `off`, `n`, empty,
# `garbage`, and lowercase `yes`, `true`, `1`, `on` — silences the rule
# outright. Lowercase `yes` is the trap a blocklist cannot cover: it reads as
# "enabled" to a human and disables the rule. Verified by enumerating the
# value space against vale 3.15.2.
#
# The allowlist demands a BARE `YES`/`error` with nothing after it, which also
# rejects `error # note` and `error ; note`. Vale itself strips those — a
# whitespace-preceded `#` or `;` comment is removed and the rule stays live —
# so rejecting them is deliberately stricter than vale, not a workaround for
# it. Uniformity is worth more here than the ability to annotate a line that
# should not exist: no shipped `.vale.ini` has any override line at all, and
# the failure mode is a loud false positive rather than a silent pass. The
# genuine hazard is the no-space form — `error# note` and `error; note` are
# NOT stripped and silence the rule outright — and a rule that demands a bare
# token catches those without having to reimplement vale's comment parsing.
#
# `[A-Za-z0-9_-]` on both halves of the name, not `[A-Za-z]`: a rule named
# `Kyberforge.Vague2` is genuinely silenced by `= NO` (verified: 1 error ->
# 0 errors), so an alpha-only class would let a digit-bearing rule name slip
# past the gate. All five current rule names are pure alpha, so this is
# forward cover, not a live hole.
bad_overrides="$(
grep -E '^[[:space:]]*Kyberforge[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+[[:space:]]*=' "$ini" \
| grep -Ev '^[[:space:]]*Kyberforge[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+[[:space:]]*=[[:space:]]*(YES|error)[[:space:]]*$' \
|| true
)"
# No `grep -q` in that pipeline on purpose: `-q` exits on its first match, and
# under `set -o pipefail` the resulting SIGPIPE on the upstream grep would make
# the whole pipeline report 141 and read as "no findings".
if [[ -n "$bad_overrides" ]]; then
err "$rel_ini overrides a Kyberforge rule to something other than a bare YES or error (first: '${bad_overrides%%$'\n'*}') — every rule in this prefilter is level: error and every alert is a FAIL, and any other value downgrades or silences the rule while vale still exits 0. A trailing comment is rejected too: vale strips a spaced '# ...' but not 'error# ...', so this asks for the bare token rather than guessing which form you meant"
fi
done
# KyberforgeCopilot is agent-audit's alone — ADR-0013 scopes it to `.agent.md`
# files only, for the Copilot-only 'Use proactively has no effect' check, and
# records that it must not be extended to `.md` files. The loop above
# deliberately asserts only `Kyberforge`, since
# skill-audit's copy legitimately has no Copilot style, so dropping
# `, KyberforgeCopilot` from agent-audit's `[**/*.agent.md]` section unloaded the
# whole style silently: no glob broke, the styles/ diff above stayed clean (the
# style directory is still shipped, just never loaded), the two .vale.ini files
# are deliberately unequal so no equality check applies, and the probe below
# still passed because it keys on a Kyberforge alert. Assert the style is loaded
# whenever it is shipped.
if [[ -d "$AGENT_AUDIT/assets/vale/styles/KyberforgeCopilot" && -f "$AGENT_INI" ]]; then
if ! grep -Eq '^[[:space:]]*BasedOnStyles[[:space:]]*=.*KyberforgeCopilot([[:space:],]|$)' "$AGENT_INI"; then
err "${AGENT_INI#"$REPO_ROOT"/} ships a styles/KyberforgeCopilot style but no section's BasedOnStyles names it — the style is never loaded, so its Copilot-only rules lint nothing"
fi
fi
# Prints the `files:` regex of every hook, in ONE manifest ($2), whose entry
# is $1's vale-wrap.sh. Records are delimited by their `- id:` line, so the
# check does not depend on `entry:` preceding `files:` within a record.
#
# Deliberately kept per-manifest rather than unioned across both files: the
# validation loop below needs to know whether a probe path is in scope of
# .pre-commit-hooks.yaml (the canonical, external-facing manifest) and
# .pre-commit-config.yaml (this repo's own dev-time copy of the same hook)
# *independently*. A union here previously let a probe that matched only the
# older, looser .pre-commit-hooks.yaml pattern read as "in scope" even after
# .pre-commit-config.yaml's copy of the same hook had been narrowed away from
# it — silently masking exactly the kind of hook-rescoping drift this script
# exists to catch.
#
# Deliberately NOT memoized. The probe loop below calls this 12 times over the
# same two small manifests, which measures at 14ms against a ~870ms run (the six
# vale invocations are the wall clock). A previous memoization attempt was inert
# anyway: every call site is `x="$(hook_file_regexes ...)"`, a command
# substitution, so the cache writes landed in a subshell and the lookup never
# hit. Re-parsing is the honest, working version of a saving too small to buy.
hook_file_regexes() {
local skill="$1" manifest="$2" raw
if [[ -f "$manifest" ]]; then
awk -v skill="$skill" '
function flush() {
if (entry ~ skill "/scripts/vale-wrap.sh" && files != "") print files
entry = ""; files = ""
}
/^[ \t]*-[ \t]*id:/ { flush() }
/^[ \t]*entry:/ { entry = $0 }
/^[ \t]*files:/ { files = $0; sub(/^[ \t]*files:[ \t]*/, "", files) }
END { flush() }
' "$manifest" | while IFS= read -r raw; do
# Strip the surrounding YAML quotes; the regex itself never carries them.
raw="${raw%\'}"; raw="${raw#\'}"
raw="${raw%\"}"; raw="${raw#\"}"
printf '%s\n' "$raw"
done
fi
}
# True if $1 matches at least one newline-delimited regex in $2.
matches_any_regex() {
local rel="$1" regexes="$2" re
[[ -n "$regexes" ]] || return 1
while IFS= read -r re; do
[[ -n "$re" ]] || continue
if printf '%s\n' "$rel" | grep -Eq "$re"; then
return 0
fi
done <<EOF_RE
$regexes
EOF_RE
return 1
}
# Asks vale — the thing that actually applies these globs — whether a config
# covers a path, rather than reimplementing doublestar matching. The probe file
# carries a description with a token Kyberforge.VagueWording flags, so a config
# whose glob matches but whose BasedOnStyles lost Kyberforge fails too: it would
# lint the file and report nothing.
# On a miss, the caller reports a glob defect -- but a miss is also what a failed
# exec, an OOM-killed vale, or a full TMPDIR looks like, and discarding vale's rc
# and output made those indistinguishable and evidence-free. A flake seen once in
# this probe could not be diagnosed afterwards for exactly that reason. The rc and
# output are now stashed for the caller to attach to its message; VALE_PROBE_DIAG
# is set on every call, so a stale value from an earlier probe can never be
# reported against a later one.
VALE_PROBE_DIAG=""
vale_flags_path() {
local cfg="$1" rel="$2" tmp out rc=0
tmp="$(mktemp -d)"
mkdir -p "$tmp/$(dirname "$rel")"
{
echo "---"
echo "name: probe"
echo "description: Use when the caller wants a probe that helps with things."
echo "---"
echo ""
echo "Body."
} > "$tmp/$rel"
out="$(cd "$tmp" && vale --config "$cfg" "$rel" 2>&1)" || rc=$?
rm -rf "$tmp"
if printf '%s\n' "$out" | grep -qF "Kyberforge.VagueWording"; then
VALE_PROBE_DIAG=""
return 0
fi
# vale exits nonzero merely for *having* alerts, so rc alone proves nothing --
# it is evidence only alongside the absent alert.
VALE_PROBE_DIAG="vale exited $rc; output: ${out:-<empty>}"
return 1
}
# Missing vale is a HARD FAILURE, not a warning. Six of this script's assertions
# — one glob probe per path below — are `vale --config` invocations, and they are
# the only ones that catch the defect the whole `.vale.ini coverage` section was
# written for: the one-character glob typo (`[**/SKILL.md]` -> `[**/SKILLS.md]`)
# that leaves every text-level assertion clean while vale lints zero files. As a
# warning this self-disabled on exactly that mutation and exited 0, and since
# pre-commit swallows a passing hook's output the stderr line was never seen —
# the pre-push hook reported `Passed`. That is the same "clean exit 0 reads as
# 'checked, in sync' when nothing ran" failure the REPO_ROOT guard at the top of
# this file already refuses to allow.
#
# The opt-out exists for a machine that genuinely cannot install vale, and it is
# an env var that has to be set on purpose — never mere absence of the binary.
# Setting it downgrades the run to text-level assertions only and says so.
VALE_AVAILABLE=true
if ! command -v vale >/dev/null 2>&1; then
VALE_AVAILABLE=false
if [[ "${CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE:-}" == "1" ]]; then
echo " WARNING: vale is not installed and CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1 — .vale.ini glob coverage was NOT verified, only the text-level assertions ran. A clean result here does not mean the globs cover what their hooks lint." >&2
else
err "vale is not installed, so none of the .vale.ini glob-coverage probes ran — a glob typo that silently lints zero files is invisible without them. Install it (https://vale.sh/docs/vale-cli/installation/), or set CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1 to accept a text-only run"
fi
fi
# One representative path per file shape the prefilter is supposed to cover,
# tagged with whether that shape is expected to be in scope of BOTH manifests
# ("shared") or only the external-facing .pre-commit-hooks.yaml ("hooks-only"
# — e.g. a Copilot .agent.md file living outside this repo's own plugins/.apm/
# layout, which .pre-commit-config.yaml's repo-scoped regex has no reason to
# cover). Each probe is checked against the two manifests' `files:` regexes
# *separately*, not unioned: a path that goes stale because a hook was
# rescoped fails loudly here instead of quietly probing a shape nothing lints
# any more, and a "shared" path the two manifests disagree on fails loudly
# too — that disagreement is exactly how .pre-commit-config.yaml's regex can
# narrow out of sync with .pre-commit-hooks.yaml's without either manifest's
# own hook breaking (each still matches real files on its own), so nothing
# else would catch it.
PROBES_CHECKED=0
while IFS='|' read -r skill rel scope; do
[[ -n "$skill" ]] || continue
dir="$REPO_ROOT/plugins/kyberforge/.apm/skills/$skill"
ini="$dir/assets/vale/.vale.ini"
[[ -f "$ini" ]] || continue
PROBES_CHECKED=$((PROBES_CHECKED + 1))
hooks_regexes="$(hook_file_regexes "$skill" "$REPO_ROOT/.pre-commit-hooks.yaml")"
config_regexes="$(hook_file_regexes "$skill" "$REPO_ROOT/.pre-commit-config.yaml")"
in_hooks=false
matches_any_regex "$rel" "$hooks_regexes" && in_hooks=true
in_config=false
matches_any_regex "$rel" "$config_regexes" && in_config=true
if [[ "$in_hooks" == false && "$in_config" == false ]]; then
err "$rel matches no 'files:' regex of any $skill hook — the probe path is stale, or the hook was rescoped away from a shape it still needs to lint"
elif [[ "$scope" == "shared" && "$in_hooks" != "$in_config" ]]; then
err "$rel is in scope of $skill's hook in .pre-commit-hooks.yaml but not .pre-commit-config.yaml (or vice versa: hooks=$in_hooks, config=$in_config) — the local and canonical 'files:' regexes have drifted out of sync for this hook"
fi
if [[ "$VALE_AVAILABLE" == true ]] && ! vale_flags_path "$ini" "$rel"; then
err "$skill/assets/vale/.vale.ini raises no Kyberforge alert on $rel — its glob sections do not cover a path its own pre-commit hook is scoped to, so the hook passes that shape without linting it [$VALE_PROBE_DIAG]"
fi
# `demo.md` (bare, no `.agent.md` suffix) is `hooks-only` rather than
# `shared`: it exists only to exercise agent-audit's `[**/agents/*.md]` glob
# section in isolation from `[**/*.agent.md]` (test-check-vale-style-sync.sh's
# case 10), not because any real file under `.apm/agents/` still has that
# shape — per ADR-0016 every `.apm/agents/*` file is named `*.agent.md`, so
# `.pre-commit-config.yaml`'s regex correctly no longer matches it and that's
# not drift. `demo.agent.md` is the real, current shape and is `shared`.
#
# The two `.claude/`-prefixed probes carry the location-independence property: a
# `SKILL.md` outside `plugins/` (e.g. project-scope
# `.claude/skills/foo/SKILL.md`) still matches `[**/SKILL.md]` and gets linted
# normally — the globs constrain filename shape, not location. Every other
# probe here starts with `plugins/`, so narrowing a glob to a `plugins/`-shaped
# path (`[**/SKILL.md]` -> `[**/.apm/skills/*/SKILL.md]`) left all of them
# matching while the project-scope shape started linting as `0 errors ... in 0
# files` — the exact "vale lints zero files, hook shows Passed" failure the
# comment at the top of this section describes. Both are `hooks-only`: only
# `.pre-commit-hooks.yaml` is layout-agnostic, and `.pre-commit-config.yaml`
# pinning this repo's own `plugins/**/.apm/` layout is by design, not drift.
done <<'EOF_PROBE'
skill-audit|plugins/demo/.apm/skills/demo/SKILL.md|shared
skill-audit|.claude/skills/demo/SKILL.md|hooks-only
agent-audit|plugins/demo/.apm/agents/demo.md|hooks-only
agent-audit|plugins/demo/.apm/agents/demo.agent.md|shared
agent-audit|.claude/agents/demo.md|hooks-only
agent-audit|copilot/demo.agent.md|hooks-only
EOF_PROBE
# Second floor, on the probe TABLE rather than on the directory paths. Every row
# `continue`s when the `.vale.ini` of the skill its first column names is absent,
# so the table can verify nothing while FAIL stays 0. Two states do that, and no
# other assertion in this file sees either:
#
# * the `EOF_PROBE` heredoc gutted — a bad merge, a truncated edit, or a
# wholesale delete of the rows. The loop body never runs at all.
# * every row's skill column drifting away from the directory names on disk
# (`skill-audit|` -> `skill-auditX|`), which is what a skill rename plus a
# half-applied find/replace leaves behind.
#
# Both give a clean exit 0 from a section that checked nothing, which is why the
# guard is worth having. What it is NOT reachable by is a relocation of
# `assets/vale/`: PROBES_CHECKED only reaches 0 that way if BOTH `.vale.ini`
# files are gone, and the loop at the top of the `.vale.ini coverage` section
# errs on each of them first, so that state is already FAIL >= 2 and this guard
# is never the cause. The message therefore names the table, not the files —
# describing it as "every probe skill's .vale.ini is missing" misdiagnosed the
# one thing that can actually trigger it.
if [[ $PROBES_CHECKED -eq 0 ]]; then
err "no probe path was checked — the probe table is empty, or no row's first column names a skill directory under plugins/kyberforge/.apm/skills/ that has an assets/vale/.vale.ini, so the glob-coverage section verified nothing at all"
fi
if [[ $FAIL -gt 0 ]]; then
echo "Vale style sync check failed: $FAIL error(s). For a drifted wrapper or style, agent-audit's copy is canonical — run scripts/sync-vale-styles.sh to regenerate skill-audit's copy, then commit both. A .vale.ini finding is not drift and sync-vale-styles.sh will not fix it: edit that file's own StylesPath, BasedOnStyles or glob sections." >&2
exit 1
fi
# A clean run says what it actually inspected. Silence is what let the vacuous
# passes above look identical to real ones, and it is what made "did this script
# do any work against the real repo?" untestable from outside — the counts below
# are what tests/test-check-vale-style-sync.sh asserts a non-zero floor on.
if [[ "$VALE_AVAILABLE" == true ]]; then
echo "Vale style sync check passed: $INIS_CHECKED .vale.ini file(s) checked, $PROBES_CHECKED glob probe(s) verified with vale."
else
echo "Vale style sync check passed (text-level only, vale unavailable): $INIS_CHECKED .vale.ini file(s) checked, 0 glob probe(s) verified."
fi

View File

@@ -25,14 +25,14 @@ set -euo pipefail
#
# Both spec ceilings are inclusive: a file at exactly MAX_LINES or MAX_WORDS
# passes, and only one past it fails. That matches
# skill-audit/scripts/validate.sh, which has always used `line_count <= 500` as
# factory-audit/scripts/lib-checks-skill.sh, which has always used `line_count <= 500` as
# its pass condition — the two previously disagreed at exactly 500 lines, so a
# SKILL.md could pass its own audit and still be blocked by the commit hook.
# The ADR-0020 ceilings are inclusive the same way.
#
# Token counts aren't computed exactly here — a whitespace word count is used
# as a proxy (Python's str.split(), the same primitive
# skill-audit/scripts/validate.sh applies to these two constants; `wc -w`
# factory-audit/scripts/lib-checks-skill.sh applies to these two constants; `wc -w`
# disagrees with it on Unicode separators, which is why the awk pass that used
# to live in the loop below is gone).
#
@@ -81,20 +81,21 @@ set -euo pipefail
# description differently is worse than one reader that refuses to start.
# These constants are intentionally duplicated in
# skill-audit/scripts/validate.sh (Python) rather than shared from one file:
# this script is a standalone bash pre-commit hook, that one is an in-skill
# Python validator invoked in a different context (same rationale as
# vale-wrap.sh's per-plugin duplication — see its own header comment).
# factory-audit/scripts/lib-checks-skill.sh (Python) rather than shared from one
# file: this script is a standalone bash pre-commit hook, that one is an
# in-skill Python check library invoked in a different context.
# tests/test-skill-size-check.sh asserts both files agree on these values, so
# drift between them fails CI rather than silently diverging.
#
# The ADR-0020 constants below are duplicated the same way and carry the same
# warning: skill-audit/scripts/validate.sh holds a second copy of
# warning: factory-audit/scripts/lib-checks-skill.sh holds a second copy of
# DESC_SUGGEST_CHARS / DESC_MAX_CHARS / BODY_SUGGEST_WORDS / BODY_MAX_WORDS,
# and agent-audit/scripts/validate.sh holds a third copy of the two
# and factory-audit/scripts/lib-checks-agent.sh holds a third copy of the two
# description constants (agents take the description gates and, per ADR-0020,
# deliberately take NO body word gate). If they drift, this audit reports a
# skill ready to ship that the commit hook then rejects.
# deliberately take NO body word gate). ADR-0025's merge put those two libraries
# in one directory but did NOT collapse the copies — the two flows gate
# different spans — so the drift risk is unchanged: if they diverge, the audit
# reports a skill ready to ship that the commit hook then rejects.
MAX_LINES=500
MAX_WORDS=2770
@@ -146,7 +147,7 @@ fi
# them as 0, and both ceilings passed in total silence — the one outcome
# this script forbids itself.
# * awk's NR/NF do not agree with the Python splitlines()/split() that
# skill-audit/scripts/validate.sh uses for the SAME two constants.
# factory-audit/scripts/lib-checks-skill.sh uses for the SAME two constants.
# splitlines() also breaks on \x0b \x0c \x1c \x1d \x1e \x85 U+2028 U+2029
# and split() on every Unicode space, so a body padded with U+2028 read as
# 6 lines here and 606 lines there — hook green, audit FAIL.
@@ -225,16 +226,20 @@ def info(msg):
print("INFO: %s" % msg)
# Anything inside the BEGIN/END markers below is hashed byte-for-byte against
# the plugin copy by tests/test-adr0020-contract.sh. Never edit the marked span
# in one file alone -- including its comments -- or that test fails. Change both
# copies in one commit, keeping the span's line count intact.
# ===== BEGIN ADR-0020 SHARED BOUNDARY RESOLVER =====
# ONE resolver, embedded VERBATIM in three scripts:
# ONE resolver, embedded VERBATIM in two scripts (ADR-0025 retired the third):
# scripts/skill-size-check.sh
# plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh
# plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh
# The block between these markers must stay byte-identical in all three. It is
# copied rather than imported because a cache-installed plugin's scripts cannot
# read files outside their own plugin directory, so there is no single file all
# three can share (same constraint that forces the ADR-0020 constants to be
# duplicated). Edit one copy, then paste it over the other two.
# plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh
# The block between these markers must stay byte-identical in both. It is copied
# rather than imported because a cache-installed plugin's scripts cannot read
# files outside their own plugin directory, and this 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 (the same constraint that duplicates the
# ADR-0020 constants). Edit one copy, then paste it over the other.
#
# Requires: glob, os, re, yaml (imported by the host script; PyYAML is a hard
# dependency, preflighted in bash before the interpreter starts).
@@ -268,9 +273,9 @@ def read_text(path):
# The set of names a boundary clause may resolve against is derived from an
# AUTHORING ROOT found by walking up FROM THE TARGET FILE. It is NEVER derived
# from this script's own location: deriving it from ${BASH_SOURCE} leaked
# holocron's 39-skill universe into every consumer repo that ran this hook
# through pre-commit, so a consumer skill routing to `skill-audit` resolved
# against a plugin it had never installed.
# holocron's whole skill universe into every consumer repo that ran this hook
# through pre-commit, so a consumer skill routing to a holocron skill such as
# `factory-audit` resolved against a plugin it had never installed.
#
# An authoring root is the nearest ancestor holding plugins/*/.apm/skills/ or
# plugins/*/.apm/agents/ (a plugin monorepo), falling back to the nearest
@@ -530,8 +535,8 @@ def known_targets(start_dir):
# written yet, and any new process chain re-arms it. Unexercised is not the
# same as unnecessary, and the branch it guards is still load-bearing: the
# bare-arrow rule is the sole extractor for three real targets in
# kyberforge's audit skills (agent-audit -> agent-author, agent-audit ->
# skill-audit, skill-audit -> skill-author), all written unbackticked.
# kyberforge (factory-audit -> skill-author, factory-audit ->
# agent-author, apm-orchestrate -> apm-install), all written unbackticked.
# * A backticked hyphenated token counts only inside a boundary sentence.
# Unconditionally, `pre-push` or `commit-msg` in a TRIGGER clause is a hard
# FAIL with no escape hatch. Gating it costs nothing (measured over this
@@ -1164,7 +1169,7 @@ def hand_invoked(fm_text):
#
# Both read a FENCE-MASKED copy of the body. Scanning the raw body made a
# ```-fenced example a hard ERROR — and the skills most likely to carry one are
# skill-author and skill-audit, which DOCUMENT the references/ convention — and
# skill-author and factory-audit, which DOCUMENT the references/ convention — and
# let a `## Gotchas` heading inside a fenced block stand in for the real
# section. Masking preserves every byte offset (content becomes spaces,
# newlines stay), so a span found in the mask slices the original.
@@ -1189,12 +1194,12 @@ REFERENCE_POINTER = re.compile(
REFERENCE_PAST = re.compile(
r'\b(?:removed|deleted|renamed|superseded|replaced|obsolete|deprecated'
r'|former|formerly|gone|no longer|used to)\b', re.I)
# A pointer QUALIFIED by another skill's name — "skill-audit's
# references/validation-scripts.md" — names a file that is deliberately NOT in
# A pointer QUALIFIED by another skill's name — "factory-audit's
# references/skill-validation-scripts.md" — names a file that is deliberately NOT in
# this skill's directory. Requiring it on the local disk left NO legal spelling
# for a cross-skill reference at all: the only alternative, a full repo path
# (`plugins/kyberforge/.apm/skills/skill-audit/references/...`), is itself a
# FAIL under skill-audit's own file-structure rubric, because a path that climbs
# (`plugins/kyberforge/.apm/skills/factory-audit/references/...`), is itself a
# FAIL under factory-audit's own file-structure rubric, because a path climbing
# out of the skill directory stops resolving once the plugin is cache-installed.
# The possessive form is the sanctioned spelling, and it is skipped here. It is
# not checked further — this function has no way to locate another skill's
@@ -1318,7 +1323,7 @@ for path in files:
continue
# SPEC CONFORMANCE (family 1). Whole file, frontmatter included, counted
# with the SAME primitives skill-audit/scripts/validate.sh uses for these
# with the SAME primitives factory-audit/scripts/lib-checks-skill.sh uses for these
# two constants — see the note in bash above for what the previous awk pass
# got wrong.
lines = len(raw.splitlines())

View File

@@ -1,21 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Regenerates skill-audit's Vale copy from agent-audit's canonical copy (see
# scripts/check-vale-style-sync.sh / ADR-0014). Both copies must exist on disk
# independently — a plugin's cache-install only copies each skill's own files,
# so a symlink or shared path would break at install time — but that doesn't
# mean the copy step has to be manual. Run this after editing agent-audit's
# vale-wrap.sh or styles/Kyberforge, review the diff, then commit both trees
# together.
REPO_ROOT="${1:-$(git rev-parse --show-toplevel)}"
SKILL_AUDIT="$REPO_ROOT/plugins/kyberforge/.apm/skills/skill-audit"
AGENT_AUDIT="$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit"
cp "$AGENT_AUDIT/scripts/vale-wrap.sh" "$SKILL_AUDIT/scripts/vale-wrap.sh"
rm -rf "$SKILL_AUDIT/assets/vale/styles/Kyberforge"
cp -r "$AGENT_AUDIT/assets/vale/styles/Kyberforge" "$SKILL_AUDIT/assets/vale/styles/Kyberforge"
echo "Synced skill-audit's vale-wrap.sh and styles/Kyberforge from agent-audit's canonical copy."
echo "Review the diff, then commit both directories together."