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
11 KiB
source_keys
| source_keys | ||
|---|---|---|
|
Validation Scripts Reference
Read this when a Step 1 script fails, cannot run, or reports something that needs interpreting —
including validate-provenance.sh exiting 0 having printed something, which is INFO findings,
not a clean run. Its silent exit 0 is the only outcome that needs nothing here.
Report the gap, do not guess
If a script cannot run at all — Bash denied, python3 unavailable, PyYAML not importable, vale
not installed — say so as an INFO finding naming the script and the missing dependency, then
fall back to the manual checks below. An INFO never changes PASS/FAIL. Silently omitting the
dimension a script would have covered reports a clean audit that checked less than it claims to
have checked, and the Step 4 coverage line then names a dimension nothing actually examined.
Manual structural fallback
validate.sh needs python3 and PyYAML, and refuses to start without either — the description
value has to be measured after YAML folding is resolved, so skipping these gates would be a
vacuous pass rather than a partial one. The two are checked separately, so the message already names
the right one — report it verbatim rather than diagnosing further:
Error: python3 is required but was not found on PATH.
Error: PyYAML is required but is not importable by python3.
Without them — or with Bash denied, or on a permission error — work this list
by hand and file the results under ### Structure exactly as the script's output would have been:
namepresent, 1–64 characters, kebab-case (lowercase letters, digits and hyphens; no leading, trailing or doubled hyphen), and matching the skill's directory name exactly.descriptionpresent and non-empty; no unfilledFILL IN:placeholder in it. An absent or empty description is a FAIL, never a silent skip — it is the one field preloaded into every session, so a skill without one can never be routed to.- Description length, measured on the folded YAML value with newlines collapsed to single spaces — not on the raw block scalar, which counts indentation. 250 characters SUGGESTION, 400 FAIL (house), 1,024 FAIL (agentskills.io spec).
- Body length, counting everything after the frontmatter's closing
---. 600 words SUGGESTION, 900 FAIL (house). - Whole-file ceilings, counting the file including frontmatter: 500 lines FAIL, 2,770 words FAIL (agentskills.io spec). These are a different measurement from the two above — report them as separate findings, never merged.
- A boundary clause is present — either the prose form (
do not/instead/rather than/not for) or the compressedNot <thing> -> <name>arrow. SUGGESTION, not FAIL: the absence is deterministic, but whether this skill warrants one is the auditor's call. - Boundary targets resolve — FAIL on a name that resolves to nothing. See the section below; resolving these by hand is the one item on this list with a procedure of its own.
- Every
references/<file>.mdnamed in the body exists on disk — FAIL, not a suggestion. A dispatch table or "read X" trigger naming a missing file sends the agent nowhere. Ignore mentions inside fenced code blocks, and ignore a mention whose own line says the file is gone (removed,deleted,renamed,superseded,replaced,obsolete,deprecated,former,gone,no longer,used to) — that is a historical note, not a dispatch entry. - Gotchas discipline, both SUGGESTION. Locate the section by a heading that is Gotchas
(
## Common Gotchascounts;## Gotcha handlingand## Why gotchas matterdo not), running to the next heading at the same level or shallower. More than five top-level entries is one suggestion; a section over 25% of the body word count is a second, independent one. Count entries at column 0 only — an indented child bullet is not an entry — and ignore fenced code blocks for both. - No unfilled
FILL IN:placeholder anywhere in the body. - Every file in
scripts/carries the executable bit and contains no interactive prompt — no bareread, noselect, nothing that blocks on a TTY.
Resolving boundary targets by hand
Targets are read from both boundary forms. The compressed Not <thing> -> <name> arrow and the
prose form are each parsed and target-checked, so a typo in prose phrasing fails exactly as an
arrow typo does — do not check only the names after an arrow.
Build the universe by walking up from the SKILL.md under audit, never from the validator's own
location. The nearest ancestor holding plugins/*/.apm/skills/ or plugins/*/.apm/agents/ is the
authoring root, falling back to the nearest ancestor holding .git. When one is found the universe
is every skill and agent under <root>/plugins/*/, plus the skill's own apm package, plus the
packages that package declares in its apm.yml under dependencies.apm. Deployed .claude/ and
.agents/ trees are consulted only when no authoring root exists — they are gitignored
apm install output, and reading them would make a fresh clone and a developer machine disagree.
Three ways to read the result wrong:
- A hyphenated name used attributively is not a dangling target. "Use pre-commit hooks instead
of ad-hoc scripts" reads as a route to
pre-commiton wording alone. What separates a route from prose is grammar: a route target is terminal — followed by punctuation, a conjunction, or a boundary word — whereas a compound modifier is followed by the noun it modifies. A name followed by an ordinary noun still confirms a route when it exists, but never raises a FAIL on its own. - A SUGGESTION-tier unresolved target is not a FAIL you may promote. Terminal position alone is
not evidence of a route: "run
pre-commitinstead", "seecommit-msg" and "use the clean-up instead" are all terminal and all prose. A prose-form target earns a FAIL only when its own sentence names another target that does resolve; otherwise the script reports it and moves on, and so should you. Route notation —/nameand-> name— is exempt and always FAILs, and it is the fix to recommend when the author did mean a route. INFO boundary-target resolution DID NOT RUNis not a pass. The script prints it, and exits 0, when no universe could be determined for that path — the usual cause being a skill copy audited outside its package. Report it as an INFO naming the unchecked targets and re-run against the real directory; filing it as clean signs off targets nothing verified.
Script-specific failures
validate-provenance.shprinted nothing and exited 0. That is a pass, not a skip — it exits 0 silently when the skill has nosource_keysand noreferences/sources.md, and nothing to validate is not a finding. Check the exit code before you believe the silence: a target that is not a directory, a directory holding noSKILL.md, a missing or extra argument, and an absentpython3all exit 2 with a message on stderr. Exit 2 means the script never ran — report it as an unaudited dimension, never as a pass and never as a finding. Exit 1 is findings.- A check-9 INFO —
'<field>' changed for '<slug>' since <ref>— means go read, not just relay. Check 9 diffs the currentreferences/sources.mdagainst a base ref and flags a slug whoseDescriptionorContributing filestext differs. It is structurally incapable of telling you whether the new wording is still true — it only detects that the text changed — so when this INFO fires, open that slug's own entry: the document named in itsResearch doc:field, and the files itsContributing fileslist names. Read whichever the changed field is a claim about — a Description-only change often leaves the file list untouched, so "open the Contributing files" is where to look, not proof that they are what moved. Confirm by reading whether the (possibly strengthened) claim genuinely holds. This is the one provenance finding this script cannot verify for you: every other check here is a structural fact you can relay as-is, but check 9's job is only to tell you where to spend that reading effort, not to replace it. Acknowledging the INFO without opening those files is not auditing it. Its companion —'<field>' removed for '<slug>' since <ref>— is the same obligation in the other direction: a claim withdrawn rather than rewritten. No other check here requires the field, so confirm the removal was deliberate. - The check-9 base ref defaults to
git merge-base HEAD origin/main, and there are two ways to override it.--base-ref=<ref>on the command line, or theVALIDATE_PROVENANCE_BASE_REFenvironment variable; the flag wins when both are given, including when it is given empty (--base-ref=), which selects the default resolution and ignores the environment. Reach for one on a fork, a long-lived branch, or a mirror whose remote is not calledorigin— and when a review asks what changed since a specific commit rather than since the branch point. - A single check-9 INFO naming a whole-check skip is an unaudited dimension, not a finding about
the skill. There are three: "no repo root above the skill directory", "no base ref could be
resolved", and "
<path>is not tracked at<ref>". The third is the one to read carefully — it fires when the base ref resolved butgit show <ref>:<path>did not, which covers both a genuinely newsources.md(nothing to flag) and a path git does not know under that name: a renamed skill directory, or an installed, gitignored copy such as a deployed.claude/skills/tree. Auditing the deployed copy silently checks nothing; re-run against the authoring path underplugins/*/.apm/skills/. valereports0 files. Treat the pass as NOT RUN, not as clean, and fall back to full Step 3 judgment for the dimensions it would have covered. The bundledKyberforgestyle is scoped by glob inassets/vale/.vale.ini; a file outside those globs is silently not linted.E100 Runtime error ... does not exist(exit 2) fromvale-wrap.sh. An explicit relative--configwas passed. Pass none: the wrapper locates its ownassets/vale/.vale.inifrom its own path, so a resolved script path plus an unresolved config path produces exactly this. Do not read this exit code as vale being unavailable — that misreading sends the audit down the fallback path while vale was installed and working the whole time.- The
valebinary is genuinely absent (command not found). Report one INFO naming it, then fall back to full Step 3 judgment for the description, body-discipline and patterns dimensions — the prefilter's whole coverage. Judge those by rubric rather than dropping them. - A path argument that does not exist is a hard error in
vale-wrap.sh, deliberately: barevalewould fall back to reading stdin and print a clean-looking0 errors ... in stdin, which the0 filesguard above does not catch.