Skip to content

Commit 76860b1

Browse files
UnbreakableMJclaude
andcommitted
Add folded-scalar description checker as a pre-commit hook
New .githooks/ (tracked, unlike .git/hooks): check-description-length.py reproduces the folded-scalar rendering documented in CONTRIBUTING.md and flags any SKILL.md whose rendered `description` exceeds the 1000-char cap; it handles both the block form (`description: >`, root skills) and the single-line plain/ quoted form (Grok skills). .githooks/pre-commit validates the staged blobs of changed SKILL.md files and aborts the commit on any violation. Activated on this host via core.hooksPath; documented activation (once per clone) and manual invocation in CONTRIBUTING.md (new "Pre-commit hook" section) and the AGENTS.md digest. Covered by REUSE.toml's ** default; reuse lint passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6ba5145 commit 76860b1

4 files changed

Lines changed: 166 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: 2026 Mohamed Hammad <Mohamed.Hammad@SpacecraftSoftware.org>
3+
# SPDX-License-Identifier: GPL-3.0-or-later
4+
"""Enforce Construct's SKILL.md `description` cap (<= 1000 rendered chars).
5+
6+
The skill loader's absolute limit is 1024; this repo caps the *rendered*
7+
description at 1000 (a 24-char margin for encoding/trailing-newline edge
8+
cases). YAML folded scalars (`description: >`) join their wrapped lines with
9+
spaces, so the raw line count is not what the loader sees — this script
10+
reproduces the folding so the count matches, exactly as documented in
11+
CONTRIBUTING.md. Both the block form (`description: >`, used by root skills)
12+
and the single-line plain/quoted form (used by Grok skills) are handled.
13+
14+
Usage:
15+
check-description-length.py FILE [FILE ...]
16+
17+
Prints every offender and exits 1 if any description exceeds the cap;
18+
exits 0 otherwise. Files without frontmatter or a `description` key are
19+
skipped silently.
20+
"""
21+
import sys
22+
23+
CAP = 1000
24+
25+
26+
def rendered_length(path):
27+
"""Return the rendered description length, or None if there is none."""
28+
lines = open(path, encoding="utf-8").read().splitlines()
29+
if not lines or lines[0].strip() != "---":
30+
return None
31+
try:
32+
end = lines.index("---", 1)
33+
except ValueError:
34+
return None
35+
fm = lines[1:end]
36+
37+
di = next((k for k, l in enumerate(fm) if l.startswith("description:")), None)
38+
if di is None:
39+
return None
40+
head = fm[di][len("description:"):].strip()
41+
42+
# Block scalar: `>` (folded) / `|` (literal), optional chomping (`-`/`+`).
43+
if head[:1] in ("|", ">"):
44+
clip = not head.rstrip().endswith("-") # `-` strips the trailing newline
45+
body = []
46+
for k in range(di + 1, len(fm)):
47+
l = fm[k]
48+
if l.strip() == "":
49+
body.append("")
50+
elif l.startswith((" ", "\t")):
51+
body.append(l.strip())
52+
else:
53+
break # dedented line ends the block (next key)
54+
while body and body[-1] == "":
55+
body.pop()
56+
out, buf = [], []
57+
for b in body:
58+
if b == "":
59+
out.append(" ".join(buf))
60+
buf = []
61+
else:
62+
buf.append(b)
63+
if buf:
64+
out.append(" ".join(buf))
65+
return len("\n".join(out)) + (1 if clip else 0)
66+
67+
# Single-line plain or quoted scalar (Grok skills).
68+
if head:
69+
if len(head) >= 2 and head[0] == head[-1] and head[0] in ("'", '"'):
70+
head = head[1:-1]
71+
return len(head)
72+
return None
73+
74+
75+
def main(argv):
76+
offenders = []
77+
for path in argv:
78+
try:
79+
n = rendered_length(path)
80+
except OSError as e:
81+
print(f" ! cannot read {path}: {e}", file=sys.stderr)
82+
offenders.append((path, -1))
83+
continue
84+
if n is not None and n > CAP:
85+
offenders.append((path, n))
86+
if offenders:
87+
print(f"SKILL.md description cap (<= {CAP} rendered chars) exceeded:",
88+
file=sys.stderr)
89+
for path, n in offenders:
90+
where = "unreadable" if n < 0 else f"{n} chars ({n - CAP} over)"
91+
print(f" {path}: {where}", file=sys.stderr)
92+
print("Trim the `description` and re-stage. See CONTRIBUTING.md "
93+
"(Editing rules) for the folded-scalar rationale.", file=sys.stderr)
94+
return 1
95+
return 0
96+
97+
98+
if __name__ == "__main__":
99+
sys.exit(main(sys.argv[1:]))

.githooks/pre-commit

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/bin/sh
2+
# SPDX-FileCopyrightText: 2026 Mohamed Hammad <Mohamed.Hammad@SpacecraftSoftware.org>
3+
# SPDX-License-Identifier: GPL-3.0-or-later
4+
#
5+
# Construct pre-commit hook.
6+
#
7+
# Enforces the SKILL.md `description` cap (<= 1000 rendered chars) on every
8+
# staged skill, validating the *staged blob* (not the working tree) so a
9+
# fixup that is edited-but-not-re-staged cannot slip through. Tracked hooks in
10+
# .git/hooks are not honoured automatically — activate this once per clone:
11+
#
12+
# git config core.hooksPath .githooks
13+
#
14+
# Requires python3 (already assumed by the repo's description checker).
15+
set -eu
16+
17+
root=$(git rev-parse --show-toplevel)
18+
checker="$root/.githooks/check-description-length.py"
19+
20+
# Staged SKILL.md files (added / copied / modified).
21+
staged=$(git diff --cached --name-only --diff-filter=ACM \
22+
| grep -E '(^|/)SKILL\.md$' || true)
23+
[ -z "$staged" ] && exit 0
24+
25+
tmp=$(mktemp -d)
26+
trap 'rm -rf "$tmp"' EXIT INT TERM
27+
28+
set --
29+
for f in $staged; do
30+
dest="$tmp/$f"
31+
mkdir -p "$(dirname "$dest")"
32+
git show ":$f" > "$dest"
33+
set -- "$@" "$dest"
34+
done
35+
36+
python3 "$checker" "$@"

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ rules that bite. (A maintainer-local `CLAUDE.md` overlay adds host-specific note
1414
MUST NOT exceed 1000 chars. Folded `description: >` blocks render by joining
1515
lines with spaces (blank lines → newlines, plus a trailing newline); that
1616
rendered length is what counts, not the raw line count. Re-check after any edit.
17+
The `.githooks/pre-commit` hook enforces this on staged skills — activate once
18+
per clone with `git config core.hooksPath .githooks`.
1719
- **Rebuild BOTH bundles after any skill-dir edit**, in the same commit:
1820
`<name>.zip` (`zip -qr`, keeps dir entries) and `<name>.skill` (`zip -qrD`,
1921
drops them). A bundle that lags its `SKILL.md`/`references/` ships broken

CONTRIBUTING.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ in sync with the subdirectory listing.
154154
print(len('\n'.join(out))+1)
155155
PY
156156
```
157+
The [pre-commit hook](#pre-commit-hook) enforces this automatically on every
158+
staged skill — no need to remember the snippet, but do activate the hook.
157159
- **`microsoft-rust-guidelines` is intentionally `user-invocable: false`.** It is
158160
the mandatory auto-load Rust base — `spacecraft-standard-constitution` mandates loading it
159161
before any Rust and `spacecraft-rust-guidelines` defers to it as "load first," so
@@ -176,6 +178,33 @@ in sync with the subdirectory listing.
176178
not ship with it; never reference it from inside a `SKILL.md`. Session exports
177179
(`Chat*.txt`) are gitignored — never commit them.
178180
181+
## Pre-commit hook
182+
183+
A tracked hook enforces the description cap so a stale or over-long count can
184+
never reach a commit. Git does **not** honour tracked hooks automatically, so
185+
activate it **once per clone**:
186+
187+
```sh
188+
git config core.hooksPath .githooks
189+
```
190+
191+
On every `git commit`, `.githooks/pre-commit` checks the **staged** `SKILL.md`
192+
files (root and Grok) with `.githooks/check-description-length.py`, which
193+
reproduces the folded-scalar rendering above and aborts the commit if any
194+
rendered description exceeds **1000** characters. It validates the *staged blob*,
195+
not the working tree, so a fixup you forgot to re-stage is still caught. The
196+
only dependency is `python3`.
197+
198+
Run the same checker by hand over the whole catalogue any time:
199+
200+
```sh
201+
python3 .githooks/check-description-length.py */SKILL.md grok-skills/*/SKILL.md
202+
```
203+
204+
It exits non-zero and lists each offender (with how many chars over) when any
205+
description is too long, and is silent for files with no frontmatter or no
206+
`description` key.
207+
179208
## Nix flake
180209
181210
The repo is also a Nix flake. Skill auto-detection is by `SKILL.md` presence, so

0 commit comments

Comments
 (0)