Files
holocron/.pre-commit-config.yaml
Defame1297 ed8c99efbd fix(git): stop prefixing rtk where it rewrites the output skills parse
The #113 sweep rested on CLAUDE.md's premise that rtk either filters or passes through unchanged,
so prefixing is always safe. Measured against rtk 0.42.4, that premise is false for several of the
commands the sweep prefixed, and two skills were left giving wrong answers silently.

Why:
- `rtk git worktree list --porcelain -z` discards both flags and renders its own format. The
  `locked`/`lock_reason` fields git-worktrees Step 2 must emit are absent entirely, and paths under
  $HOME are abbreviated to `~/`.
- `rtk git branch --list <name>` prints a phantom `* ` line even when nothing matches, so
  git-branches' stated ambiguity test — "output from both means the name is ambiguous" — reported
  every name as ambiguous. `tag --list` is a clean passthrough, so only one half broke.
- `rtk git diff --name-only`/`--name-status` append a `Changes:` trailer to output documented as
  "one per line"; `--word-diff` emits none of the `[-removed-] {+added+}` markers its table
  describes; `rtk git log -L` truncates each line at ~72 chars, on the one command whose purpose is
  showing line content.
- `rtk git stash pop` prints only `FAILED: git stash pop`, swallowing the conflict diagnostic and
  retained-entry message the surrounding prose tells the agent to rely on.

Implementation notes:
- Eleven sites reverted to bare `git`, each carrying its reason inline so the next sweep does not
  undo it. `mergetool` and `rebase -i` are reverted on clause 3's interactive limb only: the TTY
  defect does not reproduce — rtk filters exactly twelve subcommands and execs the rest — and
  ADR-0023 records that measurement rather than a convenient one.
- ADR-0023 states the rule repo-wide with a third clause: a command whose output the skill parses,
  or which is interactive, stays bare. `plugins/git/README.md` is reduced to a pointer; its claim
  that gitea skills "contain no git/rtk mentions at all" was false, and its citation of
  `hard-rules.md` pointed at a file containing no occurrence of "rtk".
- Eight gitea sites swept, all verified byte-identical passthroughs first.
- `scripts/check-rtk-prefix.sh` gates clause 1. Run against main's pre-sweep corpus it reports 99
  findings including every gitea site, so it would have caught the drift #113 was filed about.

Impact: the gate covers clause 1 only, in shell-tagged fences and the opening span of Run cells.
Clause 2 is not gateable — "Run `git switch`" and "`git switch` refuses" are the same tokens — and
prose bullets are invisible to it. Both limits are recorded in gates.md rather than left implied.

Refs: #113
ADR: 0023
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeH8SCbcrCAQrtymkNuhKP
2026-09-09 05:14:54 +00:00

386 lines
20 KiB
YAML

repos:
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v2.4.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
hooks:
- id: gitleaks
stages: ['pre-commit']
- repo: https://github.com/jumanjihouse/pre-commit-hooks
rev: 3.0.0
hooks:
- id: shellcheck
args: [--severity=warning]
stages: ['pre-commit']
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: end-of-file-fixer
stages: ['pre-commit']
- id: check-json
stages: ['pre-commit']
- id: pretty-format-json
stages: ['pre-commit']
args: [--autofix]
# Every generated manifest lives at a KNOWN path, so every alternative is
# root-anchored and spells that path out. This was five `(^|/)`
# any-depth alternatives plus one `^` root-only one -- a mixture with no
# rationale, under which a fixture or vendored tree containing
# `.../.claude-plugin/plugin.json` would have been silently excluded from
# formatting while an equivalent `.../.agents/plugins/marketplace.json`
# would not. All fifteen real files (3 root marketplace manifests, 2 per
# plugin x 6 plugins) match; anything else is hand-authored and gets
# formatted.
#
# `.claude/settings.json` is the sixteenth, and it is excluded for a
# different reason: apm OWNS that file (ADR-0018, ADR-0019), and
# `apm audit --ci` replays the install into a scratch tree and diffs
# the result byte-for-byte. `pretty-format-json` sorts object keys
# unless `--no-sort-keys` is passed, while apm's hook integrator emits
# insertion order (`matcher` before `hooks`, `type` before `command`).
# Formatting the file therefore rewrites apm's output into a form apm
# would never produce, and the `apm-audit-ci` pre-push hook reports it
# as permanent drift on a file with no git diff -- exactly what
# happened when the SessionStart hook first landed in 2e395a4.
# Re-running `apm install` fixes the file; leaving it in scope here
# would re-break it on the very commit that carries the fix.
exclude: '^(\.claude-plugin/marketplace\.json|\.agents/plugins/marketplace\.json|\.github/plugin/marketplace\.json|plugins/[^/]+/\.claude-plugin/plugin\.json|plugins/[^/]+/\.github/plugin/plugin\.json|\.claude/settings\.json)$'
- id: check-yaml
stages: ['pre-commit']
- id: trailing-whitespace
stages: ['pre-commit']
- id: check-merge-conflict
stages: ['pre-commit']
- id: detect-private-key
stages: ['pre-commit']
- id: check-toml
stages: ['pre-commit']
- id: check-ast
stages: ['pre-commit']
- repo: local
hooks:
- id: run-tests
name: Run test suite
description: Run all test-*.sh files and bats suite. --strict because a suite that exits 77 (SKIPPED) at pre-push means a documented dependency is missing on this machine, and pre-commit prints nothing for a passing hook -- without it the gate went green having verified 15 of 17 suites on a vale-less PATH, with the skip list swallowed. Ad-hoc `bash tests/run-tests.sh` still skips gracefully.
entry: bash tests/run-tests.sh --strict
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
- id: check-manifests
name: Check plugin manifests
description: Validate marketplace.json and plugin.json paths
entry: bash scripts/check-manifests.sh
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
- id: check-plugin-content-sync
name: Check plugin content sync
description: Verify each plugin's flat skills/agents/commands/hooks/hooks.json mirror is in sync with .apm/ -- Claude Code has no .apm/ awareness so this compiled mirror must stay current (see issue #90)
entry: bash scripts/sync-plugin-content.sh --check --all
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
- id: check-marketplace-mirror-sync
name: Check marketplace mirror sync
description: Verify .github/plugin/marketplace.json (Copilot CLI's legacy manifest path) is byte-identical to .claude-plugin/marketplace.json -- apm has no output profile for this path, so it must be kept in sync explicitly (see issue #90)
entry: bash scripts/sync-marketplace-mirror.sh --check
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
- id: check-executables-allow-sync
name: Check executables allow key sync
description: Verify root apm.yml's executables.allow key names kyberforge's actual version -- apm matches that key by exact "<package>#<version>" lookup, so a version bump on one side alone silently stops deploying kyberforge's hooks/ and bin/ and lets the apm install go stale (see ADR-0019)
entry: bash scripts/check-executables-allow-sync.sh
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
- id: apm-marketplace-check
name: apm marketplace check
description: Validate every marketplace.packages[] entry resolves, including network reachability of remote refs -- catches stale/unreachable remote package references that check-manifests.sh deliberately skips (local-source checks only)
entry: apm marketplace check
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
- id: apm-audit-ci
name: apm audit --ci
description: Run apm's producer-side CI gate over the root manifest AND each of the six plugin packages. Verifies exactly two things per manifest -- apm.yml parses as a valid APM manifest (manifest-parse), and, if it declares dependencies, apm.lock.yaml exists and is consistent (lockfile-exists). It does NOT enforce an org policy and does NOT scan for hidden Unicode; see the comment below for why. Reference:plugins/kyberforge/.apm/skills/apm-workflow/references/audit.md
entry: bash -c 'for d in . plugins/*/; do (cd "$d" && apm audit --ci) || { echo "apm audit --ci failed in $d" >&2; exit 1; }; done'
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
# The description above deliberately claims less than this hook's old one
# did ("lockfile/policy/hidden-content integrity"), because two of those
# three were never happening:
#
# * POLICY. `apm audit --ci` discovers an org policy from the git remote,
# and apm's discovery only understands github.com and Azure DevOps.
# This repo's remote is a self-hosted Gitea, so discovery resolves
# nothing and the run prints `No org policy found at unknown;
# enforcement skipped`. apm's own message suggests
# `policy.fetch_failure_default=block` in apm.yml "to fail closed" --
# that was tried on a scratch copy and REJECTED: it does not make the
# check meaningful, it makes it permanently red. `apm audit --ci` then
# exits 1 with `No org policy found at unknown
# (policy.fetch_failure_default=block)` on every push, because there is
# no org policy to find and no supported way for this remote to serve
# one. A gate that can never go green is not a gate. Revisit if this
# repo ever gains a policy source apm can actually reach.
# * HIDDEN CONTENT. The hidden-Unicode scan is plain `apm audit`, not
# `apm audit --ci` (the two are different modes, and --ci refuses to
# combine with --file/--strip/--dry-run/PACKAGE). Plain `apm audit`
# here reports `No apm.lock.yaml found -- nothing to scan` and exits 0,
# so adding it would buy a second vacuous check, not coverage.
#
# What IS left is worth keeping, and is now run against seven manifests
# instead of one. lockfile-exists is conditional -- it is vacuous while
# every apm.yml declares `dependencies: {apm: [], mcp: []}`, and it arms
# itself the moment one does not (verified: adding a git dependency to
# plugins/lint/apm.yml fails with `apm.yml declares dependencies but
# apm.lock.yaml is absent`). manifest-parse is unconditional and fires on
# any malformed manifest (verified: a dependency entry missing its
# git/path/registry field fails with `Cannot parse apm.yml`). Running the
# six plugin packages is what makes either reachable for them at all --
# the root-only invocation audits the marketplace manifest and nothing
# else. Costs ~0.5s per package, needs no network (checked under
# `unshare -rn`), so this does NOT join apm-marketplace-check and
# apm-pack-check-clean on the offline SKIP= list.
- id: check-apm-agents-valid
name: Validate real APM agent files
description: Run agent-audit's validate.sh over every plugins/*/.apm/agents/*.agent.md file in this repo -- the artifacts it governs, not fixtures
entry: bash scripts/check-apm-agents-valid.sh
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
# validate.sh was previously exercised only by check-scope-walkup-sync,
# and only against synthetic mktemp fixtures -- it had never run against
# the four agent files it governs. That is how ADR-0016 could be amended
# to bless a `disallowedTools` frontmatter field while validate.sh's
# allowlist still rejected it: the spec and its enforcer disagreed and
# every gate stayed green. The expected file set is derived from
# `git ls-files` (the pattern tests/run-bats.sh established) rather than
# a hardcoded count, and discovering zero files is an error, not a pass.
# Needs no network.
- id: apm-pack-check-clean
name: apm pack --check-clean
description: Release gate -- verify .claude-plugin/marketplace.json still matches what apm.yml + .apm/ would currently generate, and that per-package versions agree with the per_package versioning strategy. Closes issue #90's deferred item 3 (a check-clean-equivalent gate) using apm's own flag instead of custom drift logic.
entry: apm pack --check-versions --check-clean --dry-run
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
- id: check-vale-style-sync
name: Check Vale style copies are in sync
description: Diff skill-audit's Vale copy against agent-audit's canonical copy
entry: bash scripts/check-vale-style-sync.sh
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
# verbose so the DOWNGRADED run is audible. This hook can pass while
# having verified strictly less than its name claims:
# CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1 skips all six glob probes
# and says so on a `passed (text-level only, vale unavailable)` line.
# pre-commit prints nothing at all for a passing hook, so without this
# the opt-out reinstated exactly the silent vacuous pass the script was
# written to kill, one level up -- the run showed a bare `Passed` and
# the documented instruction to read that summary line was impossible to
# follow in the one situation the opt-out exists for. The script's clean
# output is a single line, so this costs one line per push.
- id: check-scope-walkup-sync
name: Check scope walk-up implementations agree
description: Behaviorally cross-check validate.sh, validate-provenance.sh, new-agent.sh, and new-skill.sh's independent $HOME/.git/apm.yml walk-up ports against each other
entry: bash scripts/check-scope-walkup-sync.sh
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
- id: check-release-needed
name: Check a release tag covers .pre-commit-hooks.yaml's paths
description: On push to main only, fail if files exposed via .pre-commit-hooks.yaml changed since the last tag
entry: bash scripts/check-release-needed.sh
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
- id: validate-plugins
name: Validate plugins
description: Run claude plugin validate --strict on every plugin directory
entry: bash -c 'for d in plugins/*/; do claude plugin validate --strict "$d" || exit 1; done'
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
- id: validate-marketplace
name: Validate marketplace manifest
description: Run claude plugin validate --strict on the root marketplace manifest
entry: claude plugin validate --strict .claude-plugin/marketplace.json
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
- id: skill-frontmatter
stages: ['pre-commit']
name: SKILL.md frontmatter validation
description: Ensure SKILL.md files have required frontmatter fields
entry: bash
language: system
files: '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$'
# Pinned by tests/test-skill-frontmatter.sh, which drives this exact
# `bash -c <script> <arg0> <files...>` call shape rather than a copy of
# the script -- the bug below was invisible to any test that did not.
args:
- -c
- |
# Every check reads the FRONTMATTER only, never the whole file. A
# `metadata:` / `name:` / `description:` line inside a body code
# fence is documentation (skill-author quotes exactly such a block)
# and used to satisfy these greps.
for f in "$@"; do
[[ -f "$f" ]] || continue
fm="$(awk '
{ sub(/\r$/, "") }
NR == 1 { sub(/^\357\273\277/, "") }
!opened && /^[[:blank:]]*$/ { next }
!opened {
if ($0 ~ /^---[[:blank:]]*$/) { opened = 1; next }
exit
}
/^---[[:blank:]]*$/ { closed = 1; exit }
{ print }
END { if (!opened || !closed) exit 3 }
' "$f")" || {
echo "ERROR: $f has no closing YAML frontmatter block (expected --- ... --- at the top of the file)"
exit 1
}
missing=""
printf '%s\n' "$fm" | grep -q "^name:" || missing="${missing}name: "
printf '%s\n' "$fm" | grep -q "^description:" || missing="${missing}description: "
# Scoped to the `metadata:` block and stopped at the next
# top-level key, so a `version:` under a following `source:` list
# cannot stand in for it; the `^ version:` anchor is exact, so a
# deeper-nested ` version:` cannot either. No line budget, so a
# long `metadata:` block does not hide the key.
ver="$(printf '%s\n' "$fm" | awk '
/^metadata:/ { inm = 1; next }
inm && /^[A-Za-z]/ { exit }
inm && /^ version:/ {
v = $0
sub(/^ version:[[:blank:]]*/, "", v)
sub(/[[:blank:]]+#.*$/, "", v)
sub(/[[:blank:]]+$/, "", v)
print "found:" v
exit
}
')"
[[ -n "$ver" ]] || missing="${missing}metadata.version "
if [[ -n "$missing" ]]; then
echo "ERROR: $f is missing required frontmatter fields (${missing})"
exit 1
fi
raw="${ver#found:}"
v="$raw"
case "$v" in
\"*\") v="${v#\"}"; v="${v%\"}" ;;
\'*\') v="${v#\'}"; v="${v%\'}" ;;
esac
if [[ ! "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "ERROR: $f has a malformed frontmatter metadata.version (${raw:-<empty>}) -- expected a three-part semver, e.g. \"1.0.0\""
exit 1
fi
done
# arg0 for `bash -c`. WITHOUT it pre-commit's first filename lands in
# $0 and is dropped from "$@" -- so a single-file commit, the normal
# case, ran the loop zero times and reported Passed having checked
# nothing. Do not remove; tests/test-skill-frontmatter.sh pins it.
- skill-frontmatter
- id: skill-size-check
stages: ['pre-commit']
name: SKILL.md size and context-budget ceilings
description: Enforce agentskills.io's 500-line/2,770-whole-file-word spec ceilings AND ADR-0020's context budget -- description 250 chars SUGGESTION / 400 FAIL, body-only 600 words SUGGESTION / 900 FAIL, and every boundary-clause routing target resolving to a real skill or agent under plugins/*/.apm/
entry: scripts/skill-size-check.sh
language: script
files: '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$'
pass_filenames: true
verbose: true
# verbose so the SUGGESTION tier is audible. ADR-0020 depends on it:
# "A ceiling does not produce an average ... The halving depends
# entirely on the 250-character SUGGESTION tier being visible and
# respected." pre-commit prints nothing at all for a passing hook, and
# a SUGGESTION deliberately does not fail, so without verbose every
# suggestion would be swallowed -- the exact invisibility ADR-0013
# records for Vale warnings. Costs nothing on a clean file: the script
# prints only findings.
- id: check-rtk-prefix
stages: ['pre-commit']
name: ADR-0023 rtk prefix on executable git commands
description: Enforce ADR-0023 clause 1 -- an executable, instructed git command in a shell code fence or a dispatch-table Run cell is written `rtk git`. Clauses 2 and 3 are not machine-decidable; a deliberately bare command opts out with the literal string ADR-0023 on its own line
entry: scripts/check-rtk-prefix.sh
language: script
files: '^plugins/[^/]+/\.apm/(skills/.*\.md|agents/.*\.agent\.md)$'
# README.md is excluded on purpose, not by oversight. A skill-directory
# README is consumer-facing prose that no agent ever loads, and the
# `git clone` lines in the seven tests/README.md files are setup
# instructions for a third party who has no rtk installed. Prefixing
# those would be actively wrong -- see ADR-0023's consumer section.
exclude: '(^|/)README\.md$'
pass_filenames: true
- id: vale-audit-prefilter-skill
stages: ['pre-commit']
name: Vale audit prefilter (SKILL.md)
description: Run Vale against SKILL.md files as a deterministic prefilter for skill-audit, via skill-audit's own bundled copy
entry: plugins/kyberforge/.apm/skills/skill-audit/scripts/vale-wrap.sh
language: script
files: '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$'
pass_filenames: true
- id: vale-audit-prefilter-agent
stages: ['pre-commit']
name: Vale audit prefilter (agent files)
description: Run Vale against agent markdown files as a deterministic prefilter for agent-audit, via agent-audit's own bundled copy
entry: plugins/kyberforge/.apm/skills/agent-audit/scripts/vale-wrap.sh
language: script
files: '^plugins/[^/]+/\.apm/agents/[^/]+\.agent\.md$'
pass_filenames: true
- repo: meta
hooks:
- id: check-hooks-apply
- id: check-useless-excludes