# Every skill's `metadata.version` is mandatory, not a per-plugin option **Status: accepted (2026-09-07).** ## Context `metadata.version` is optional SKILL.md frontmatter (`create.md`'s "Optional frontmatter" list: "uncomment and fill in, or remove entirely"). `skill-author`'s own bump logic was written conditionally — "with `metadata.version` present, bump the minor version on create... and the patch version on improve" — which only makes sense if presence is a real per-skill choice. Adoption never followed a rule; it followed the plugin. Of 39 skills, 12 carry a version: | Plugin | Has it | Total | |---|---|---| | `core` | 3 | 3 | | `gitea` | 6 | 7 | | `lint` | 2 | 2 | | `git` | 1 | 9 | | `bin` | 0 | 11 | | `kyberforge` | 0 | 7 | `core`, `gitea` and `lint` are consistent adopters (`gitea-files` the one gap); `bin` and `kyberforge` are consistent non-adopters; `git` has one outlier (`git-commits`, versioned for no plugin-specific reason found on inspection — no comment, no cross-reference, nothing distinguishing it from its eight siblings). Issue #127 raised this as an undocumented split: two house norms coexisting with no stated rule for which applies where, the same class of defect as an unstated `rtk`/bare-`git` convention (#113) found in the same audit pass. ## Decision **Every skill's frontmatter carries `metadata.version`.** It is no longer optional, and no longer a per-plugin choice. - **The 27 skills that never carried one are seeded at `1.0.0`**, not `0.1.0`. `0.1.0` is `skill-author`'s existing new-skill starting point, chosen for a skill with no revision history to its name yet. These 27 have all been through the ADR-0020 retrofit and repeated audit passes without ever tracking a version; crediting them with `0.1.0` would understate that, and there is no real history to justify seeding higher than a first stable release. `1.0.0` marks "versioned as of this retrofit," `0.1.0` keeps meaning "created and never yet revised." - **New skills still start at `0.1.0`.** `skill-author`'s create/improve bump convention is unchanged; only the presence of the field stops being conditional. - **The one outlier in the other direction, `git-commits`, keeps its existing value** (`0.1.3`) — it already had real tracked history under the old conditional rule, and this decision does not reset skills that were already compliant. - **`bin/write-docs`'s top-level `version:` moves into `metadata:`, normalized to `1.0.0`.** It is the one skill that carried a version outside the `metadata:` block, which is why the table above counts `bin` as 0 — a top-level `version:` is not `metadata.version`, and nothing reads it. #127 raised it alongside the split because "does a skill carry a version" and "where does it live" are the same question. Its value (`1.0`) is not semver and carries no more real history than the 27 unversioned skills, so it is relocated and reset to the same `1.0.0` seed rather than preserved like `git-commits`'s tracked `0.1.3`. - **`skill-frontmatter`'s pre-commit hook gains the check.** It already fails a SKILL.md missing `name:` or `description:`; a missing `metadata.version` is now the same class of failure, not a style nit an audit might or might not catch. ## Considered options **Leave it per-plugin, document the split.** This was the initial framing of #127 and is coherent — `core`/`gitea`/`lint` keep it, `bin`/`kyberforge` don't, two outliers get normalized to match their plugin. Rejected on reconsideration: a rule that says "some plugins track this and some don't" is strictly harder to state, audit and onboard against than "every skill does," for a field whose entire job is answering "did this change since I last read it" — a question with the same shape everywhere it's asked, not one that varies by plugin domain. **Drop the field corpus-wide.** Rejected: `skill-author` already depends on it to decide whether a create/improve pass owes a bump, so the 12 skills carrying it are not tracking dead weight — removing it discards real revision signal for no gain. ## Consequences 27 SKILL.md files gain `metadata.version: "1.0.0"`, and a 28th — `bin/write-docs` — reaches the same value by relocating its top-level `version: "1.0"` into `metadata:`. `skill-author`'s `create.md` moves the field from "Optional frontmatter" to the required list, citing this ADR. `skill-author`'s own SKILL.md drops the "with `metadata.version` present" conditional in its bump-rule line, since presence is no longer in question. `.pre-commit-config.yaml`'s `skill-frontmatter` hook is extended to require the field, closing the gap #113 and #118 both named in the same audit pass: a stated rule with nothing enforcing it drifts the same way an unstated one does. ## Amendment (2026-09-16): the bump is enforced at push, not only required to exist Making the field mandatory did not make it move. The only thing that bumped it was `skill-author` Step 4, so every hand edit and every trim pass skipped the bump: on `docs/simplification-audit`, 17 of the 40 skill directories that changed against `main` carried the same `metadata.version` as `main`, and `gitea` alone sat at six different values. Both validators checked presence and semver shape, never movement, so the field could not answer the question this ADR gives it — "did this change since I last read it". (Simplification audit finding 33.) `scripts/check-skill-version-bump.sh` now runs as a pre-push hook on every push, whatever the target branch. It takes its baseline from the merge-base of the pushed commit with `origin/main` (local `main` if `origin/main` does not resolve). For each skill directory under `plugins/*/.apm/skills/` that differs between the pushed commit and that merge-base, ignoring `tests/`, the pushed `metadata.version` must be strictly greater than the version the skill had at the merge-base — not the version on `main`'s current tip. The baseline is the merge-base, not the previous commit. Readers only ever see `main` — installs resolve against the default branch (ADR-0018) — so one bump per branch is what the field owes them. A per-commit check would bump a skill once per commit and inflate the number past meaning. The rule is "greater", not "exactly one patch higher", so a second `skill-author` pass on the same branch that bumps again still passes. `tests/` is excluded because no agent loads it; a fixture-only change does not change the skill. Skills absent from either side are exempt: new, renamed and merged skills start fresh under the rules above, and deleted skills have nothing to check. Every plugin is covered, `bin` included, and the gate is repo-local — it is not exported through `.pre-commit-hooks.yaml`. The gate fails closed rather than passing when it has no trustworthy baseline: when neither `origin/main` nor `main` resolves, when the pushed commit shares no merge-base with it, and when only local `main` resolves and the pushed commit is that merge-base, since a local `main` the pushed commit already contains is no independent record of what shipped. It reads versions with `python3` and PyYAML and fails with a clear message if either is missing. Three alternatives were rejected. Declaring the field advisory is the cheapest, but concedes the field cannot do its job. Dropping the field was rejected by this ADR already, and costs more now. Checking at commit time against `HEAD` was rejected for the inflation described above. The 17 unbumped skills took a patch bump in the commit that added the gate. A typo fix in a skill now costs a version bump; that is the rule working, not noise. The gate differs from `check-release-needed` in when it runs: that hook acts only when pre-commit reports a push to `main`, so a manual `pre-commit run --hook-stage pre-push` skips it, while this gate runs there too and checks `HEAD`. The two hooks share both known gaps. A merge made with Gitea's merge button runs no local hooks, so it is not checked. And pre-commit's pre-push integration checks only the first ref with new commits in a multi-ref push (`git push origin a b`, `git push --all`): `_pre_push_ns` in pre-commit's `hook_impl.py` returns after that ref, so the other refs are pushed unchecked.