Files
holocron/docs/adr/0022-skill-metadata-version-is-mandatory.md
Defame1297 44bde9e9c9 docs(adr): correct the records the branch left describing deleted things
Seven ADRs described code that no longer exists or behaviour the gates do
not have. Where the wrong text came from main it carries a dated
correction; where this branch introduced it, it is fixed in place, because
main never published it and there is no record to preserve.

Fixed in place, branch-introduced:

- ADR-0021's 2026-09-14 correction asserted apm audit --ci "was never a
  drift gate at all". It is one: it replays the install and diffs. The
  claim contradicted this branch's own AGENTS.md and gates.md.
- ADR-0015 said unconditionally that no pre-push hook needs the network.
  The guarantee holds only once apm install has populated apm_modules/.
- ADR-0014's 2026-09-16 correction said restoring .pre-commit-hooks.yaml
  would ship a hook that fails for every consumer, because their checkout
  has no lib-boundary-resolver.sh. pre-commit clones the whole hook repo
  and skill-size-check.sh resolves the library from BASH_SOURCE, so the
  hook would work.
- ADR-0019's "twelve hooks pass under unshare -rn" matched neither HEAD
  (8) nor main (14), and stated the offline guarantee unconditionally.

Corrected, inherited from main:

- ADR-0022 and ADR-0013 named skill-frontmatter's pre-commit hook as the
  enforcer of mandatory metadata.version. That hook was deleted on this
  branch; the check lives in skill-size-check.sh.
- ADR-0022 enumerated the tip rule's carve-outs as a closed list and
  described a single merge-base. The gate also exempts a tree-identical
  skill and intersects every base from merge-base --all, and emits a third
  failure form. 8cfd54f said the documented behaviour did not change; it
  did. The gate is correct and is unchanged -- the record was not.
- ADR-0020's Decision still routed description overflow to README.md, its
  ADR-0025 amendment pointed the mirrored constants at validate.sh, which
  holds none, and its Enforcement table still named the two deleted
  validate.sh paths.
- ADR-0015's Status claimed every plugin's plugin.json is pack output;
  none exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwD8Egs5r4ndqeFLmhusX2
2026-09-20 18:38:00 +00:00

14 KiB

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.

    Correction (2026-09-20): that hook no longer exists. skill-frontmatter was removed and its required-field checks folded into skill-size-check. The enforcer is now scripts/skill-size-check.sh:324-335, declared under the skill-size-check hook at .pre-commit-config.yaml:237. It checks presence and three-part-semver shape, on the same SKILL.md glob and at the same pre-commit stage, so the decision is unaffected — only the name of the hook that holds it. The same substitution applies to the Consequences section below.

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.

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 — scripts/skill-size-check.sh (the pre-commit hook) and factory-audit's scripts/lib-checks-skill.sh — 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 amendment below reverses that last choice: the pushed version must now also exceed main's 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 also fails closed when the pushed ref does not resolve to a commit, and when a SKILL.md the tree names cannot be read by git show or parsed by python3 — a read failure is reported as such, never as a missing version. 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.

Amended by ADR-0014 (2026-09-16). check-release-needed is retired, so the comparison below records the state when this ADR was written, not a hook that still runs. See ADR-0014's amendment.

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 one ref of a multi-ref push (git push origin a b, git push --all). In pre-commit 4.6.1, _pre_push_ns in hook_impl.py skips delete lines and returns on the first remaining ref whose remote sha is non-zero and present locally; a ref whose remote sha is zero or unknown locally is returned only if it has commits that no remote-tracking ref of that remote has. Every later ref is pushed unchecked. When the returned ref's unpushed history reaches a root commit, pre-commit sets no PRE_COMMIT_TO_REF at all, so the gate checks HEAD, which is the pushed ref only if it is checked out.

Amendment (2026-09-16): the pushed version must also exceed main's tip

This reverses the choice above that the baseline is the merge-base "not the version on main's current tip". A changed skill's pushed metadata.version must now be strictly greater than both its version at the merge-base and its version at the tip of origin/main (local main under the same fallback, with the same fail-closed rules).

The merge-base alone lets two branches ship two different changes under one version. Branches A and B both start from a skill at 1.0.0, change it differently, and bump it to 1.0.1. A merges. B's merge-base is still the 1.0.0 commit, so B passes, and the two 1.0.1 bumps are the same line change, so git merges B without a conflict. main then carries two different 1.0.1 contents, and the field again fails to answer "did this change since I last read it". Checking against the tip as well makes B fail until it bumps past 1.0.1.

  • A skill absent at the tip (deleted on main since the branch started) is held to the merge-base rule alone. A skill absent at both is new and stays exempt.
  • When main has not moved since the merge-base, the two baselines are the same commit and the skill is checked once.
  • The failure names the baseline it missed: (not above merge-base) or (not above origin/main tip), one line per baseline missed.

The cost is that a branch behind main may have to bump again after another branch lands a bump on the same skill. That is the case the rule exists for, and rebasing onto or merging main first shows the version to beat. The rule reads origin/main as last fetched, so a tip that moved since the last fetch is not seen until the next one.

Amendment (2026-09-20): two exemptions and a third failure form the rules above never stated

The carve-outs enumerated above read as a closed list, and the baseline above reads as a single merge-base. scripts/check-skill-version-bump.sh as shipped has two further exemptions and emits a third failure form. The gate is right and is not changing; this ADR was behind it. Its own header comments (:9-93) have described all three correctly since it shipped.

  • The baseline is git merge-base --all, not one merge-base. A criss-cross history — main merges a branch while that branch merges a commit of main — has two merge bases, and which one git merge-base prints is an implementation detail. The script takes every base (:154-157) and intersects the changed-skill sets across them (:203-217): a skill matching any one base is already shipped by that base and is exempt, and a skill that does reach the comparison must exceed the version at every base it exists at (:367-376). Picking one base made the verdict a coin flip — an already-merged bump failed the push it should have passed.
  • A skill whose directory tree object equals the tip's skips the tip comparison. same_subtree() (:288-297, applied at :334-337) compares tree object ids rather than diffing: the same tree is the same content, whatever route the history took to it. A branch cut before a fix landed on main and then cherry-picking that fix has one merge-base, predating the fix, so the skill counts as changed against it and reaches the tip comparison carrying exactly the tip's version — same content, same version. The merge-base intersection catches that only when some base carries the content, which the criss-cross shape gives and a linear one does not. Without the skip the only escapes are a spurious bump, leaving main carrying two versions of identical content, or a rebase the push does not otherwise need.
  • A third failure form. The amendment above lists (not above merge-base) and (not above origin/main tip). When there is more than one base, the merge-base line is sha-suffixed — (not above merge-base <sha>) (:372, against the unsuffixed :374) — because "which merge-base" is the one question a reader cannot answer from the branch alone.

8cfd54f recorded that "ADR-0022 is not amended: the documented behaviour does not change". That was wrong for the tree-identical case: the same_subtree skip makes a push pass that this ADR as written requires to fail, which is documented behaviour changing, not an implementation detail.

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.