Repository navigation
Expand file tree
/
Copy path.pre-commit-config.yaml
More file actions
684 lines (682 loc) · 38.4 KB
/
Copy path.pre-commit-config.yaml
File metadata and controls
684 lines (682 loc) · 38.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
---
default_stages: [commit, push]
default_language_version:
python: python3
# Node 22 is the current active LTS line. markdownlint-cli2 ≥ v0.18
# (and its string-width dependency from v6 onward) uses the regex
# `/v` flag, which requires Node ≥ 20 to parse. The previous Node
# 18.6.0 pin matched the framework's pre-PR-#18 tooling baseline
# but is incompatible with the markdownlint-cli2 v0.22.1 rev
# pinned below.
#
# Bumped 22.11.0 -> 22.13.0: markdownlint-cli2's transitive dep
# `@eslint/js@10.0.1` declares engines `^20.19.0 || ^22.13.0 || >=24`,
# so the old 22.11.0 pin failed `npm install` with EBADENGINE. 22.13.0
# is the lowest 22.x that satisfies it.
#
# Bumped 22.13.0 -> 22.20.0: ava@7.0.0 declares engines
# `^20.19 || ^22.20 || ^24.12 || >=25`; 22.13.0 fails EBADENGINE.
# 22.20.0 is the lowest 22.x that satisfies both constraints.
node: 22.20.0
minimum_prek_version: '0.3.5'
repos:
- repo: meta
hooks:
- id: identity
name: Print input to the static check hooks for troubleshooting
- id: check-hooks-apply
name: Check if all hooks apply to the repository
- repo: https://github.com/thlorenz/doctoc
rev: v2.5.0
hooks:
- id: doctoc
name: Add TOC for Markdown and RST files
files: ^.+\.(md|rst)$
# Skip agent-facing skill definitions across every skill-dir layout
# (.agents/skills canonical home, plus the .claude/skills and
# .github/skills relays, and the plugins/magpie-*/skills/ source
# together with the flat skills/ tree that mirrors it): their YAML
# frontmatter must be the first thing in the file, which is
# incompatible with doctoc inserting a TOC block at the top. Also
# skip skill-evals fixture and
# README files, and the spec-loop specs/prompts (YAML-frontmatter
# specs and short single-purpose prompts, same rationale as skills)
# — short docs that don't warrant a TOC.
# Skip the PR template — GitHub pre-populates a new PR description
# with the template verbatim, so a TOC block becomes per-PR noise the
# contributor has to delete by hand.
# Skip every shared-block source (tools/dev/blocks/*.md plus the
# pre-flight block source tools/dev/preflight-block.md, and the
# rules sections under tools/setup-preflight, which the tool emits
# on its own stdout): their SPDX header must be the first thing
# in the file too — `_strip_licence_header()` only strips it there —
# and a TOC block ahead of it would leak the SPDX comment (and an
# empty TOC wrapper) into every propagated copy, same incompatibility
# as the skill definitions above. The block source was not excluded
# before and did carry a TOC, which rode into all 65 propagated
# copies as a table of contents for a file none of them are.
# Skip harness command files (plugins/magpie-*/commands/ and the
# generated source in tools/adversarial-review/commands/): frontmatter
# first, like skills, and the source is pinned byte-for-byte to its
# generator by a test, which a TOC wrapper would break.
exclude: ^(plugins/magpie-[^/]+/commands/.*|tools/adversarial-review/commands/.*|\.claude/skills/.*|\.agents/skills/.*|\.github/skills/.*|skills/.*|plugins/magpie-[^/]+/skills/.*|tools/cve-tool-vulnogram/generate-cve-json/SKILL\.md|tools/skill-evals/.*|tools/spec-loop/.*|\.github/PULL_REQUEST_TEMPLATE\.md|tools/dev/blocks/.*|tools/dev/preflight-block\.md|tools/setup-preflight/src/setup_preflight/sections/.*)$
args:
- "--maxlevel"
- "3"
# Stamp an SPDX Apache-2.0 licence identifier into Markdown files so the
# Apache RAT licence check (.github/workflows/rat.yml) sees an approved
# header on every scanned file. Markdown carries no comment header by
# convention, so RAT would otherwise flag it as unapproved. Plain
# Markdown gets an HTML-comment header on line 1; files with YAML front
# matter get a `# SPDX-License-Identifier` comment as the first line
# *inside* the front matter, so line 1 stays `---` and no new key is
# introduced for a front-matter validator to reject. The hook fixes files
# in place and fails the commit when it changes one (the end-of-file-fixer
# convention), so the contributor re-stages the now-stamped file. The
# `exclude` mirrors `.rat-excludes`: the third-party-licence detection
# fixtures deliberately contain non-Apache licence text and must not be
# stamped Apache-2.0.
- repo: local
hooks:
- id: add-license-headers
name: Stamp SPDX licence header into Markdown
language: system
entry: tools/dev/add-license-headers.py
files: \.md$
exclude: ^tools/skill-evals/evals/pr-management-code-review/step-4-third-party-license/fixtures/.*$
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-merge-conflict
name: Check that merge conflicts are not being committed
- id: detect-private-key
name: Detect if private key is added to the repository
# The next three hooks are *fixers*: they open every matched file
# in read-write mode (to normalise it in place) before checking
# whether a fix is even needed. The secure-agent sandbox
# write-denies three tracked path sets — the agent's own config
# (`.claude/settings.json`), the self-adoption skill relays
# (`.claude/skills/*` symlinks), and the plugin hook scripts
# (`plugins/magpie-setup/hooks/`) — because agents must never
# modify them. A hook script
# runs automatically on a harness event, so an agent that could
# rewrite one could arrange to run arbitrary code on the next
# session start; the deny is the point, not an oversight. So on
# `prek run --all-files` (which `agent-pre-commit.sh` and the CI
# mirror both run) the read-write `open()` on those paths is denied
# and the whole run aborts, even though the files need no fix.
# Exclude them: `.claude/settings.json` is gated by the
# sandbox-lint baseline, the skill symlinks are validated by
# `symlink-lint`, the upgrade hook script is checked for
# existence and correct wiring by `check-family-plugins.py`, and
# the vetted-ops policy is parsed by `vetted_ops.config` on every
# dispatch (a malformed file fails the call), so none of them
# loses coverage by skipping EOF / whitespace / line-ending
# normalisation here. This keeps prek a single invocation that
# runs cleanly in-sandbox with no bypass.
#
# Adding a `permissions.deny` rule on an in-repo path means adding
# that path here too — the deny becomes a sandbox write-deny, and
# every one of these three hooks aborts the run without it.
- id: end-of-file-fixer
name: Make sure that there is an empty line at the end
exclude: &sandbox_write_denied ^(\.claude/(settings\.json|skills/)|plugins/magpie-setup/hooks/|\.apache-magpie-overrides/tools/vetted-ops/)
# `--fix=lf`, not the default `auto`: `auto` only fails a file
# that mixes the two styles, so a file converted wholesale to
# CRLF (an editor on Windows, `core.autocrlf=true`) passes and
# lands as a whole-file rewrite. `lf` converts any CRLF to LF.
- id: mixed-line-ending
name: Enforce LF line endings (convert \r\n to \n)
args: [--fix=lf]
exclude: *sandbox_write_denied
- id: trailing-whitespace
name: Remove trailing whitespace at end of line
exclude: *sandbox_write_denied
# markdownlint — catches structurally-bad markdown. Config in
# `.markdownlint.json` enables only the rules that catch real bugs
# (broken anchors via MD051, dangling link references via MD053);
# style choices the existing docs already settled stay off.
- repo: https://github.com/DavidAnson/markdownlint-cli2
rev: v0.23.3
hooks:
- id: markdownlint-cli2
name: markdownlint
files: \.md$
# Skip skill-evals fixture markdown: report.md / system-prompt.md /
# user-prompt-template.md are test data containing raw code diffs and
# CLI snippets (leading-underscore identifiers, `*args`, `--- /dev/null`,
# etc.). The eval runner reads them as plain text, never rendered, so
# prose rules like MD037 only ever false-positive on them. Suite
# README.md files are NOT excluded — they are real docs.
exclude: ^tools/skill-evals/evals/.+/fixtures/.+\.md$
# typos — fast spell-checker. Allowlist is `.typos.toml`.
#
# Override the default args (which include `--write-changes`) so
# the hook never silently rewrites files; CI surfaces typos as
# errors instead.
#
# `pass_filenames: false` + the trailing `.` arg → typos walks the
# repo itself instead of being handed the file list, the same shape
# as the `lychee` hook below. That is a memory requirement, not a
# preference: typos' peak RSS scales with the number of paths passed
# to one invocation (~1.2 MB per path), and prek splits the file list
# across roughly `cpu_count` invocations. On a 4-vCPU runner (which is
# what the prek workflow's `main` job gets) the ~4.9k tracked files
# arrive as ~1.2k-path chunks (~925 MB each); on the 1-vCPU
# `ubuntu-slim` its pull-request job uses, a whole-tree list would
# arrive as a single invocation that peaks at ~5.7 GB and is
# OOM-killed. Walking makes the core count irrelevant, which is what
# keeps this hook safe on slim. prek reports
# that as a bare `exit code: 1` with no output — a signal-killed
# process has no exit code of its own — which reads like a hook
# failure with a missing message rather than the kill it is. Walking
# costs ~21 MB flat, whatever the core count.
#
# typos honours .gitignore while walking, and uv writes a `*`
# .gitignore inside `.venv`, so a synced workspace stays out of the
# walk: it covers the same files the list did.
- repo: https://github.com/crate-ci/typos
rev: v1.50.3
hooks:
- id: typos
name: typos
args: [--force-exclude, .]
pass_filenames: false
# lychee — link checker (was the standalone `link-check.yml`
# workflow; converted to this prek hook). Validates cross-file links,
# `#anchor` fragments, and external URLs across markdown / rst /
# `.md.j2`. Config in `.lychee.toml`.
#
# `language: rust` with `additional_dependencies: [cli:lychee]`
# means prek installs lychee itself (cargo install from crates.io)
# into an isolated, cached hook env — the hook does NOT depend on a
# locally-installed lychee, and the prek CI workflow needs no extra
# install step. Unpinned, so it tracks the **latest** lychee; the
# exact version is resolved when the hook env is first built and then
# held stable by the prek env cache until the cache busts (keyed on
# this file). lychee must be >= 0.24 for `.lychee.toml`'s
# `include_fragments = "anchor-only"` enum-string schema.
#
# `pass_filenames: false` + the trailing `.` arg → lychee scans the
# whole repo (so a renamed link target is caught no matter which
# file references it); `files:` only gates *whether* the whole-repo
# scan fires, i.e. it runs when any doc file changes and always on
# `prek run --all-files`. That gate is why CI gives this hook a step
# of its own (`prek run lychee --all-files`, both events) instead of
# letting it ride along with the pull-request run's `--from-ref`
# scope: a PR that breaks a link by renaming a non-doc file matches
# no `.md`, so the hook would never fire. `.lychee.toml` sets
# `offline = true`, so no external URL is fetched and the
# `.lycheecache` result cache stays empty — CI carries no cache step
# for it.
- repo: local
hooks:
- id: lychee
name: lychee (link check)
language: rust
entry: lychee
args: ["--config", ".lychee.toml", "--no-progress", "."]
additional_dependencies: ["cli:lychee"]
files: \.(md|rst|j2)$
pass_filenames: false
# Local placeholder linter — catches hardcoded references like
# `apache/airflow` or `Apache Airflow` that should be the
# placeholder tokens `<upstream>` / `<PROJECT>` per
# `AGENTS.md#placeholder-convention-used-in-skill-files`.
- repo: local
hooks:
- id: check-placeholders
name: check-placeholders
language: system
entry: tools/dev/check-placeholders.sh
files: ^(skills/.*|plugins/magpie-[^/]+/skills/.*|tools/.*)\.(md|sh|py|yaml|yml|toml)$
pass_filenames: false
# Vendor-neutrality score — regenerate the block embedded in
# docs/vendor-neutrality.md from repository metadata (tool README
# `**Vendor:**` / `**Harness:**` fields, skill frontmatter, the
# privacy-llm registry) so the published number never drifts from the
# tree. `--in-place` rewrites the block and exits non-zero when it
# changed, so a stale doc fails the commit and the contributor re-stages
# the regenerated file — the doctoc pattern, but for a computed score.
# `pass_filenames: false`: the scorer always reads the whole tree; the
# `files:` filter only gates *whether* the regen fires (and it always
# runs on `prek run --all-files` in CI). Deterministic and offline, so
# the same tree always yields the same block. Tool + tests + scoring
# rule live in tools/vendor-neutrality-score (README + tests).
- repo: local
hooks:
- id: vendor-neutrality-score
name: Regenerate the vendor-neutrality score in docs/vendor-neutrality.md
language: system
entry: uv run --project tools/vendor-neutrality-score vendor-neutrality-score --in-place
files: ^(tools/[^/]+/README\.md|tools/privacy-llm/models\.md|tools/vendor-neutrality-score/.*|skills/[^/]+/SKILL\.md|plugins/magpie-[^/]+/skills/[^/]+/SKILL\.md|docs/vendor-neutrality\.md)$
pass_filenames: false
# AI-tutor knowledge-base sync — fail when a `docs/education/` source
# page (or a training lesson) changes without regenerating the tutor
# prompts in `ai-tutors/`. `--check` prints the stale prompts and exits
# non-zero; the contributor reruns `python3
# ai-tutors/inject-knowledge-base.py` (no flag) to regenerate and
# re-stages. `pass_filenames: false`: the script always scans every
# lesson; the `files:` filter only gates *whether* the check fires (and
# it always runs on `prek run --all-files` in CI). Deterministic and
# offline. Script + pytest suite live in ai-tutors/.
- repo: local
hooks:
- id: ai-tutors-kb-check
name: Check ai-tutors prompts are in sync with docs/education
language: system
entry: python3 ai-tutors/inject-knowledge-base.py --check
files: ^(ai-tutors/|docs/education/)
pass_filenames: false
# Self-adoption symlink hygiene: reject cyclic symlinks, misdirected
# skill relays, and incomplete self-adoption symlink sets. Fires on
# changes under `skills/` or any agent-skills tree (always on
# `prek run --all-files`). Rules, rationale, and the pytest suite live
# in tools/symlink-lint (README + tests).
- repo: local
hooks:
- id: symlink-lint
name: symlink-lint
language: system
entry: python3 tools/symlink-lint/src/symlink_lint/__init__.py
files: ^(\.(agents|claude|github|kiro|grok)/skills/.*|skills/.*|plugins/magpie-[^/]+/skills/.*)$
types_or: [symlink, file]
pass_filenames: false
# Release-archive guard: build the source archive exactly as the release
# does (git archive of the export tree, honouring .gitattributes
# export-ignore) and reject any symlink a safe extractor refuses — a
# dangling link (its target got export-ignored) or a chain (target is
# itself a symlink, which ATR's upload validator rejects as "outside the
# extraction directory"). This is what -1'd an RC upload; the hook fails
# the commit before an RC is cut. Re-fires whenever .gitattributes, any
# symlink, or the skills/agent-view tree that the archive ships changes.
- repo: local
hooks:
- id: symlink-lint-archive
name: symlink-lint (release archive)
language: system
entry: python3 tools/symlink-lint/src/symlink_lint/__init__.py --archive
files: ^(\.gitattributes|\.(agents|claude|github|kiro|grok)/skills/.*|skills/.*|plugins/magpie-[^/]+/skills/.*|plugins/magpie-setup/templates/.*)$
types_or: [symlink, file]
pass_filenames: false
# Marketplace-plugin consistency guard. Validates that every plugin is
# properly defined and in sync with the skills' `family:` frontmatter (the
# source of truth): magpie-setup's upgrade-hook wiring + script, each
# per-family manifest (name / skills / description), every
# `.claude-plugin/marketplace.json` entry resolving to a matching, uniquely
# named plugin.json, and each family's skills present as real directories
# that the flat skills/ tree mirrors back with single-hop symlinks. Catches a
# new skill, a changed family, a stale symlink, or a malformed manifest.
#
# Runs as `--fix`, so the hook *regenerates* rather than merely reporting: the
# manifests and catalogue entries are a pure function of the frontmatter and
# `pyproject.toml`'s version, so there is nothing for a contributor to decide
# when they drift, and a check-only hook just made them run the fixer by hand.
# Skill directories are source and are never generated — moving one is a
# decision `--fix` refuses to make. prek fails the run
# when a hook modifies files, so drift still stops the commit — it now arrives
# already corrected and staged-ready instead of as an instruction.
- repo: local
hooks:
- id: check-family-plugins
name: check-family-plugins (marketplace plugins vs frontmatter)
language: system
entry: python3 tools/dev/check-family-plugins.py --fix
files: ^(skills/.*/SKILL\.md|plugins/.*|\.claude-plugin/(marketplace|plugin)\.json|\.codex-plugin/plugin\.json|\.agents/plugins/marketplace\.json|(plugin|marketplace)\.json|gemini-extension\.json|apm\.yml|pyproject\.toml)$
pass_filenames: false
# Claude Code mods — `claude plugin validate --strict` and `claude plugin
# test` on every plugin whose `hooks/hooks.json` declares `modules`
# (docs/when-to-use-mods.md). Claude Code loads a hooks module only after
# static analysis passes; a module that fails it never runs, and nothing
# else in the tree notices. A no-op until the tree has a mod.
#
# `language: node` with the CLI pinned in `additional_dependencies`, so
# prek installs it into a cached hook env, the way `lychee` gets its
# toolchain: no local Claude Code needed, and CI needs no install step.
#
# `stages: [manual]`: the hook env is a ~200 MB native binary, which is
# not worth fetching on every contributor's first commit for a check that
# concerns only the few who write mods. CI runs it on every event (its own
# step in `.github/workflows/pre-commit.yml`); locally it is
# `prek run check-claude-mods --hook-stage manual --all-files`.
#
# Claude Code is a dev-only tool here: CI runs it to check the mods, it is
# not a dependency of anything Magpie ships, and nothing from it goes into a
# release or a plugin.
#
# The pin is moved by `.github/workflows/bump-hook-npm-pins.yml` (Dependabot's
# `pre-commit` ecosystem never touches `additional_dependencies`), with a
# 12-hour cooldown rather than the 7 days the rest of the tree uses. A
# cooldown buys time for a bad or compromised release to be withdrawn
# before CI installs it. Claude Code ships several times a day and replaces
# a bad release within hours, so 12 hours already covers that window; and
# a security problem in it is fixed by the next release, so a week-long wait
# would keep CI on the known-bad version instead of protecting it. The mods
# API is also still moving, and adopters auto-update, so the check has to
# track what they actually run. Being dev-only (above), the worst a bad
# version can do is fail or wrongly pass this check. See
# `tools/dev/bump-hook-npm-pins.py`.
- repo: local
hooks:
- id: check-claude-mods
name: check-claude-mods (claude plugin validate --strict / test)
language: node
entry: tools/dev/check-claude-mods.sh
additional_dependencies: ["@anthropic-ai/claude-code@2.1.295"]
files: ^(.*/hooks/.*|.*\.claude-plugin/plugin\.json|tools/dev/check-claude-mods\.sh)$
pass_filenames: false
stages: [manual]
# The same check on ordinary commits, with the contributor's own Claude
# Code: `language: system`, so nothing is downloaded. It skips itself when
# `claude` is not installed, and when the installed one is older than the
# pin above (an older CLI may not know events a mod uses). A newer one
# runs. CI's pinned run above stays the authoritative result.
- repo: local
hooks:
- id: check-claude-mods-local
name: check-claude-mods-local (local Claude Code, skipped if absent)
language: system
entry: tools/dev/check-claude-mods.sh --local
files: ^(.*/hooks/.*|.*\.claude-plugin/plugin\.json|tools/dev/check-claude-mods\.sh)$
pass_filenames: false
# Every shared prose block a skill carries — the auto-inserted setup
# pre-flight, plus any number of declared blocks. It cannot be a hook: on
# most harnesses *no code runs at all* when a plugin is installed or
# upgraded (AP1 defines no hook component; Claude Code's SessionStart hook
# is wired only into magpie-setup), so the check has to be instructions the
# agent reads on invocation. It cannot be an include either: a family plugin
# must be self-contained — the packagers drop symlinks rather than follow
# them — so a shared file outside the plugin root would be unreachable from
# the install shape most adopters use. That leaves one source and many
# generated copies per block, which is exactly the drift this hook exists
# to prevent — edit `tools/dev/preflight-block.md` (the pre-flight block)
# or `tools/dev/blocks/<name>.md` (a declared block) and let `--fix`
# propagate it. The `setup` family is exempt from the pre-flight block:
# those skills perform the setup the block asks for.
- repo: local
hooks:
- id: check-shared-blocks
name: check-shared-blocks (shared prose blocks in every SKILL.md and sibling detail file)
language: system
entry: python3 tools/dev/check-shared-blocks.py --fix
files: ^(skills/[^/]+/[^/]+\.md|plugins/magpie-[^/]+/skills/[^/]+/[^/]+\.md|tools/dev/preflight-block\.md|tools/dev/preflight-detail\.md|tools/dev/blocks/.*\.md)$
pass_filenames: false
# The reconciliation fingerprint. Runs after check-shared-blocks so it
# hashes the file an adopter actually installs, and before the token
# count so that measurement sees the final bytes. The hash covers only
# `requires_config` and the skill's structural anchors: a reworded
# paragraph must not tell every adopter their configuration went stale,
# and a renamed step must. `files:` matches every `*.md` directly inside a
# skill directory, not just `SKILL.md`, because the fingerprint spans the
# sibling detail files too — a heading renamed in `locks.md` moves the
# `setup` digest, and a `SKILL.md`-only pattern would let that commit pass
# green here and fail on `main`.
- repo: local
hooks:
- id: skill-surface-hash
name: skill-surface-hash (reconciliation fingerprint in every SKILL.md)
language: system
entry: python3 tools/dev/skill-surface-hash.py --fix
files: ^(skills/[^/]+/[^/]+\.md|plugins/magpie-[^/]+/skills/[^/]+/[^/]+\.md)$
pass_filenames: false
# The isolated-setup fingerprint shipped inside the pre-flight checker.
# A marketplace install has no framework source to hash, so the checker
# compares against this constant to notice that an upgrade changed the
# secure-setup files and propose `setup-isolated-setup-update`. `files:`
# mirrors `SOURCES` in `setup_preflight/isolated.py`.
- repo: local
hooks:
- id: isolated-setup-fingerprint
name: isolated-setup-fingerprint (secure-setup fingerprint in setup-preflight)
language: system
entry: env PYTHONPATH=tools/setup-preflight/src python3 -m setup_preflight.isolated write-constant
files: ^(\.claude/settings\.json|tools/agent-guard/src/.*|tools/agent-isolation/.*|tools/container-gateway/src/.*|plugins/magpie-setup/skills/setup/setup_preflight/isolated(_fingerprint)?\.py)$
pass_filenames: false
# Cross-file near-duplicate prose gate: fails on a paragraph pasted from
# one scoped file into another instead of being pulled into a shared
# block. Wired only over the setup-family surface the shared-block
# extraction actually touched (`WIRED_SKILLS_ROOT` in the script) — the
# design covers the whole `skills/` tree, but that tree carries ~3800
# pre-existing near-duplicate pairs this effort never touched (see the
# script's module docstring, "Landing scope vs. the whole duplication
# problem"), so wiring it there today would fail on day one. `files:`
# only gates *whether* the run fires; the script always scans its whole
# wired scope itself (`pass_filenames: false`), since a diff-scoped run
# cannot see that a paragraph added here duplicates one that already
# exists somewhere the diff never touched.
- repo: local
hooks:
- id: check-duplication
name: check-duplication (cross-file near-duplicate prose, setup-family scope)
language: system
entry: python3 tools/dev/check-duplication.py
files: ^(plugins/magpie-setup/skills/setup/.*\.md|tools/dev/blocks/.*\.md|tools/dev/preflight-block\.md)$
pass_filenames: false
# Deterministic full-file token measurements. Run after skill fixers.
# The dedicated path-filtered CI workflow also catches deleted skills.
- repo: local
hooks:
- id: skill-token-count
name: Check measured_tokens stamps in SKILL.md frontmatter
language: system
entry: uv run --project tools/skill-token-count skill-token-count --check
files: ^(skills/.*/SKILL\.md|plugins/magpie-[^/]+/skills/.*/SKILL\.md|tools/dev/preflight-block\.md|tools/skill-token-count/.*|uv\.lock|\.pre-commit-config\.yaml)$
pass_filenames: false
# Quick-start screenshot set. Three lists have to agree — the harnesses
# `capture-screenshot.sh` offers, the ecosystem manifests that make a harness
# real, and the PNGs on disk — and none of the existing gates read images, so
# every way this drifts is silent: the page renders and the offline link check
# passes whether a file is a real capture, an untouched placeholder, or a shot
# taken at the wrong window size. Catches a family added to the frontmatter
# with no screenshot, a harness whose manifest was dropped, an orphan file, and
# a family set captured at two different window sizes. Placeholders are the
# documented interim state, so they are reported, not failed.
- repo: local
hooks:
- id: check-quickstart-recording
name: check-quickstart-recording (setup, first-run and example SVGs)
language: system
entry: python3 tools/dev/check-quickstart-recording.py
files: ^(assets/(quickstart|examples)/.*|docs/.*\.md|README\.md|plugins/.*|skills/[^/]+/SKILL\.md|tools/dev/render-wizard\.py)$
pass_filenames: false
# Documentation claims that must track the tree. Four mechanical checks,
# each added after the drift it catches was found by hand:
# - every tools/spec-loop/specs/*.md is listed in BOTH index files
# (overview.md and README.md) — ten specs were absent from both,
# including per-family specs and one added four PRs earlier;
# - README.md's family table "N skills" cells match live `family:`
# frontmatter;
# - docs/modes.md's *Modes at a glance* Skill-count column matches live
# `mode:` frontmatter (the validator's modes-doc rule checks section
# membership but reads only the mode and status columns, so the counts
# were unguarded);
# - bare "N skills" totals in docs/setup/marketplace.md match the
# catalogue;
# - every script in tools/dev/ is named in tools/dev/README.md — these are
# the framework's own gates, and an undocumented one is invisible to the
# next contributor deciding whether it applies to their change.
# None of these break anything when wrong, which is exactly why they rot:
# they are numbers a human must remember to update while thinking about
# Per-family configuration table. Which `<project-config>/` files a family's
# skills read -- and which of them an adopter must actually create -- was
# carried by a hand-maintained table in nine of the ten family READMEs, and
# had drifted exactly as far as an unguarded table does: `security` listed
# none of the thirteen files its skills read. The required set is now
# declared per skill in `requires_config:` frontmatter, the optional set is
# derived from what each skill references, and the table is generated from
# both. Runs as `--fix`, like check-family-plugins: the block is a pure
# function of the frontmatter, so there is nothing for a contributor to
# decide when it drifts.
# Companion skill packages. Third-party, per family AND per harness, and never
# a dependency -- the generated block is what keeps all three of those true in
# ten READMEs at once. Runs as `--fix` for the same reason its neighbours do.
- repo: local
hooks:
- id: check-companion-skills
name: check-companion-skills (companion registry vs family READMEs)
language: system
entry: python3 tools/dev/check-companion-skills.py --fix
files: ^(tools/dev/(companion-skills\.json|check-companion-skills\.py)|docs/[^/]+/README\.md|docs/setup/companion-skills\.md|plugins/magpie-[^/]+/skills/.*)$
pass_filenames: false
- repo: local
hooks:
- id: check-skill-config
name: check-skill-config (family config tables vs requires_config)
language: system
entry: python3 tools/dev/check-skill-config.py --fix
files: ^(skills/[^/]+/SKILL\.md|plugins/magpie-[^/]+/skills/.*|plugins/magpie-setup/templates/.*|docs/[^/]+/README\.md|tools/dev/check-skill-config\.py)$
pass_filenames: false
# The animated wizard runs derive from the same frontmatter, so they move
# when it moves. Regenerated here rather than only checked, for the same
# reason as its neighbours: the output is a pure function of the source.
- id: render-wizard
name: render-wizard (animated setup + per-family config runs)
language: system
entry: python3 tools/dev/render-wizard.py
files: ^(skills/[^/]+/SKILL\.md|plugins/magpie-[^/]+/skills/.*/SKILL\.md|plugins/magpie-setup/templates/.*|tools/dev/(render-wizard|check-skill-config)\.py)$
pass_filenames: false
# something else.
- repo: local
hooks:
- id: check-doc-sync
name: check-doc-sync (spec indexes + declared skill counts)
language: system
entry: python3 tools/dev/check-doc-sync.py
files: ^(skills/[^/]+/SKILL\.md|plugins/magpie-[^/]+/skills/[^/]+/SKILL\.md|tools/spec-loop/specs/.*\.md|tools/skill-evals/.*|README\.md|docs/modes\.md|docs/setup/marketplace\.md|tools/dev/.*)$
pass_filenames: false
# Workspace-membership guard. The single source of truth for
# which Python projects get pre-commit hooks + the CI pytest
# matrix is the `[tool.uv.workspace] members` array in the root
# `pyproject.toml`. If a new `tools/<name>/pyproject.toml`
# appears without being added to that array, both surfaces
# silently skip it — the very drift bug the workspace refactor
# was meant to prevent.
#
# It also verifies every declared member's tests actually run. Both
# the CI matrix and the workspace sweep key off the presence of
# `[tool.pytest.ini_options]`, so a project can carry a full tests/
# directory and never be executed by anything — no red tick, because
# no job. Reported: tests with no config (never run), config with no
# tests (a green job that collected nothing), and neither. A project
# that should not be tested declares `[tool.magpie.checks] skip =
# ["pytest"]` rather than being quietly absent.
#
# Re-fires on any member pyproject, the root pyproject, or any test
# file — adding or deleting tests changes the answer.
- repo: local
hooks:
- id: check-workspace-members
name: check-workspace-members (membership + tests actually run)
language: system
entry: tools/dev/check-workspace-members.py
files: ^((tools/[^/]+(/[^/]+)?|ai-tutors|skills)/pyproject\.toml|pyproject\.toml|.*/tests?/.*\.py|.*/test_[^/]+\.py|.*_test\.py)$
pass_filenames: false
# Tool-capability PR labels. `.github/labeler.yml` is generated from every
# tool README's `**Capability:**` line; regenerate it whenever a README or
# the generator changes, so a new tool is labelled with no hand-edit.
- repo: local
hooks:
- id: generate-labeler-config
name: generate-labeler-config (tool READMEs + skill frontmatter -> .github/labeler.yml)
language: system
entry: tools/dev/generate-labeler-config.py
files: ^(tools/[^/]+/README\.md|\.github/labeler\.yml|tools/dev/generate-labeler-config\.py|plugins/magpie-[^/]+/skills/[^/]+/SKILL\.md|skills/[^/]+|docs/labels-and-capabilities\.md)$
pass_filenames: false
# Every label docs/labels-and-capabilities.md defines must have a rule in
# .github/labeler.yml (or an UNMAPPED entry with a reason), and no rule
# may name an undefined label: a label nobody can apply automatically is
# how pull requests ended up unlabelled.
- id: check-labeler-coverage
name: check-labeler-coverage (every taxonomy label has a labeler rule)
language: system
entry: tools/dev/generate-labeler-config.py --check-coverage
files: ^(tools/[^/]+/README\.md|\.github/labeler\.yml|tools/dev/generate-labeler-config\.py|plugins/magpie-[^/]+/skills/[^/]+/SKILL\.md|skills/[^/]+|docs/labels-and-capabilities\.md)$
pass_filenames: false
# Workspace-level static checks. Iterate over every uv-workspace
# member declared in the root `pyproject.toml`'s
# `[tool.uv.workspace] members` list via
# `tools/dev/run-workspace-check.sh`. The driver auto-discovers
# which checks apply per member (e.g. only runs `mypy` on members
# with a `[tool.mypy]` section in their pyproject) and honours an
# opt-out `[tool.magpie.checks] skip = [...]` block per member.
#
# Adding a new uv-managed tool requires NO edit here — append its
# path to `[tool.uv.workspace] members` in the root pyproject and
# all four hooks pick it up automatically.
- repo: local
hooks:
- id: workspace-ruff-check
name: ruff check (workspace)
language: system
entry: tools/dev/run-workspace-check.sh ruff "ruff check"
files: ^(tools/[^/]+(/[^/]+)?/(src|tests|pyproject\.toml)|pyproject\.toml|tools/dev/run-workspace-check\.sh)
pass_filenames: false
- id: workspace-ruff-format
name: ruff format (workspace)
language: system
entry: tools/dev/run-workspace-check.sh ruff-format "ruff format --check"
files: ^(tools/[^/]+(/[^/]+)?/(src|tests|pyproject\.toml)|pyproject\.toml|tools/dev/run-workspace-check\.sh)
pass_filenames: false
- id: workspace-mypy
name: mypy (workspace)
language: system
entry: tools/dev/run-workspace-check.sh mypy mypy
files: ^(tools/[^/]+(/[^/]+)?/(src|tests|pyproject\.toml)|pyproject\.toml|tools/dev/run-workspace-check\.sh)
pass_filenames: false
- id: workspace-pytest
name: pytest (workspace)
language: system
entry: tools/dev/run-workspace-check.sh pytest "pytest --color=yes"
files: ^(tools/[^/]+(/[^/]+)?/(src|tests|pyproject\.toml)|pyproject\.toml|tools/dev/run-workspace-check\.sh)
pass_filenames: false
# Skills' sibling scripts (plugins/*/skills/*/scripts) are not
# workspace members; their stdlib unittest suites run here.
- id: skill-script-tests
name: unittest (skill sibling scripts)
language: system
entry: tools/dev/run-skill-script-tests.sh
files: ^(plugins/magpie-[^/]+/skills/[^/]+/(scripts|tests)/.*\.py|tools/dev/run-skill-script-tests\.sh)$
pass_filenames: false
# Validate `skills/**`, every `tools/<name>/README.md`, and the
# `docs/labels-and-capabilities.md` taxonomy via the
# `skill-and-tool-validate` CLI. Re-fires on validator-source changes so
# rule updates get re-applied. The doc and tool-README triggers exist so
# the capability-sync check catches drift between docs and live source.
# `--project` (not `--directory`) so CWD stays at repo root.
- repo: local
hooks:
- id: skill-and-tool-validate
name: skill-and-tool-validate (skills, tools, capability sync)
language: system
entry: uv run --project tools/skill-and-tool-validator skill-and-tool-validate
files: ^(skills/.*\.md|plugins/magpie-[^/]+/skills/.*\.md|tools/[^/]+/README\.md|docs/labels-and-capabilities\.md|tools/skill-and-tool-validator/(src|pyproject\.toml))
pass_filenames: false
# Validate `tools/spec-loop/specs/` frontmatter and required body sections
# via the `spec-validate` CLI. Re-fires on spec-source changes so rule
# updates get re-applied. `--project` (not `--directory`) so CWD stays at
# repo root; the directory argument is passed directly to the CLI so it
# scans the full specs tree even when only one file changed.
- repo: local
hooks:
- id: spec-validate
name: spec-validate (spec-loop specs frontmatter + sections)
language: system
entry: uv run --project tools/spec-validator spec-validate tools/spec-loop/specs/
files: ^(tools/spec-loop/specs/.*\.md|tools/spec-validator/(src|pyproject\.toml))
pass_filenames: false