@spardutti/claude-skills 2.51.3 → 2.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,7 +36,7 @@ npx @spardutti/claude-skills
36
36
  ```
37
37
 
38
38
  ```text
39
- Claude Skills Installer v2.51.2
39
+ Claude Skills Installer v2.52.0
40
40
 
41
41
  ── Frontend ──────────────────────────────
42
42
  ◉ react ◯ tanstack-query
@@ -138,6 +138,7 @@ Portable slash commands installed to `.claude/commands/`. Some orchestrate paral
138
138
  | `/optimize` | Make one measured job faster without changing what it proves — fixes a benchmark and a result that must not move, sends 4 read-only subagents to find suspects, then changes one thing per round with a prediction written first, keeping only what beats the noise. Profiles after every wrong prediction, reruns before judging a result that flipped, and ends by writing the cause as a skill rule for you |
139
139
  | `/handoff` | Write a handoff doc so a fresh session picks the work up — captures what auto-compaction loses: decisions made and rejected, dead ends already ruled out, why the tree is dirty, and the one next step |
140
140
  | `/lockdown` | Per-repo supply-chain hardening — detects the package managers, Dockerfiles, and CI in use, then guides you through and applies install-time, deploy, and pipeline hardening for npm/pnpm, pip/uv, Docker, and GitHub Actions |
141
+ | `/add-rule` | Turn a mistake Claude keeps making into a check on every edit — a text rule with a bad and a good example from real code when a pattern can catch it, or a lesson shown only on the files it names when it needs judgment. Old hits go into a baseline and come back as "fix it if you can" notes; nothing is added to CLAUDE.md |
141
142
  | `/skill-report` | Which installed skills earn their place in this repo — every `/ship` skills audit is logged by a hook, and this tallies it: how often each skill applied, how often it caught a violation, and the rule it catches most. Names rules that break often enough to become a script check, and skills that never flag and may be dead weight |
142
143
 
143
144
  ## How It Works
@@ -195,7 +196,9 @@ scripts/ validate-skills.mjs — checks skill length caps and reference in
195
196
  ship-gate.sh — /ship's file-length, folder-structure and mutation checks, behind an exit code
196
197
  ship-gate-hook.sh — refuses gh pr create/merge without a ship-gate receipt
197
198
  version-check.sh — SessionStart nudge when a newer catalog is published
198
- skill-flags-record.sh — logs each /ship skills-audit verdict; skill-report.mjs tallies it
199
+ skill-flags-record.sh — logs each /ship skills audit and /code-review; skill-report.mjs tallies them
200
+ edit-check.sh — PostToolUse check on each edited file: line-limit.sh, lint-ratchet.mjs, .edit-rules.json
201
+ line-limit.sh — the 200-line ratchet, shared by the edit check, the ship gate and preflight
199
202
  gauntlet-selftest.sh — behavioural tests for the hooks (runs on pre-push)
200
203
  gauntlet-survey.sh — what the hook would do in every repo under a directory
201
204
  preflight.sh — everything that must pass before a release
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bash
2
+
3
+ INPUT=$(cat)
4
+
5
+ FILE=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' \
6
+ | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
7
+ [ -z "$FILE" ] && exit 0
8
+
9
+ HERE="$(cd "$(dirname "$0")" && pwd)"
10
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
11
+ if [ -f "$HERE/gate-config.sh" ]; then
12
+ . "$HERE/gate-config.sh"
13
+ load_gauntlet_conf "$PROJECT_DIR/.claude/gauntlet.conf" "$HERE/../gauntlet.conf"
14
+ is_ignored "${FILE#"$PROJECT_DIR"/}" && exit 0
15
+ fi
16
+ FAILS=""
17
+ NOTES=""
18
+
19
+ run() {
20
+ local out rc
21
+ out=$("$@" 2>&1); rc=$?
22
+ [ -z "$out" ] && return
23
+ if [ "$rc" = 0 ]; then NOTES="$NOTES$out"$'\n'; else FAILS="$FAILS$out"$'\n'; fi
24
+ }
25
+
26
+ # The gate measures length from the git root, so a monorepo subfolder shares its baseline.
27
+ GIT_ROOT=$(git -C "$PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$PROJECT_DIR")
28
+ [ -f "$HERE/line-limit.sh" ] && run env LINE_LIMIT_ROOT="$GIT_ROOT" bash "$HERE/line-limit.sh" --file "$FILE"
29
+ lint() { cd "$PROJECT_DIR" && node "$HERE/lint-ratchet.mjs" --file "$FILE"; }
30
+ [ -f "$HERE/lint-ratchet.mjs" ] && command -v node >/dev/null && run lint
31
+
32
+ if [ -n "$FAILS" ]; then
33
+ printf '%s%s' "$FAILS" "$NOTES" >&2
34
+ exit 2
35
+ fi
36
+ if [ -n "$NOTES" ] && declare -F json_string >/dev/null; then
37
+ printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}\n' "$(printf '%s' "$NOTES" | json_string)"
38
+ fi
39
+ exit 0
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+
3
+ GAUNTLET_MAX_LINES=200
4
+ GAUNTLET_MUTATE=""
5
+ GAUNTLET_SOURCE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|gd"
6
+ GAUNTLET_IGNORE_EXT="md|mdx|txt|rst|adoc|jsonc?|ya?ml|toml|lock|cfg|ini|env|csv|tsv|sql|html|css|scss|svg|png|jpg|jpeg|gif|webp|ico|pdf|woff2?|ttf|otf|mp3|mp4|wav|zip|gz|tres|tscn|import|godot"
7
+ GAUNTLET_IGNORE_FILES="*.gen.ts *.gen.tsx *.generated.* */migrations/*.py */alembic/versions/*.py */components/ui/*.tsx */hooks/use-mobile.ts */*.config.*"
8
+ # Components are length-checked but not mutated: their mutants are class names and JSX, not
9
+ # behaviour. Override in .claude/gauntlet.conf to mutate them too.
10
+ GAUNTLET_NO_MUTATE='\.(tsx|jsx)$'
11
+
12
+ load_gauntlet_conf() { # load_gauntlet_conf <project conf> <fallback conf>
13
+ [ -n "${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ] && . "$HOME/.claude/gauntlet.conf"
14
+ local conf="$1"; [ -f "$conf" ] || conf="$2"
15
+ if [ -f "$conf" ]; then . "$conf"; fi
16
+ }
17
+
18
+ # set -f: unquoted, the list is glob-expanded before it is split, and */components/ui/*.tsx
19
+ # expanded into the very path it should match.
20
+ is_ignored() {
21
+ set -f
22
+ for pat in $GAUNTLET_IGNORE_FILES; do
23
+ case "/$1" in $pat) set +f; return 0 ;; esac
24
+ done
25
+ set +f
26
+ return 1
27
+ }
28
+
29
+ json_string() {
30
+ awk 'BEGIN { ORS = "" } {
31
+ gsub(/\033\[[0-9;?]*[ -\/]*[@-~]/, "")
32
+ gsub(/[\001-\010\013-\037\177]/, "") # JSON forbids raw control characters
33
+ gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); gsub(/\t/, "\\t")
34
+ if (NR > 1) print "\\n"
35
+ print
36
+ }'
37
+ }
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -uo pipefail
4
+
5
+ HERE="$(cd "$(dirname "$0")" && pwd)"
6
+ . "$HERE/gate-config.sh" || exit 1
7
+ ROOT="$(cd "${LINE_LIMIT_ROOT:-$(git -C "$HERE" rev-parse --show-toplevel 2>/dev/null || echo "$HERE/..")}" && pwd -P)"
8
+ load_gauntlet_conf "$ROOT/.claude/gauntlet.conf" "$HERE/../gauntlet.conf"
9
+ LIMIT=$GAUNTLET_MAX_LINES
10
+ BASELINE="$ROOT/.line-limit-baseline"
11
+ SELF="${HERE#"$ROOT"/}/line-limit.sh"
12
+ if ! [ "$LIMIT" -gt 0 ] 2>/dev/null; then
13
+ [ "${1:-}" = "--file" ] || echo "LINE LIMIT — off (GAUNTLET_MAX_LINES=$LIMIT)"
14
+ exit 0
15
+ fi
16
+
17
+ is_code() {
18
+ local ext="\\.($GAUNTLET_SOURCE_EXT|sh)$" skip="(^|/)node_modules/|^\\.claude/"
19
+ [[ $1 =~ $ext ]] && ! [[ $1 =~ $skip ]] && ! is_ignored "$1"
20
+ }
21
+
22
+ # Paths are kept relative to ROOT so the baseline is portable between clones.
23
+ files() {
24
+ { git -C "$ROOT" ls-files --cached --others --exclude-standard 2>/dev/null \
25
+ || (cd "$ROOT" && find . -type f -not -path './.git/*' | sed 's|^\./||'); } \
26
+ | while IFS= read -r f; do is_code "$f" && [ -f "$ROOT/$f" ] && printf '%s/%s\n' "$ROOT" "$f"; done | sort
27
+ }
28
+
29
+ counts() {
30
+ while IFS= read -r f; do
31
+ printf '%s %s\n' "$(wc -l < "$f" | tr -d ' ')" "${f#"$ROOT"/}"
32
+ done
33
+ }
34
+
35
+ ONE=""
36
+ if [ "${1:-}" = "--file" ]; then
37
+ ONE="$(cd "$(dirname -- "${2:-.}")" 2>/dev/null && pwd -P)/$(basename -- "${2:-.}")"
38
+ REL="${ONE#"$ROOT"/}"
39
+ [ "$REL" != "$ONE" ] && [ -f "$ONE" ] && is_code "$REL" || exit 0
40
+ git -C "$ROOT" check-ignore -q -- "$REL" 2>/dev/null && exit 0
41
+ NOW=$(printf '%s\n' "$ONE" | counts)
42
+ else
43
+ NOW=$(files | counts)
44
+ fi
45
+
46
+ # On an existing baseline this only banks: an entry may drop or go, never appear or grow.
47
+ if [ "${1:-}" = "--baseline" ]; then
48
+ if [ -f "$BASELINE" ]; then
49
+ printf '%s\n' "$NOW" | awk -v lim="$LIMIT" '{ n = $1; p = $0; sub(/^[^ ]+ /, "", p) }
50
+ NR == FNR { was[p] = n; next } (p in was) && n > lim { print (n < was[p] ? n : was[p]), p }' "$BASELINE" - > "$BASELINE.tmp" \
51
+ && mv "$BASELINE.tmp" "$BASELINE"
52
+ else
53
+ printf '%s\n' "$NOW" | awk -v lim="$LIMIT" '$1 > lim' > "$BASELINE"
54
+ fi
55
+ n=$(wc -l < "$BASELINE" | tr -d ' ')
56
+ echo "line-limit: baseline written — $n file(s) over $LIMIT accepted as debt."
57
+ echo "Commit $BASELINE. From here they may shrink but never grow."
58
+ exit 0
59
+ fi
60
+
61
+ FAILED=0
62
+ NEW_OVER=""
63
+ GREW=""
64
+ SHRANK=""
65
+
66
+ while read -r n f; do
67
+ [ -z "$f" ] && continue
68
+ was=$(awk -v p="$f" '{ n = $1; sub(/^[^ ]+ /, "") } $0 == p { print n }' "$BASELINE" 2>/dev/null)
69
+ if [ -z "$was" ]; then
70
+ # Not accepted debt, so the real limit applies.
71
+ if [ "$n" -gt "$LIMIT" ]; then
72
+ NEW_OVER="$NEW_OVER $f — $n lines (limit $LIMIT)
73
+ "
74
+ FAILED=1
75
+ fi
76
+ elif [ "$n" -gt "$was" ]; then
77
+ GREW="$GREW $f — $n lines, was $was
78
+ "
79
+ FAILED=1
80
+ elif [ "$n" -lt "$was" ]; then
81
+ SHRANK="$SHRANK $f — $n lines, was $was
82
+ "
83
+ elif [ -n "$ONE" ]; then
84
+ echo "LINE LIMIT — $f is $n lines, over $LIMIT (old debt). Split it if this change allows."
85
+ fi
86
+ done <<EOF
87
+ $NOW
88
+ EOF
89
+
90
+ # A file in the baseline that no longer exists is debt that was paid by deleting
91
+ # or renaming it. Report it so the baseline can be trimmed, never fail on it.
92
+ GONE=""
93
+ if [ -z "$ONE" ] && [ -f "$BASELINE" ]; then
94
+ while read -r was f; do
95
+ [ -z "$f" ] && continue
96
+ [ -f "$ROOT/$f" ] || GONE="$GONE $f — gone (was $was lines)
97
+ "
98
+ done < "$BASELINE"
99
+ fi
100
+
101
+ if [ -n "$NEW_OVER" ]; then
102
+ echo "LINE LIMIT — over $LIMIT and not accepted debt:"
103
+ printf '%s' "$NEW_OVER"
104
+ echo " Split them."
105
+ fi
106
+
107
+ if [ -n "$GREW" ]; then
108
+ echo "LINE LIMIT — accepted debt that grew:"
109
+ printf '%s' "$GREW"
110
+ echo " The baseline is a ceiling, not a licence. Take the addition elsewhere."
111
+ fi
112
+
113
+ if [ -n "$SHRANK" ] || [ -n "$GONE" ]; then
114
+ echo "LINE LIMIT — debt paid down (not a failure):"
115
+ [ -n "$SHRANK" ] && printf '%s' "$SHRANK"
116
+ [ -n "$GONE" ] && printf '%s' "$GONE"
117
+ echo " Run: bash $SELF --baseline to bank it."
118
+ fi
119
+
120
+ if [ "$FAILED" = 0 ] && [ -z "$SHRANK" ] && [ -z "$GONE" ] && [ -z "$ONE" ]; then
121
+ over=$(wc -l < "$BASELINE" 2>/dev/null | tr -d ' ')
122
+ echo "LINE LIMIT — ok. ${over:-0} file(s) of accepted debt, none grew."
123
+ fi
124
+
125
+ exit $FAILED
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, realpathSync } from "node:fs";
4
+ import { relative, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { brokenRules, loadEditRules, projectFiles, textFindings } from "./text-rules.mjs";
7
+ import { mergeBaseline, readBaseline, writeBaseline } from "./ratchet-baseline.mjs";
8
+
9
+ const ROOT = realpathSync(process.cwd());
10
+ const BASELINE = resolve(ROOT, ".lint-baseline");
11
+ const ESLINT = resolve(ROOT, "node_modules/.bin/eslint");
12
+ const SELF = relative(ROOT, fileURLToPath(import.meta.url));
13
+ let TEXT = [];
14
+ let LESSONS = [];
15
+
16
+ const isTest = (f) => /\.(test|spec)\.|(^|\/)(test|tests|__tests__)\//.test(f);
17
+
18
+ // A test's describe() callback is one long function by design, and React
19
+ // components are left unannotated by convention, so each rule names its files.
20
+ const RULES = [
21
+ {
22
+ id: "max-lines-per-function",
23
+ skill: "code-structure",
24
+ config: ["error", { max: 30, skipBlankLines: true, skipComments: true }],
25
+ applies: (f) => /\.(m?js|jsx|ts|tsx)$/.test(f) && !isTest(f),
26
+ },
27
+ {
28
+ id: "@typescript-eslint/explicit-module-boundary-types",
29
+ skill: "typescript-best-practices",
30
+ config: "error",
31
+ applies: (f) => /\.ts$/.test(f) && !isTest(f),
32
+ },
33
+ ];
34
+
35
+ // skills/ is where this repo keeps the skills it ships; a consumer has them under .claude/skills/.
36
+ const hasSkill = (name) =>
37
+ existsSync(resolve(ROOT, ".claude/skills", name, "SKILL.md")) || existsSync(resolve(ROOT, "skills", name, "SKILL.md"));
38
+ const LINT = RULES.filter((r) => hasSkill(r.skill));
39
+
40
+ // A plugin rule the project's config cannot load makes ESLint crash, so retry without it.
41
+ function eslint(rules, target) {
42
+ const results = eslintOnce(rules, target);
43
+ const core = rules.filter((r) => !r.id.includes("/"));
44
+ if (results || core.length === rules.length) return { results, ran: rules };
45
+ return { results: eslintOnce(core, target), ran: core };
46
+ }
47
+
48
+ function eslintOnce(rules, target) {
49
+ const args = ["-f", "json"];
50
+ for (const r of rules) args.push("--rule", JSON.stringify({ [r.id]: r.config }));
51
+ args.push(target);
52
+ const run = spawnSync(ESLINT, args, { cwd: ROOT, encoding: "utf8", maxBuffer: 1 << 28 });
53
+ try {
54
+ return JSON.parse(run.stdout);
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function eslintFindings(results) {
61
+ const out = [];
62
+ for (const file of results) {
63
+ const rel = relative(ROOT, file.filePath);
64
+ for (const m of file.messages) {
65
+ const rule = RULES.find((r) => r.id === m.ruleId);
66
+ if (rule && rule.applies(rel)) out.push({ rel, rule: m.ruleId, line: m.line, message: m.message });
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ function countBy(list) {
73
+ const counts = new Map();
74
+ for (const f of list) {
75
+ const key = `${f.rule} ${f.rel}`;
76
+ counts.set(key, (counts.get(key) ?? 0) + 1);
77
+ }
78
+ return counts;
79
+ }
80
+
81
+ function baseline(only, newOnly) {
82
+ const was = readBaseline(BASELINE);
83
+ const pick = (r) => (only ? r.id === only : !newOnly || !was.rules.has(r.id));
84
+ const lint = existsSync(ESLINT) ? LINT.filter(pick) : [];
85
+ const text = TEXT.filter(pick);
86
+ if (newOnly && was.exists && lint.length + text.length === 0) return 0;
87
+ const { results, ran } = lint.length ? eslint(lint, ".") : { results: [], ran: [] };
88
+ if (!results) {
89
+ console.error("lint-ratchet: eslint gave no JSON; baseline not written.");
90
+ return 1;
91
+ }
92
+ const found = [...eslintFindings(results), ...projectFiles(ROOT).flatMap((rel) => textFindings(text, ROOT, rel))];
93
+ const judged = new Set([...ran, ...text].map((r) => r.id));
94
+ const n = writeBaseline(BASELINE, mergeBaseline(was, countBy(found), judged), new Set([...was.rules, ...judged]));
95
+ console.log(`lint-ratchet: baseline written — ${n} rule/file pair(s) accepted as debt.`);
96
+ return 0;
97
+ }
98
+
99
+ function scan(rel) {
100
+ const lint = existsSync(ESLINT) ? LINT.filter((r) => r.applies(rel)) : [];
101
+ const { results, ran } = lint.length ? eslint(lint, rel) : { results: null, ran: [] };
102
+ const text = TEXT.filter((r) => r.applies(rel));
103
+ return {
104
+ rules: [...(results ? ran : []), ...text],
105
+ found: [...(results ? eslintFindings(results) : []), ...textFindings(text, ROOT, rel)],
106
+ };
107
+ }
108
+
109
+ function checkFile(path) {
110
+ const abs = resolve(ROOT, path);
111
+ const rel = relative(ROOT, existsSync(abs) ? realpathSync(abs) : abs);
112
+ if (rel.startsWith("..") || spawnSync("git", ["check-ignore", "-q", "--", rel], { cwd: ROOT }).status === 0) return 0;
113
+ const { rules, found } = scan(rel);
114
+ const was = readBaseline(BASELINE);
115
+ let failed = 0;
116
+ for (const rule of rules) {
117
+ const hits = found.filter((f) => f.rule === rule.id);
118
+ const before = was.counts.get(`${rule.id} ${rel}`) ?? 0;
119
+ if (hits.length > before && !was.rules.has(rule.id)) {
120
+ console.log(`NOTE — ${rule.id} has no baseline yet, so its ${hits.length} finding(s) in ${rel} do not block. Run node ${SELF} --baseline-new to record old code.`);
121
+ } else if (hits.length > before) {
122
+ failed = 1;
123
+ console.log(`LINT — ${rel}: ${hits.length} × ${rule.id}, was ${before}. Fix the new one(s):`);
124
+ for (const h of hits) console.log(` ${rel}:${h.line} ${h.message}`);
125
+ } else if (hits.length > 0) {
126
+ console.log(`NOTE — ${rel} has ${hits.length} old ${rule.id} finding(s). Fix them if this change allows.`);
127
+ } else if (before > 0) {
128
+ console.log(`NOTE — ${rel}: ${rule.id} debt paid down. Run node ${SELF} --baseline ${rule.id} to bank it.`);
129
+ }
130
+ }
131
+ for (const lesson of LESSONS.filter((l) => l.applies(rel))) console.log(`LESSON — ${lesson.message}`);
132
+ return failed;
133
+ }
134
+
135
+ function selftest() {
136
+ const broken = brokenRules(TEXT);
137
+ for (const b of broken) console.log(`RULE — ${b}`);
138
+ if (broken.length === 0) console.log(`lint-ratchet: ${TEXT.length} text rule(s) catch their bad example and pass their good one.`);
139
+ return broken.length ? 1 : 0;
140
+ }
141
+
142
+ function main(mode, arg) {
143
+ ({ text: TEXT, lessons: LESSONS } = loadEditRules(ROOT));
144
+ if (mode === "--baseline") return baseline(arg, false);
145
+ if (mode === "--baseline-new") return baseline(undefined, true);
146
+ if (mode === "--file") return checkFile(arg ?? "");
147
+ if (mode === "--selftest") return selftest();
148
+ console.error("usage: lint-ratchet.mjs --baseline [rule-id] | --baseline-new | --file <path> | --selftest");
149
+ return 1;
150
+ }
151
+
152
+ const [mode, arg] = process.argv.slice(2);
153
+ try {
154
+ process.exit(main(mode, arg));
155
+ } catch (err) {
156
+ console.log(`NOTE — .edit-rules.json could not be used, so its rules did not run: ${err.message}`);
157
+ process.exit(mode === "--file" ? 0 : 1);
158
+ }
@@ -0,0 +1,39 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+
3
+ const ruleOf = (key) => key.split(" ")[0];
4
+
5
+ // "# rules:" names every rule baselined so far, since a rule with no hits leaves no other line.
6
+ export function readBaseline(path) {
7
+ const counts = new Map();
8
+ const rules = new Set();
9
+ if (!existsSync(path)) return { counts, rules, exists: false };
10
+ for (const line of readFileSync(path, "utf8").split("\n")) {
11
+ if (line.startsWith("# rules:")) {
12
+ for (const id of line.slice(8).trim().split(/\s+/).filter(Boolean)) rules.add(id);
13
+ continue;
14
+ }
15
+ const m = line.match(/^(\d+) (\S+) (.+)$/);
16
+ if (!m) continue;
17
+ counts.set(`${m[2]} ${m[3]}`, Number(m[1]));
18
+ rules.add(m[2]);
19
+ }
20
+ return { counts, rules, exists: true };
21
+ }
22
+
23
+ // A rule baselined before only banks: its debt may drop or go, never appear or grow.
24
+ export function mergeBaseline(was, now, judged) {
25
+ const merged = new Map([...was.counts].filter(([key]) => !judged.has(ruleOf(key))));
26
+ for (const [key, n] of was.counts) {
27
+ if (judged.has(ruleOf(key)) && now.has(key)) merged.set(key, Math.min(n, now.get(key)));
28
+ }
29
+ for (const [key, n] of now) {
30
+ if (judged.has(ruleOf(key)) && !was.rules.has(ruleOf(key))) merged.set(key, n);
31
+ }
32
+ return merged;
33
+ }
34
+
35
+ export function writeBaseline(path, merged, rules) {
36
+ const lines = [...merged].map(([key, n]) => `${n} ${key}`).sort();
37
+ writeFileSync(path, [`# rules: ${[...rules].sort().join(" ")}`, ...lines, ""].join("\n"));
38
+ return lines.length;
39
+ }
@@ -20,7 +20,7 @@ changed_tests() { # changed_tests <family>
20
20
  files_context() { # files_context <owner> <owned files>
21
21
  fam=$(family_of "$1/.")
22
22
  { printf '%s\n' "$PROJECT_DIR" "$1" "$(git rev-parse "$BASE" 2>/dev/null)"
23
- cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh" "$HERE/ship-gate-files.sh"
23
+ cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh" "$HERE/ship-gate-files.sh" "$HERE/gate-config.sh" "$HERE/line-limit.sh"
24
24
  printf '%s\n' "$CHANGED" | grep -vE '\.(md|mdx|txt|rst|adoc)$' | grep -vE "$TEST_RE" \
25
25
  | grep -vxF -f <(printf '%s\n' "$2") | while IFS= read -r f; do
26
26
  [ -n "$f" ] || continue
@@ -139,7 +139,7 @@ project_key() { # project_key <owner> <tool>
139
139
  fam=$(family_of "$1/.")
140
140
  { printf '%s\n' "$PROJECT_DIR" "$1" "$2" "$(git rev-parse "$BASE" 2>/dev/null)" \
141
141
  "$GAUNTLET_NO_MUTATE" "$GAUNTLET_IGNORE_FILES"
142
- cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh" "$HERE/ship-gate-files.sh"
142
+ cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh" "$HERE/ship-gate-files.sh" "$HERE/gate-config.sh" "$HERE/line-limit.sh"
143
143
  printf '%s\n' "$CHANGED" | grep -vE '\.(md|mdx|txt|rst|adoc)$' | while IFS= read -r f; do
144
144
  [ -n "$f" ] || continue
145
145
  case "$(family_of "$f")" in "$fam"|any) printf '%s\n' "$f"; [ -f "$f" ] && cat "$f" ;; esac
@@ -107,6 +107,20 @@ structure_fail() {
107
107
  # An agent that pipes the gate through `tail` cuts the findings off; the last line still says where they are.
108
108
  gate_report() {
109
109
  REPORT="/tmp/claude-shipgate-$1.log"
110
- exec > >(tee "$REPORT") 2>&1
111
- trap 'echo "ship-gate: full output in $REPORT"' EXIT
110
+ exec 3>&1 4>&2 > >(tee "$REPORT") 2>&1
111
+ TEE_PID=${!:-} # bash before 4.4 does not set $! for process substitution
112
+ trap 'gate_exit $?' EXIT
113
+ }
114
+
115
+ # A FAIL goes to the diary /add-rule reads, so a test gap the gate keeps finding can become a rule.
116
+ gate_exit() {
117
+ echo "ship-gate: full output in $REPORT"
118
+ [ "$1" = 1 ] || return 0
119
+ exec 1>&3 2>&4
120
+ # A child left running (a dev server) can hold tee open forever; give it 2s, not a hang.
121
+ for _ in 1 2 3 4 5 6 7 8 9 10; do [ -n "$TEE_PID" ] && kill -0 "$TEE_PID" 2>/dev/null || break; sleep 0.2; done
122
+ mkdir -p "$PROJECT_DIR/.claude"
123
+ printf '{"ts":"%s","branch":"%s","status":"FAIL","report":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
124
+ "$(git branch --show-current 2>/dev/null | json_string)" "$(json_string < "$REPORT")" \
125
+ >> "$PROJECT_DIR/.claude/gate-findings.jsonl"
112
126
  }
@@ -47,7 +47,7 @@
47
47
  set -uo pipefail
48
48
 
49
49
  HERE=$(cd "$(dirname "$0")" && pwd)
50
- . "$HERE/ship-gate-projects.sh" && . "$HERE/ship-gate-structure.sh" && . "$HERE/ship-gate-files.sh" || { echo "ship-gate: cannot read its helpers in $HERE"; exit 1; }
50
+ . "$HERE/gate-config.sh" && . "$HERE/ship-gate-projects.sh" && . "$HERE/ship-gate-structure.sh" && . "$HERE/ship-gate-files.sh" || { echo "ship-gate: cannot read its helpers in $HERE"; exit 1; }
51
51
 
52
52
  MODE=""
53
53
  case "${1:-}" in
@@ -73,20 +73,7 @@ if [ -n "$ROOT" ]; then
73
73
  PROJECT_DIR="$ROOT"
74
74
  fi
75
75
 
76
- GAUNTLET_MAX_LINES=200
77
- GAUNTLET_MUTATE=""
78
- GAUNTLET_SOURCE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|gd"
79
- GAUNTLET_IGNORE_EXT="md|mdx|txt|rst|adoc|jsonc?|ya?ml|toml|lock|cfg|ini|env|csv|tsv|sql|html|css|scss|svg|png|jpg|jpeg|gif|webp|ico|pdf|woff2?|ttf|otf|mp3|mp4|wav|zip|gz|tres|tscn|import|godot"
80
- GAUNTLET_IGNORE_FILES="*.gen.ts *.gen.tsx *.generated.* */migrations/*.py */alembic/versions/*.py */components/ui/*.tsx */hooks/use-mobile.ts */*.config.*"
81
- # Length-checked and skill-audited like everything else, but not mutated.
82
- # Mutation pays on logic and burns time on presentation: a component's mutants
83
- # are class names, copy and JSX shape, none of which is behaviour, and one
84
- # session spent an evening killing them. Logic that lives inside a component is
85
- # logic that wants extracting — which the 200-line rule already pushes toward.
86
- # Override in .claude/gauntlet.conf to mutate components too.
87
- GAUNTLET_NO_MUTATE='\.(tsx|jsx)$'
88
- [ -n "${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ] && . "$HOME/.claude/gauntlet.conf"
89
- CONF=.claude/gauntlet.conf; [ -f "$CONF" ] || CONF="$HERE/../gauntlet.conf"; [ -f "$CONF" ] && . "$CONF"
76
+ load_gauntlet_conf .claude/gauntlet.conf "$HERE/../gauntlet.conf"
90
77
 
91
78
  # ------------------------------------------------------------------- the scope
92
79
  BASE="${1:-}"
@@ -135,17 +122,6 @@ SOURCED=$(printf '%s\n' "$CHANGED" | grep -E "\.($GAUNTLET_SOURCE_EXT)$" \
135
122
  # uncovered line started failing the gate: vitest.config.ts has no tests, will
136
123
  # never have tests, and is not a thing anyone writes a test for. Left in, every
137
124
  # diff that touches a config reports findings nobody can close.
138
- # set -f matters: an unquoted list is glob-expanded before it is split, so
139
- # */components/ui/*.tsx turned itself into the very path it was meant to match
140
- # and then matched nothing.
141
- is_ignored() {
142
- set -f
143
- for pat in $GAUNTLET_IGNORE_FILES; do
144
- case "/$1" in $pat) set +f; return 0 ;; esac
145
- done
146
- set +f
147
- return 1
148
- }
149
125
  FILES=$(printf '%s\n' "$SOURCED" | while IFS= read -r f; do
150
126
  [ -n "$f" ] && ! is_ignored "$f" && printf '%s\n' "$f"
151
127
  done)
@@ -190,7 +166,7 @@ if [ "$GAUNTLET_MAX_LINES" -gt 0 ]; then
190
166
  while IFS= read -r f; do
191
167
  n=$(wc -l < "$f")
192
168
  [ "$n" -gt "$LARGEST" ] && LARGEST=$n
193
- [ "$n" -gt "$GAUNTLET_MAX_LINES" ] && OVER="$OVER $f — $n lines (limit $GAUNTLET_MAX_LINES)
169
+ out=$(LINE_LIMIT_ROOT="$PWD" bash "$HERE/line-limit.sh" --file "$f") || OVER="$OVER$(printf '%s\n' "$out" | grep -E '^ .* — [0-9]+ lines')
194
170
  "
195
171
  done <<< "$FILES"
196
172
  fi
@@ -1,10 +1,10 @@
1
1
  #!/bin/bash
2
- # SubagentStop: appends each gauntlet-skills verdict to .claude/skill-flags.jsonl.
3
2
 
4
3
  INPUT=$(cat | tr -d '\n')
5
4
 
6
5
  case "$INPUT" in
7
- *'"agent_type":"gauntlet-skills"'*) ;;
6
+ *'"agent_type":"gauntlet-skills"'*) LOG=skill-flags.jsonl ;;
7
+ *'\"failure_scenario\"'*) LOG=review-findings.jsonl ;;
8
8
  *) exit 0 ;;
9
9
  esac
10
10
 
@@ -19,7 +19,9 @@ TRANSCRIPT=$(printf '%s' "$INPUT" | grep -o '"agent_transcript_path":"[^"]*"' |
19
19
  MODEL=""
20
20
  [ -f "$TRANSCRIPT" ] && MODEL=$(grep -o '"model":"[^"]*"' "$TRANSCRIPT" | tail -1 | sed 's/.*:"//; s/"$//')
21
21
 
22
- printf '{"ts":"%s","model":"%s","reply":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$MODEL" "$REPLY" \
23
- >> "$PROJECT_DIR/.claude/skill-flags.jsonl"
22
+ . "$SCRIPT_DIR/gate-config.sh" 2>/dev/null || json_string() { tr -cd '[:alnum:]/._-'; }
23
+ BRANCH=$(git -C "$PROJECT_DIR" branch --show-current 2>/dev/null | json_string)
24
+ printf '{"ts":"%s","branch":"%s","model":"%s","reply":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$BRANCH" "$MODEL" "$REPLY" \
25
+ >> "$PROJECT_DIR/.claude/$LOG"
24
26
 
25
27
  exit 0
@@ -0,0 +1,46 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+
5
+ export function loadEditRules(root) {
6
+ const file = resolve(root, ".edit-rules.json");
7
+ const { text = [], lessons = [] } = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
8
+ const withFiles = (r) => ({ ...r, applies: (f) => new RegExp(r.files).test(f) });
9
+ return { text: text.map(withFiles), lessons: lessons.map(withFiles) };
10
+ }
11
+
12
+ function matchLines(rule, content) {
13
+ const re = new RegExp(rule.pattern, `g${(rule.flags ?? "").replace("g", "")}`);
14
+ return [...content.matchAll(re)].map((m) => content.slice(0, m.index).split("\n").length);
15
+ }
16
+
17
+ export function textFindings(rules, root, rel) {
18
+ const own = rules.filter((r) => r.applies(rel));
19
+ const path = resolve(root, rel);
20
+ if (own.length === 0 || !existsSync(path)) return [];
21
+ const content = readFileSync(path, "utf8");
22
+ return own.flatMap((r) => matchLines(r, content).map((line) => ({ rel, rule: r.id, line, message: r.message })));
23
+ }
24
+
25
+ export function projectFiles(root) {
26
+ const args = ["ls-files", "--cached", "--others", "--exclude-standard"];
27
+ const run = spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 1 << 28 });
28
+ return run.status === 0 ? run.stdout.split("\n").filter(Boolean) : walk(root, "");
29
+ }
30
+
31
+ function walk(root, dir) {
32
+ return readdirSync(resolve(root, dir), { withFileTypes: true }).flatMap((e) => {
33
+ if (e.name === "node_modules" || e.name === ".git") return [];
34
+ const rel = dir ? `${dir}/${e.name}` : e.name;
35
+ return e.isDirectory() ? walk(root, rel) : [rel];
36
+ });
37
+ }
38
+
39
+ export function brokenRules(rules) {
40
+ const broken = [];
41
+ for (const r of rules) {
42
+ if (matchLines(r, r.bad ?? "").length === 0) broken.push(`${r.id}: its bad example is not caught`);
43
+ if (matchLines(r, r.good ?? "").length > 0) broken.push(`${r.id}: its good example is flagged`);
44
+ }
45
+ return broken;
46
+ }
@@ -12,6 +12,7 @@ const SHIP_GATE_FILENAME = "ship-gate.sh";
12
12
  const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
13
13
  const VERSION_CHECK_FILENAME = "version-check.sh";
14
14
  const SKILL_FLAGS_FILENAME = "skill-flags-record.sh";
15
+ const EDIT_CHECK_FILENAME = "edit-check.sh";
15
16
  const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
16
17
  const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
17
18
 
@@ -96,6 +97,12 @@ export async function registerHooks(settingsPath) {
96
97
  settings.hooks.PostToolUse = [autoMarkEntry];
97
98
  }
98
99
 
100
+ const editCheckEntry = {
101
+ matcher: "Write|Edit|MultiEdit",
102
+ hooks: [{ type: "command", command: `$CLAUDE_PROJECT_DIR/.claude/hooks/${EDIT_CHECK_FILENAME}` }],
103
+ };
104
+ upsert(settings.hooks.PostToolUse, EDIT_CHECK_FILENAME, editCheckEntry);
105
+
99
106
  // Register SessionStart version check. No matcher: startup and resume both
100
107
  // want it, and the script's own daily stamp keeps a burst of resumes quiet.
101
108
  const versionCheckCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${VERSION_CHECK_FILENAME}`;
@@ -1,7 +1,8 @@
1
1
  import { registerHooks } from "./hook-settings.mjs";
2
2
  import { mkdir, writeFile, readFile, chmod, unlink } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
3
4
  import { join, resolve, dirname } from "node:path";
4
- import { spawn } from "node:child_process";
5
+ import { spawn, spawnSync } from "node:child_process";
5
6
  import { homedir } from "node:os";
6
7
 
7
8
  // Installs the hook scripts and registers them in .claude/settings.json.
@@ -17,17 +18,21 @@ import { homedir } from "node:os";
17
18
  // explicitly applied before the first edit
18
19
  // gauntlet.sh Stop — fast typecheck and tests on the diff
19
20
  // ship-gate.sh file length and mutation, behind an exit code
21
+ // gate-config.sh sourced by ship-gate.sh — shared helpers
20
22
  // ship-gate-projects.sh sourced by ship-gate.sh — project detection, reuse
21
23
  // ship-gate-hook.sh PreToolUse on Bash — no commit without a receipt
22
24
  // version-check.sh SessionStart — a line when a newer catalog exists
23
25
  // skill-flags-record.sh SubagentStop — logs each skills-audit verdict
24
26
  // skill-report.mjs read by /skill-report — tallies that log
27
+ // edit-check.sh PostToolUse — line-limit.sh, lint-ratchet.mjs and
28
+ // text-rules.mjs on each edited file
25
29
 
26
30
  const GATE_FILENAME = "skill-gate.sh";
27
31
  const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
28
32
  const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
29
33
  const GAUNTLET_FILENAME = "gauntlet.sh";
30
34
  const SHIP_GATE_FILENAME = "ship-gate.sh";
35
+ const GATE_CONFIG_FILENAME = "gate-config.sh";
31
36
  const SHIP_GATE_PROJECTS_FILENAME = "ship-gate-projects.sh";
32
37
  const SHIP_GATE_STRUCTURE_FILENAME = "ship-gate-structure.sh";
33
38
  const SHIP_GATE_FILES_FILENAME = "ship-gate-files.sh";
@@ -35,6 +40,11 @@ const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
35
40
  const VERSION_CHECK_FILENAME = "version-check.sh";
36
41
  const SKILL_FLAGS_FILENAME = "skill-flags-record.sh";
37
42
  const SKILL_REPORT_FILENAME = "skill-report.mjs";
43
+ const EDIT_CHECK_FILENAME = "edit-check.sh";
44
+ const LINE_LIMIT_FILENAME = "line-limit.sh";
45
+ const LINT_RATCHET_FILENAME = "lint-ratchet.mjs";
46
+ const TEXT_RULES_FILENAME = "text-rules.mjs";
47
+ const RATCHET_BASELINE_FILENAME = "ratchet-baseline.mjs";
38
48
  const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
39
49
  const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
40
50
 
@@ -99,6 +109,20 @@ export async function writeGauntletConf(targetDir, testCommand) {
99
109
  return confPath;
100
110
  }
101
111
 
112
+ // Before the edit hook is registered, so code that predates it never blocks an edit.
113
+ // Only rules never baselined are recorded: re-recording a known one would bank growth.
114
+ function writeBaselines(dir, hooksDir) {
115
+ const gitRoot = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" }).stdout?.trim();
116
+ const root = gitRoot || dir;
117
+ if (!existsSync(join(root, ".line-limit-baseline"))) {
118
+ spawnSync("bash", [join(hooksDir, LINE_LIMIT_FILENAME), "--baseline"],
119
+ { cwd: root, env: { ...process.env, LINE_LIMIT_ROOT: root }, stdio: "ignore" });
120
+ }
121
+ console.log(" Recording old code as baseline debt (runs ESLint once per new rule)...");
122
+ spawnSync(process.execPath, [join(hooksDir, LINT_RATCHET_FILENAME), "--baseline-new"],
123
+ { cwd: dir, stdio: "ignore", timeout: 600_000 });
124
+ }
125
+
102
126
  export async function setupHook(targetDir = process.cwd()) {
103
127
  const resolved = resolve(targetDir);
104
128
  const hooksDir = join(resolved, ".claude", "hooks");
@@ -111,6 +135,7 @@ export async function setupHook(targetDir = process.cwd()) {
111
135
  APPLICATION_GATE_FILENAME,
112
136
  GAUNTLET_FILENAME,
113
137
  SHIP_GATE_FILENAME,
138
+ GATE_CONFIG_FILENAME,
114
139
  SHIP_GATE_PROJECTS_FILENAME,
115
140
  SHIP_GATE_STRUCTURE_FILENAME,
116
141
  SHIP_GATE_FILES_FILENAME,
@@ -118,6 +143,11 @@ export async function setupHook(targetDir = process.cwd()) {
118
143
  VERSION_CHECK_FILENAME,
119
144
  SKILL_FLAGS_FILENAME,
120
145
  SKILL_REPORT_FILENAME,
146
+ EDIT_CHECK_FILENAME,
147
+ LINE_LIMIT_FILENAME,
148
+ LINT_RATCHET_FILENAME,
149
+ TEXT_RULES_FILENAME,
150
+ RATCHET_BASELINE_FILENAME,
121
151
  ]) {
122
152
  const dest = join(hooksDir, name);
123
153
  await writeFile(dest, await readHookSource(name), { mode: 0o755 });
@@ -131,6 +161,7 @@ export async function setupHook(targetDir = process.cwd()) {
131
161
  // not present — fine
132
162
  }
133
163
 
164
+ writeBaselines(resolved, hooksDir);
134
165
  await registerHooks(settingsPath);
135
166
 
136
167
  console.log(` Hook installed: .claude/hooks/${GATE_FILENAME}`);
@@ -140,5 +171,6 @@ export async function setupHook(targetDir = process.cwd()) {
140
171
  console.log(` Installed: .claude/hooks/${SHIP_GATE_FILENAME} (used by /ship)`);
141
172
  console.log(` Hook installed: .claude/hooks/${SHIP_GATE_HOOK_FILENAME}`);
142
173
  console.log(` Hook installed: .claude/hooks/${SKILL_FLAGS_FILENAME}`);
174
+ console.log(" Hook installed: .claude/hooks/edit-check.sh (baselines: .line-limit-baseline, .lint-baseline)");
143
175
  console.log(` Settings updated: .claude/settings.json`);
144
176
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.51.3",
3
+ "version": "2.52.0",
4
4
  "description": "Guardrails for Claude Code — best-practice skills, planning commands, and delivery gates.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -11,7 +11,7 @@
11
11
  "claude-skills": "bin/cli.mjs"
12
12
  },
13
13
  "scripts": {
14
- "prepack": "cp ../README.md README.md && mkdir -p hooks && cp ../scripts/skill-gate.sh ../scripts/skill-gate-automark.sh ../scripts/skill-application-gate.sh ../scripts/gauntlet.sh ../scripts/ship-gate.sh ../scripts/ship-gate-projects.sh ../scripts/ship-gate-structure.sh ../scripts/ship-gate-files.sh ../scripts/ship-gate-hook.sh ../scripts/version-check.sh ../scripts/skill-flags-record.sh ../scripts/skill-report.mjs hooks/",
14
+ "prepack": "cp ../README.md README.md && mkdir -p hooks && cp ../scripts/skill-gate.sh ../scripts/skill-gate-automark.sh ../scripts/skill-application-gate.sh ../scripts/gauntlet.sh ../scripts/ship-gate.sh ../scripts/gate-config.sh ../scripts/ship-gate-projects.sh ../scripts/ship-gate-structure.sh ../scripts/ship-gate-files.sh ../scripts/ship-gate-hook.sh ../scripts/version-check.sh ../scripts/skill-flags-record.sh ../scripts/skill-report.mjs ../scripts/edit-check.sh ../scripts/line-limit.sh ../scripts/lint-ratchet.mjs ../scripts/text-rules.mjs ../scripts/ratchet-baseline.mjs hooks/",
15
15
  "postpack": "git checkout -- README.md && rm -rf hooks"
16
16
  },
17
17
  "engines": {