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
14 KiB
source_keys
| source_keys | |||
|---|---|---|---|
|
The description and body contract
House contract. Every rule here is enforced by /factory-audit —
scripts/validate.sh for the counts and the boundary targets, the bundled Vale styles for the
prose patterns, and its reference files for the judgment calls.
Why the budget exists
A skill's name and description are loaded into every agent's context at the start of every
session, whether or not the skill is ever invoked. The body is loaded only on invocation, and then
competes with the caller's live conversation. Those are two different costs, so they get two
different ceilings — and a fat description is not merely expensive. A description that summarizes
the workflow gets followed instead of the body: a description saying "code review between tasks"
produced one review from a skill whose flowchart specified two.
Description
A description carries exactly three things:
- Trigger clause — when to invoke, imperative: "Use when ...", never "This skill ...". Focus on user intent, not the skill's internal mechanics.
- At most one capability clause — what it does, one clause, no enumeration. Be specific ("parses and validates OpenAPI specs", not "helps with APIs").
- Boundary clause — form:
Not <thing> -> <skill-name>.Add one only where a near-miss skill could steal activations.
Banned from a description; move it to the body or to README.md:
- Capability enumeration or feature lists
- Output-format detail ("Produces a compact findings report with Why and Fix per finding")
- Composition or architecture notes ("composes X rather than duplicating Y", "This is a cross-cutting shared skill", "the human-facing entry point")
- Implementation detail ("Self-validates via a bundled deterministic script")
- Restating the same trigger twice in two registers — a verb list, then the same verbs re-quoted as user phrasings. This is a FAIL, not a suggestion.
Indirect triggers are conditional, not mandatory. Add "even if the user doesn't mention X
explicitly" only where the user's natural phrasing genuinely omits the domain word — true for the
gitea-* family, because people say "create an issue" rather than "create a Gitea issue"; false
for git-commits, where the user says "commit". Adding one everywhere is what inflated this
corpus, and it was deleted as a blanket rule.
Boundary targets must resolve, and the notation decides how hard the gate bites. Route
notation — /name, or any arrow form (-> name, -> `name`) — is checked
unconditionally: an unresolved target there is a blocking ERROR. The prose form ("do not use
for X, use y instead") is only a SUGGESTION by default, because a bare hyphenated word in a
boundary clause is as likely to be a tool, a file format or an English compound as a route. It
is promoted to a blocking ERROR only when a second target in the same sentence does resolve,
which corroborates that the name was meant as a route. So a typo does not dangle equally
either way — write the arrow when you want the target checked. Targets resolve against a universe built by walking up from the SKILL.md
itself: the nearest ancestor holding
plugins/*/.apm/{skills,agents} (or, failing that, the nearest ancestor holding .git) contributes
every skill and agent under <root>/plugins/*/, plus the skill's own apm package and the packages
that package declares in apm.yml under dependencies.apm. A sibling plugin in the same monorepo
therefore resolves; a skill in an unrelated repo does not. A boundary clause naming a target
outside that universe sends the router nowhere — a blocking failure in arrow or /name form, and
in prose form a SUGGESTION nobody is forced to act on, which is the worse outcome because it ships.
Check the target exists before writing it — do not invent a plausible sibling name.
That universe is the apm marketplace and stops there. A host built-in is not a routing target:
/compact, /clear and /init are Claude Code slash commands with no counterpart in Copilot CLI
or Codex, and .apm/ source compiles for all three, so routing to one is a portability defect. The
gate is right to fail it and there is no allowlist. If a built-in genuinely needs mentioning, write
it un-slashed — the `compact` built-in — which makes no routing claim and is not checked.
One arrow, one target. The resolver reads only the first name after an arrow, so a second is
checked by nothing and the gate emits a SUGGESTION naming both. Split instead of conjoining:
Not <thing> -> first-skill. Not <other thing> -> second-skill., never
Not <thing> -> first-skill or second-skill.
Never let a hyphenated routing target wrap across lines in a folded > scalar. YAML folding
replaces the newline with a space, so gitea-labels- at the end of one line and milestones at
the start of the next fold into gitea-labels- milestones. The gate then reads the target as
gitea-labels, finds no such skill, and reports it dangling — this is what broke
gitea-labels-milestones, and nothing in the source lines looks wrong. Reflow so the whole name
sits on one line. The same applies to any backticked skill or agent name anywhere in a description.
Length. 250 characters SUGGESTION, 400 characters FAIL, counting the frontmatter value only with YAML folding resolved. The agentskills.io 1,024-character spec limit is unchanged and sits above both. The SUGGESTION tier is the one that moves the average; treat 250 as the target and 400 as the outlier stop.
Hand-invoked skills are exempt. A skill carrying disable-model-invocation: true is absent
from the model-visible listing and is reached only by the user typing /name. It takes one plain
human-facing sentence — no trigger clause, no boundary clause, no indirect triggers. Worked
example — the whole description of the zoom-out skill, which carries disable-model-invocation:
Tell the agent to zoom out and give broader context or a higher-level perspective. Use when
you're unfamiliar with a section of code or need to understand how it fits into the bigger
picture.
Body
The body carries the decision procedure only: ordered steps, decision branches, gates, and
which reference to load when. Everything else moves to references/.
Ask of every sentence: "Would the agent get this wrong without it?" Cut anything that answers "no."
Include:
- Non-obvious sequences or ordering constraints — the agent may skip or reorder steps without this
- Domain conventions the agent cannot infer from general knowledge — the core value a skill adds
- One default per decision point, plus one escape hatch — never a menu; menus cause the agent to pause or pick arbitrarily
- Gotchas — facts that defy reasonable assumptions
Exclude:
- Concepts the agent already knows (what JSON is, how HTTP works) — tokens without behavior change
- Exhaustive option lists — pick a default; the agent does not benefit from choosing
- Steps the agent handles independently — over-specifying leads agents down unproductive paths
- Restatements of the description — it is already in context
Move to references/: lookup tables, spec restatements, output schemas, templates, example
blocks, rationale prose, and any content only one branch reaches. Each reference file is
self-contained for its concern, and every one is wired from the body with the literal conditional
form:
The one exception, stated once so it is not re-litigated: an output schema stays in the body
only when it applies to every flow and is short — roughly 50 words or less, which is the "Output
format template" pattern below. An output schema that is longer than that, or that only one flow
produces, moves to references/ like any other schema. No third option exists, and the two rules
do not disagree.
If <condition>, read `references/<file>.md`.
A generic pointer ("see references/ for details") is a Vale error — the agent cannot act on it.
A dispatch table is the wiring. Where the body dispatches, a row already pairs a condition with
a target, which is what the literal form encodes — so do not restate each row underneath as a prose
conditional. That duplicates the routing in the one body whose whole purpose is to be short. The
literal form is what a reference loaded without a table needs: a mid-procedure deepening, an
escape hatch, an error path. A table earns this on four properties — every flow has a row and every
row's target exists on disk; each row pairs exactly one target with a condition the agent can
evaluate from the request, never a literal slash invocation; one line after the table names the
matched file as the only one to read; and the gates every branch needs sit in the body, not inside
one flow's file. That last one is the property the git-commits v0.1.2 failure turned on, and it is
the one a dispatch split is most likely to break. factory-audit's references/skill-body-discipline.md
carries the audit-side form of the same exemption; the two lists are the same four properties, and
an edit to either belongs in both.
Dispatch is mandatory at two or more mutually exclusive flows. The body carries the dispatch
table and the gates common to every branch; each flow gets its own self-contained references/
file. Exemplar: the apm-workflow skill — a 294-word body dispatching to 3,154 words of
references across five flow files. Calibrate against 294: that file's whole-file count is 348
words, and aiming at that number instead overshoots the body budget by ~18%. The 3,154 excludes
references/sources.md, which is a provenance record and is never loaded at runtime.
Length. 600 words SUGGESTION, 900 words FAIL, counting the body only — everything after
the frontmatter's closing ---.
Gotchas section
- Each entry must state a fact that contradicts a reasonable default — something the agent gets wrong by acting sensibly. "Write a descriptive commit message" is not one; the agent does it unprompted and nothing in the environment argues against it. A safety gate is a different case, even where the agent knows the rule — see the paraphrase bullet below.
- More than five entries is a SUGGESTION — five is the guideline, not a ceiling.
- A Gotcha that paraphrases a step in the body below it is a FAIL, but deleting it is correct
only when the surviving copy is reachable from every branch that reaches the Gotcha. In a
dispatch body it often is not: each flow file loads alone, so a step in one is invisible to an
invocation that took another branch. Where the restated rule is a safety gate more than one flow
needs, move it into the body's common-gates section instead of dropping it.
git-commitsv0.1.2 is the worked failure: the retrofit deleted its always-loaded "never commit secrets" Gotcha in favour of a step in one flow file, and left the history-rewrite branch — which stages and--amends, committing new content exactly as a fresh commit does — with no such check anywhere in its loaded context, against this repo's governance hard prohibitions. v0.1.3 carries the rule as a gate on every flow. - A Gotchas section exceeding 25% of the body is a SUGGESTION.
- Place the section near the top — a gotcha read after the mistake is worthless.
Two size gates, two measurements
| Gate | SUGGESTION | FAIL | Counts |
|---|---|---|---|
| description | 250 chars | 400 chars | the description: value only |
| body | 600 words | 900 words | the body only, after the closing --- |
| spec backstop | — | 1,024 chars | the description: value only |
| spec backstop | — | 2,770 words / 500 lines | the whole file, frontmatter included |
The 600/900 pair and the 2,770/500 pair are not the same measurement and must not be unified: the first is a quality gate on what the caller's context absorbs, the second a conformance backstop on the file. A skill can sit well inside one and fail the other.
When a body approaches its ceiling, relocate rather than delete — move reference material to
references/<topic>.md behind a conditional trigger, and bundle repeated executable logic into
scripts/ rather than reinventing it each run.
Body patterns
Default with escape hatch (not a menu):
Use <X> for <task>. For <edge case>, use <Y> instead.
Prescriptive sequence (when order is critical or fragile):
Run exactly:
```bash
<command>
```
Do not modify flags.
Checklist (multi-step workflows):
- [ ] Step 1: ...
- [ ] Step 2: ...
Dispatch table (two or more mutually exclusive flows):
| Condition | Flow | Reference |
|---|---|---|
| <condition> | <flow> | `references/<file>.md` |
Output format template (when the skill produces structured output on every flow, and the
schema is roughly 50 words or less — see the exception under Body above; anything longer or
flow-specific belongs in references/):
Output format:
```
<field>: <value>
```
For longer templates, place them in references/<topic>.md or assets/<name>.md and reference
conditionally.
Embedding org-specific policy
If a skill encodes a rule sourced from an org convention file (e.g. core/instructions/*.md),
inline that content directly into the skill (SKILL.md or a references/ file) rather than pointing
to the file's path. Plugins must be self-contained and portable — the org file may not exist
wherever the plugin is installed, and in this repo such files are meant to be deleted once their
content is fully embedded downstream. Tag the inlined content with a source_keys entry using the
same references/sources.md schema as the create flow's Step 6, noting in the Research doc:
field that the source is an org convention rather than a plugin research corpus entry, so
provenance survives after the source file is gone.