Files
holocron/docs/adr/0022-skill-metadata-version-is-mandatory.md
Defame1297 8451169d2b feat(gates): enforce metadata.version bumps on changed skills at pre-push
check-skill-version-bump fails a push when a skill directory changed
against its merge-base with main (tests/ excluded) without a strictly
higher metadata.version than main. New, renamed and deleted skills are
exempt; every plugin is covered. Recorded as a dated section in
ADR-0022 and documented in gates.md.

Patch-bumps the 17 skills that changed on this branch without a bump,
so the branch passes its own gate. Simplification audit finding 33.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:09:56 +00:00

115 lines
7.0 KiB
Markdown

# 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
**Context.** 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.)
**Decision.** `scripts/check-skill-version-bump.sh` runs as a pre-push hook on every push. For each
skill directory under `plugins/*/.apm/skills/` that differs between the pushed ref and its
merge-base with `main`, ignoring `tests/`, the pushed `metadata.version` must be strictly greater
than `main`'s.
- **Baseline is the merge-base with `main`, 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.
- **"Greater", not "exactly one patch higher".** A second `skill-author` pass on the same branch
that bumps again still passes.
- **`tests/` is excluded.** 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; 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`.
**Considered options.** *Declare the field advisory* — cheapest, but concedes the field cannot do
its job. *Drop the field* — rejected by this ADR already, and costlier now. *Check at commit
time against `HEAD`* — rejected for the inflation above.
**Consequences.** The 17 unbumped skills took a patch bump in the commit that added the gate.
Like `check-release-needed`, the gate only fires on a local `git push` through pre-commit: a merge
made with Gitea's merge button runs no local hooks and is not checked. A typo fix in a skill now
costs a version bump; that is the rule working, not noise.