# Enforcement gates Reference for this repo's pre-commit and pre-push hooks: what each one guards, what its numbers mean, and which shapes were tried and rejected. Read it when a gate fails, before changing anything in `.pre-commit-config.yaml`, or before "fixing" something that looks like an inconsistency — several of the oddities documented here are load-bearing and have already been re-litigated once. `AGENTS.md` carries only the operative rules an agent needs in the moment. The reasoning lives here. --- ## Running the gates | Command | Scope | |---|---| | `pre-commit run --all-files` | the commit-stage hooks | | `pre-commit run --hook-stage pre-push --all-files` | the push gate, one command — with one caveat below | | `pre-commit run skill-size-check --all-files` | just the ADR-0020 size/context gates | Install hooks via `pc-run`, wiring **all three stages**. This repo's `.pre-commit-config.yaml` has no `default_install_hook_types`, so a plain install silently skips `commit-msg` (Conventional Commits) and `pre-push` (everything below). The pre-push command reports **16** hooks, not 14. The extra two are pre-commit's own `meta` hooks, `check-hooks-apply` and `check-useless-excludes`: they declare no `stages:`, so they run at every stage including this one. Both are declared in this repo's `.pre-commit-config.yaml` like everything else — what separates them is `repo: meta` (pre-commit's own built-ins) from `repo: local`. Fourteen is the count of hooks this repo authors itself. **The caveat: one of those 14 is a silent no-op under that invocation.** `check-release-needed` exits 0 immediately unless `PRE_COMMIT_REMOTE_BRANCH` equals `refs/heads/main`, and pre-commit exports that variable only from the real pre-push git hook during an actual `git push`. Running the stage by hand — or from a CI runner — therefore reports it `Passed` having checked nothing. That is by design for feature branches — pushing WIP must not be blocked on cutting a premature tag — but it means `--hook-stage pre-push --all-files` is a full rehearsal of 13 hooks and a skip of the fourteenth. The script's own header records the same gap for a PR merged through Gitea's merge button, where no local push happens at all. ## The pre-push gate Fourteen hooks, grouped below by what they guard rather than by the order `.pre-commit-config.yaml` declares them in. **Core checks** | Hook | Guards | |---|---| | `run-tests` | `bash tests/run-tests.sh --strict` — the whole suite, skips fatal (see [Tests](#tests)) | | `check-manifests` | `marketplace.json` and `plugin.json` paths resolve (needs `jq`) | **Generated-content drift gates** | Hook | Guards | |---|---| | `check-plugin-content-sync` | each plugin's flat `skills/agents/commands/hooks` mirror matches `.apm/` (issue #90) | | `check-marketplace-mirror-sync` | `.github/plugin/marketplace.json` is byte-identical to `.claude-plugin/marketplace.json` — no apm output profile targets that path | | `check-vale-style-sync` | skill-audit's Vale copy matches agent-audit's canonical copy, plus six glob-coverage probes (see [Vale](#vale)) | | `check-scope-walkup-sync` | `validate.sh`, `validate-provenance.sh`, `new-agent.sh` and `new-skill.sh`'s four independent `$HOME`/`.git`/`apm.yml` walk-up ports still agree behaviorally | | `check-executables-allow-sync` | root `apm.yml`'s `executables.allow` key names kyberforge's actual version (see [apm gates](#apm-gates)) | `check-executables-allow-sync` is the odd one in this group: it guards a *silent failure* rather than drift in generated text. **Artifact validators** | Hook | Guards | |---|---| | `check-apm-agents-valid` | runs agent-audit's `validate.sh` over every real `plugins/*/.apm/agents/*.agent.md` (see [Agent files](#agent-files-take-the-description-gates-not-the-body-gate)) | **apm's own gates** | Hook | Guards | |---|---| | `apm-marketplace-check` | every `marketplace.packages[]` entry resolves, including network reachability of remote refs | | `apm-audit-ci` | `apm audit --ci` once per manifest — root plus each of the six plugin packages | | `apm-pack-check-clean` | `apm pack --check-versions --check-clean --dry-run` — the compiled marketplace still matches what `apm.yml` + `.apm/` would generate, and per-package versions agree with the `per_package` strategy | **Host validators** (both need the `claude` CLI on PATH) | Hook | Guards | |---|---| | `validate-plugins` | `claude plugin validate --strict` on every plugin directory | | `validate-marketplace` | `claude plugin validate --strict` on the root marketplace manifest | **Release** | Hook | Guards | |---|---| | `check-release-needed` | on a real `git push` to `main` only — fails if files exposed via `.pre-commit-hooks.yaml` changed since the last tag. A no-op everywhere else, including under `pre-commit run --hook-stage pre-push` (see [the caveat above](#running-the-gates)) | Four of these shell out to `apm`: `apm-marketplace-check`, `apm-audit-ci`, `apm-pack-check-clean`, and `check-plugin-content-sync` (via `scripts/sync-plugin-content.sh`, which wraps `apm pack`). The first and third are bare `apm …` entries and the second is a `bash -c` loop calling `apm` once per package, so without the CLI the push dies with an unhelpful "command not found". Install with `apm-install`, or `curl -sSL https://aka.ms/apm-unix | sh`; verify with `apm --version`. `jq` is needed by `scripts/check-manifests.sh` and `scripts/sync-plugin-content.sh` — those at least fail loudly (`Error: jq is required but not installed`). ## Skill and agent context gates (ADR-0020) The `skill-size-check` pre-commit hook, scoped to `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$`, runs `scripts/skill-size-check.sh`. It is also shipped to external repos as `kyberforge-skill-size-check` (see [External consumers](#external-consumers-the-root-pre-commit-hooksyaml)). **Two things fall outside that scope, both deliberately.** The `[^/]+/SKILL\.md$` tail admits only a `SKILL.md` sitting directly in a skill directory under `.apm/skills/`: - the `plugins/kyberforge/docs/research/examples/` reference skills, which are vendored upstream corpus and not this repo's to gate; - `plugins/kyberforge/.apm/skills/skill-author/assets/templates/SKILL.md` — inside `.apm/skills/`, but two directories deeper. It is the `FILL IN:` scaffold `skill-author` copies, so its `description: >` is a comment block rather than a description and every ADR-0020 measurement over it would be meaningless. A reader adjusting the pattern needs to know it is there. Everything else it matches exactly, with nothing over- or under-caught. Re-derive both halves: ``` git ls-files | grep -cE '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$' # the real skills git ls-files | grep -E '^plugins/[^/]+/\.apm/skills/.*SKILL\.md$' \ | grep -vE '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$' # the scaffold only ``` The first count equals the number of skill directories (`ls -d plugins/*/.apm/skills/*/ | wc -l`); the second returns exactly the template. The remaining unmatched `SKILL.md` files in the tree are the generated flat mirror, which is excluded by the `.apm/` segment on purpose — a mirror edit is drift, not an authoring change. ### `skill-frontmatter`, the other hook on that scope A second `repo: local` pre-commit hook, `skill-frontmatter`, runs on the **same** `files:` pattern at the same stage. It is a shell loop that, **for the YAML frontmatter block only** — everything between the opening `---` and the next `---` — asserts four things per file: | Check | Rejects with | |---|---| | a `^name:` line is present | "missing required frontmatter fields (name: …)" | | a `^description:` line is present | "missing required frontmatter fields (description: …)" | | `metadata:` contains a `^ version:` key, anchored, scanning to the next top-level key | "missing required frontmatter fields (metadata.version)" | | that version's value is three-part semver (`1.0.0`, quoted or not) | "has a malformed frontmatter metadata.version (…)" | Every one of those qualifiers is load-bearing, and each replaced a defect that let the hook report Passed having measured nothing. `tests/test-skill-frontmatter.sh` pins all of them: - **Frontmatter-scoped, not whole-file.** The checks used to `grep` the entire file, so a `metadata:` or `name:` block quoted in a **body code fence** satisfied them — `skill-author`'s own docs quote exactly such a block. - **Bounded by the next top-level key, not by `-A10`.** The version check was `grep -A10 "^metadata:" | grep -q " version:"`, which ran ten lines past the end of the block: a `version:` belonging to a following `source:` list entry counted (`write-docs` and `research` both have a `source:` list immediately after `metadata:`), while a `metadata:` block with more than ten lines before its `version:` was reported missing. - **`^ version:` anchored.** `" version:"` was an unanchored substring, so a deeper-nested ` version:` matched too. - **The value is asserted, not just the key.** `plugins/bin/.apm/skills/write-docs/SKILL.md` carried `version: "1.0"` — present, correctly nested, and not a version — through an entire PR under a presence-only check. Two-part `1.0` is a YAML float, not a version string. - **The call shape is pinned.** `entry: bash` with `args: ['-c',