@spardutti/claude-skills 2.31.0 → 2.32.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.
@@ -0,0 +1,506 @@
1
+ #!/usr/bin/env bash
2
+ # ship-gate.sh — the deterministic half of /ship's quality gate.
3
+ #
4
+ # File length and mutation are not judgement calls, so they do not live in prose
5
+ # where a model can decide they are not worth it. They live here, behind an exit
6
+ # code. /ship runs this and obeys the result; the skills audit stays in the
7
+ # command, because that one genuinely needs a model.
8
+ #
9
+ # bash .claude/hooks/ship-gate.sh [base-ref]
10
+ # bash .claude/hooks/ship-gate.sh --key print the receipt key and stop
11
+ # bash .claude/hooks/ship-gate.sh --force write a FORCED receipt, run nothing
12
+ # bash .claude/hooks/ship-gate.sh --baseline record today's mutmut survivors as
13
+ # accepted debt, write no receipt
14
+ #
15
+ # On completion it writes a RECEIPT at /tmp/claude-shipgate-<key>, and the
16
+ # PreToolUse hook refuses `git commit` / `git push` without a matching one. The
17
+ # point is that a gate has to be evidence rather than an instruction: a sentence
18
+ # saying "run this first" is a sentence an agent can decide is not worth it, and
19
+ # then report a pass it never earned. A receipt cannot be reported, only produced.
20
+ #
21
+ # The key is the content of everything about to ship, so it is the same before
22
+ # and after `git commit` — and it changes the moment anything is edited, which is
23
+ # what makes a fix re-gate itself instead of riding on the previous verdict.
24
+ #
25
+ # Exit codes:
26
+ # 0 clean — nothing over the line limit, nothing survived, nothing uncovered
27
+ # 1 findings that must be dealt with before shipping
28
+ # 2 ran, but could not prove the tests (a mutation tool is missing) — report,
29
+ # don't block; the output names exactly what to install and where
30
+ #
31
+ # Config: the same .claude/gauntlet.conf as the Stop hook.
32
+ # GAUNTLET_MAX_LINES=200 the per-file limit; 0 turns the check off
33
+ # GAUNTLET_SOURCE_EXT=... which extensions count as source. Deliberately NOT
34
+ # GAUNTLET_CODE_EXT: the Stop hook's key answers a
35
+ # different question, and a docs repo sets it to .md
36
+ # GAUNTLET_IGNORE_EXT=... extensions that need no gate at all. An extension in
37
+ # neither list is reported UNPROVEN rather than passed
38
+ # GAUNTLET_IGNORE_FILES=.. space-separated globs, matched against /<path>, for
39
+ # files nobody wrote: generated code and vendored
40
+ # components. They are named in the output, never
41
+ # dropped silently. Set it to "" to gate everything
42
+ # GAUNTLET_MUTATE="cmd" one explicit mutation command for the whole repo,
43
+ # replacing per-project detection. $MUTATE_FLAGS holds
44
+ # the --mutate flags, $FILES the changed files.
45
+
46
+ set -uo pipefail
47
+
48
+ MODE=""
49
+ case "${1:-}" in
50
+ --key) MODE=key; shift ;;
51
+ --force) MODE=force; shift ;;
52
+ --baseline) MODE=baseline; shift ;;
53
+ esac
54
+
55
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
56
+ cd "$PROJECT_DIR" 2>/dev/null || { echo "ship-gate: cannot read $PROJECT_DIR"; exit 1; }
57
+ git rev-parse --git-dir >/dev/null 2>&1 || { echo "ship-gate: not a git repo"; exit 1; }
58
+
59
+ # `git diff --name-only` prints paths from the repo ROOT, but CLAUDE_PROJECT_DIR
60
+ # can point at a subdirectory of it. Testing those paths from the subdirectory
61
+ # found no files at all, so the gate said "nothing to check" and wrote a PASS
62
+ # receipt over a diff it never looked at. Work from the root and the paths git
63
+ # prints are the paths this script tests. The receipt key moves with it, and the
64
+ # PreToolUse hook asks this script for the key rather than computing its own, so
65
+ # the two cannot disagree.
66
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
67
+ if [ -n "$ROOT" ]; then
68
+ cd "$ROOT" || { echo "ship-gate: cannot read $ROOT"; exit 1; }
69
+ PROJECT_DIR="$ROOT"
70
+ fi
71
+
72
+ GAUNTLET_MAX_LINES=200
73
+ GAUNTLET_MUTATE=""
74
+ GAUNTLET_SOURCE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|gd"
75
+ GAUNTLET_IGNORE_EXT="md|mdx|txt|rst|adoc|json|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"
76
+ GAUNTLET_IGNORE_FILES="*.gen.ts *.gen.tsx *.generated.* */migrations/*.py */alembic/versions/*.py */components/ui/*.tsx */*.config.*"
77
+ # Length-checked and skill-audited like everything else, but not mutated.
78
+ # Mutation pays on logic and burns time on presentation: a component's mutants
79
+ # are class names, copy and JSX shape, none of which is behaviour, and one
80
+ # session spent an evening killing them. Logic that lives inside a component is
81
+ # logic that wants extracting — which the 200-line rule already pushes toward.
82
+ # Override in .claude/gauntlet.conf to mutate components too.
83
+ GAUNTLET_NO_MUTATE='\.(tsx|jsx)$'
84
+ [ -n "${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ] && . "$HOME/.claude/gauntlet.conf"
85
+ [ -f ".claude/gauntlet.conf" ] && . ".claude/gauntlet.conf"
86
+
87
+ # ------------------------------------------------------------------- the scope
88
+ BASE="${1:-}"
89
+ if [ -z "$BASE" ]; then
90
+ BASE=$(git merge-base HEAD develop 2>/dev/null \
91
+ || git merge-base HEAD main 2>/dev/null \
92
+ || git rev-list --max-parents=0 HEAD | head -1)
93
+ fi
94
+
95
+ CHANGED=$( { git diff "$BASE"...HEAD --name-only 2>/dev/null
96
+ git diff HEAD --name-only 2>/dev/null
97
+ git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )
98
+
99
+ # Only the files this gate can actually read go into the key. It used to be every
100
+ # changed file, so a version bump in package.json or a line in a changelog threw
101
+ # away a receipt the gate would have re-earned unchanged — fifteen minutes of
102
+ # mutation testing to prove nothing about the file that moved, at the one moment
103
+ # a release is trying to ship. An ignored extension is one no check here opens,
104
+ # so it cannot change a verdict. Unknown extensions stay in, because UNPROVEN is
105
+ # a verdict about exactly those.
106
+ KEYED=$(printf '%s\n' "$CHANGED" | grep -viE "\.($GAUNTLET_IGNORE_EXT)$")
107
+
108
+ # The repo path plus the names and contents of the changed files, in a fixed
109
+ # order. The path is in there because the key is otherwise pure content: two
110
+ # repos holding the same file would share a receipt, and one would be waved
111
+ # through on the other's verdict. Deliberately not a diff: a file moves from "untracked" to "in the diff" at commit time, so a
112
+ # diff-based key changes when the content did not, and the receipt would go stale
113
+ # the moment it was committed. This is identical either side of a commit, and
114
+ # changes the instant any of those files is edited.
115
+ RECEIPT_KEY=$( { printf '%s\n' "$PROJECT_DIR"
116
+ printf '%s\n' "$KEYED" | while IFS= read -r f; do
117
+ [ -n "$f" ] || continue
118
+ printf '%s\n' "$f"
119
+ [ -f "$f" ] && cat "$f"
120
+ done; } | git hash-object --stdin )
121
+ RECEIPT="/tmp/claude-shipgate-$RECEIPT_KEY"
122
+
123
+ if [ "$MODE" = key ]; then printf '%s\n' "$RECEIPT_KEY"; exit 0; fi
124
+ if [ "$MODE" = force ]; then
125
+ printf 'FORCED %s\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
126
+ echo "ship-gate: FORCED — receipt written without running any check."
127
+ exit 0
128
+ fi
129
+ SOURCED=$(printf '%s\n' "$CHANGED" | grep -E "\.($GAUNTLET_SOURCE_EXT)$" \
130
+ | grep -vE '(\.|_)(test|spec)\.[^.]+$|(^|/)tests?/|(^|/)\.stryker-tmp/' | while read -r f; do
131
+ [ -f "$f" ] && printf '%s\n' "$f"
132
+ done)
133
+
134
+ # A generated file cannot be split and a vendored one is not ours to split, so
135
+ # the length check fires on every route regeneration and teaches people to
136
+ # --force. A gate that is always forced is not a gate. These are held out of
137
+ # both checks — and printed, because a silent skip is the same lie as a fake
138
+ # PASS. The leading / is what lets one pattern match components/ui at the repo
139
+ # root and apps/web/src/components/ui alike.
140
+ #
141
+ # */*.config.* is here for the same reason, and became urgent the moment an
142
+ # uncovered line started failing the gate: vitest.config.ts has no tests, will
143
+ # never have tests, and is not a thing anyone writes a test for. Left in, every
144
+ # diff that touches a config reports findings nobody can close.
145
+ # set -f matters: an unquoted list is glob-expanded before it is split, so
146
+ # */components/ui/*.tsx turned itself into the very path it was meant to match
147
+ # and then matched nothing.
148
+ is_ignored() {
149
+ set -f
150
+ for pat in $GAUNTLET_IGNORE_FILES; do
151
+ case "/$1" in $pat) set +f; return 0 ;; esac
152
+ done
153
+ set +f
154
+ return 1
155
+ }
156
+ FILES=$(printf '%s\n' "$SOURCED" | while IFS= read -r f; do
157
+ [ -n "$f" ] && ! is_ignored "$f" && printf '%s\n' "$f"
158
+ done)
159
+ IGNORED=$(printf '%s\n' "$SOURCED" | while IFS= read -r f; do
160
+ [ -n "$f" ] && is_ignored "$f" && printf '%s\n' "$f"
161
+ done)
162
+ if [ -n "$IGNORED" ]; then
163
+ echo "ship-gate: not gated, matched GAUNTLET_IGNORE_FILES:"
164
+ printf '%s\n' "$IGNORED" | sed 's/^/ /'
165
+ fi
166
+
167
+ if [ -z "$FILES" ]; then
168
+ # "I recognised nothing" is not "there is nothing", and the gate used to report
169
+ # both as a PASS. A Godot repo changed only .gd files, which no extension in
170
+ # the list named, so zero source files were found and a PASS receipt was
171
+ # written over work nothing had checked. An extension this gate cannot place is
172
+ # UNPROVEN: it does not block, but it never claims a pass either.
173
+ UNKNOWN=$(printf '%s\n' "$CHANGED" | grep -E '\.[A-Za-z0-9]+$' \
174
+ | grep -viE "\.($GAUNTLET_SOURCE_EXT)$" \
175
+ | grep -viE "\.($GAUNTLET_IGNORE_EXT)$" \
176
+ | sed 's/.*\.//' | sort -u | tr '\n' ' ')
177
+ if [ -n "$UNKNOWN" ]; then
178
+ echo "ship-gate: UNPROVEN — nothing here was checked. These changed files are"
179
+ echo " in a language this gate does not know: $UNKNOWN"
180
+ echo " If they are source, add the extension in .claude/gauntlet.conf:"
181
+ echo " GAUNTLET_SOURCE_EXT=\"\$GAUNTLET_SOURCE_EXT|${UNKNOWN%% *}\""
182
+ echo " If they are not, add it to GAUNTLET_IGNORE_EXT the same way."
183
+ printf 'UNPROVEN %s unknown-extensions\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
184
+ exit 2
185
+ fi
186
+ echo "ship-gate: no changed code files — nothing to check"
187
+ printf 'PASS %s no-code-changes\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
188
+ exit 0
189
+ fi
190
+
191
+ echo "ship-gate: $(printf '%s\n' "$FILES" | wc -l) changed code file(s), base $(git rev-parse --short "$BASE")"
192
+ echo
193
+ STATUS=0
194
+
195
+ # ------------------------------------------------- check 1: file length (hard)
196
+ OVER=""
197
+ LARGEST=0
198
+ if [ "$GAUNTLET_MAX_LINES" -gt 0 ]; then
199
+ while IFS= read -r f; do
200
+ n=$(wc -l < "$f")
201
+ [ "$n" -gt "$LARGEST" ] && LARGEST=$n
202
+ [ "$n" -gt "$GAUNTLET_MAX_LINES" ] && OVER="$OVER $f — $n lines (limit $GAUNTLET_MAX_LINES)
203
+ "
204
+ done <<< "$FILES"
205
+ fi
206
+
207
+ if [ "$GAUNTLET_MAX_LINES" -le 0 ]; then
208
+ echo "FILE LENGTH — off (GAUNTLET_MAX_LINES=0)"
209
+ elif [ -n "$OVER" ]; then
210
+ echo "FILE LENGTH — over the limit:"
211
+ printf '%s' "$OVER"
212
+ echo " Splitting a file is a design decision. Do it deliberately, or ship with --force."
213
+ STATUS=1
214
+ else
215
+ echo "FILE LENGTH — ok, largest is $LARGEST lines"
216
+ fi
217
+
218
+ # ---------------------------------------------------------- which project owns
219
+ # A monorepo holds several projects, each with its own runner and its own
220
+ # mutation tool. Walk up from each changed file to the nearest manifest.
221
+ # mutmut is installed into the project's environment, not onto PATH. Ask the
222
+ # project how to run its own tools before falling back to a bare binary.
223
+ py_mutmut() {
224
+ d="$1"
225
+ # Returned relative to the PROJECT, because the command runs from inside it.
226
+ # Tests that need a database, a queue, or any other service cannot run on the
227
+ # host: mutmut still generates every mutant and reports them all "not
228
+ # checked", which prints exactly like a clean run. An executable .mutmut-run
229
+ # in the project runs mutmut wherever those services are — typically
230
+ # `docker compose run` against the app container — and takes precedence over
231
+ # every host-local option below.
232
+ if [ -x "$d.mutmut-run" ]; then printf './.mutmut-run\n'; return; fi
233
+ if [ -x "$d.venv/bin/mutmut" ]; then printf './.venv/bin/mutmut\n'; return; fi
234
+ if [ -f "$d""uv.lock" ] && command -v uv >/dev/null 2>&1; then printf 'uv run mutmut\n'; return; fi
235
+ if [ -f "$d""poetry.lock" ] && command -v poetry >/dev/null 2>&1; then printf 'poetry run mutmut\n'; return; fi
236
+ command -v mutmut >/dev/null 2>&1 && printf 'mutmut\n'
237
+ }
238
+
239
+ owner_of() {
240
+ d=$(dirname "$1")
241
+ while :; do
242
+ if [ -f "$d/package.json" ] || [ -f "$d/pyproject.toml" ] \
243
+ || [ -f "$d/pytest.ini" ] || [ -f "$d/setup.cfg" ]; then
244
+ printf '%s\n' "${d#./}"; return
245
+ fi
246
+ [ "$d" = "." ] || [ "$d" = "/" ] && { printf '.\n'; return; }
247
+ d=$(dirname "$d")
248
+ done
249
+ }
250
+
251
+ OWNERS=$(while IFS= read -r f; do owner_of "$f"; done <<< "$FILES" | sort -u)
252
+
253
+ # --------------------------------------------- check 2: mutation, per project
254
+ echo
255
+ echo "MUTATION — $(printf '%s\n' "$OWNERS" | wc -l) project(s) in this diff"
256
+ MISSING=""
257
+
258
+ for owner in $OWNERS; do
259
+ [ "$owner" = "." ] && label="<repo root>" || label="$owner/"
260
+ OWNED=$(while IFS= read -r f; do
261
+ [ "$(owner_of "$f")" = "$owner" ] && printf '%s\n' "$f"
262
+ done <<< "$FILES" | grep -vE "$GAUNTLET_NO_MUTATE" || true)
263
+ [ -z "$OWNED" ] && continue
264
+
265
+ # ONE comma-joined --mutate value, never a flag per range. Stryker's CLI
266
+ # parses --mutate with `(val) => val.split(",")`, a coercion that ignores the
267
+ # previous value, so a repeated flag OVERRIDES instead of appending and only
268
+ # the last one survives. Measured on two files: the repeated form found 1 of
269
+ # 1808 files and 28 mutants, the joined form 2 files and 51. Every run since
270
+ # this gate shipped mutated exactly one file and called the rest proven — and
271
+ # when that one file had no tests, Stryker exited on "No tests were executed"
272
+ # and the gate reported UNPROVEN, which writes a receipt and blocks nothing.
273
+ FLAGS=""
274
+ GLOBBED=","
275
+ while IFS= read -r f; do
276
+ hunks=$(git diff -U0 "$BASE" -- "$f" 2>/dev/null \
277
+ | grep -oE '^@@ -[0-9,]+ \+[0-9]+(,[0-9]+)?' | sed 's/.*+//' \
278
+ | while IFS=, read -r s l; do l=${l:-1}; [ "$l" -gt 0 ] && echo "$s-$((s+l-1))"; done)
279
+ [ -z "$hunks" ] && hunks="1-$(wc -l < "$f")"
280
+ rel=${f#$owner/}
281
+ # A Next.js route folder — [id], [[...slug]] — is a glob expression to
282
+ # Stryker, which refuses "Cannot combine a glob expression with a mutation
283
+ # range" and fails the entire run, not just that file. Escaping the brackets
284
+ # stops the path resolving at all. Replacing each with ? keeps it matching
285
+ # that one file and drops the range, so the file is mutated whole: a
286
+ # superset of the diff, slower than a range but never less than what changed.
287
+ case "$rel" in
288
+ *[][*?{}]*) FLAGS="$FLAGS,$(printf '%s' "$rel" | sed 's/[][*?{}]/?/g')"
289
+ GLOBBED="$GLOBBED$rel," ;;
290
+ *) for r in $hunks; do FLAGS="$FLAGS,$rel:$r"; done ;;
291
+ esac
292
+ done <<< "$OWNED"
293
+
294
+ base=""; RUN=""
295
+ if [ "$owner" != "." ]; then base="$owner/"; RUN="cd '$owner' && "; fi
296
+ if [ -n "$GAUNTLET_MUTATE" ]; then
297
+ CMD="$GAUNTLET_MUTATE"; TOOL="config"
298
+ elif [ -f "$base"package.json ] && grep -qs '@stryker-mutator/core' "$base"package.json; then
299
+ TOOL="stryker"
300
+ # From inside the owning package: npx resolves against ITS node_modules, and
301
+ # stryker.config.json lives there too. A monorepo root usually has neither.
302
+ #
303
+ # Deliberately NOT --incremental. --force does rerun every mutant in scope,
304
+ # so the diff itself was always judged fresh — but incremental mode still
305
+ # merges reports/stryker-incremental.json into the REPORT for everything
306
+ # out of scope, and this gate greps the whole report. Survivors recorded
307
+ # months ago came back as findings against a diff that never touched them:
308
+ # 8 of them on one commit, all long since killed. --mutate already scopes
309
+ # the run to the changed hunks, so incremental buys nothing here and costs
310
+ # a cache that goes stale exactly the way mutmut's did.
311
+ CMD="${RUN}npx --no-install stryker run --mutate '${FLAGS#,}'"
312
+ elif [ -f "$base"package.json ]; then
313
+ MISSING="$MISSING $label needs Stryker:
314
+ npm --prefix ${owner} i -D @stryker-mutator/core @stryker-mutator/vitest-runner
315
+ then ${base}stryker.config.json:
316
+ {\"testRunner\":\"vitest\",\"plugins\":[\"@stryker-mutator/vitest-runner\"],\"coverageAnalysis\":\"perTest\"}
317
+ "
318
+ continue
319
+ elif [ -n "$(py_mutmut "$base")" ]; then
320
+ TOOL="mutmut"
321
+ # mutmut takes its scope from [tool.mutmut] in pyproject, not from a flag:
322
+ # --paths-to-mutate was 2.x, and 3.x filters by fnmatch globs over mutant
323
+ # NAMES (app.balance.reserve.*). There is no per-line scoping at all.
324
+ #
325
+ # `mutmut run` prints 🙁 for a survivor and exits 0 either way — parsing it
326
+ # reports clean with survivors sitting there, which is a false green and
327
+ # worse than reporting nothing. `mutmut results` is the readable source:
328
+ # it prints "<mutant name>: survived" per survivor.
329
+ M="$(py_mutmut "$base")"
330
+ # mutmut caches verdicts in ./mutants and only invalidates one when the
331
+ # SOURCE of that function changes. Adding a test does not, so it replays the
332
+ # old "survived" and calls mutants the new tests kill still alive — measured
333
+ # at 20 of them on one commit. `results` then prints that whole cache, so
334
+ # survivors turn up for files nowhere near the diff. Deleting the dir first
335
+ # makes both describe the tree being shipped; `mutmut run` rewrites it
336
+ # anyway, and a cold run is ~35s for ~1000 mutants.
337
+ # One cd for all three: they run in the same shell, already there.
338
+ CMD="${RUN}rm -rf mutants; $M run >/dev/null 2>&1; $M results"
339
+ else
340
+ MISSING="$MISSING $label needs mutmut:
341
+ uv add --dev mutmut # or: poetry add --group dev mutmut, pip install mutmut
342
+ then ${base}pyproject.toml:
343
+ [tool.mutmut]
344
+ source_paths = [\"src/\"]
345
+ pytest_add_cli_args_test_selection = [\"tests/\"]
346
+ (mutmut 3 renamed these — paths_to_mutate/tests_dir are 2.x and are ignored)
347
+ "
348
+ continue
349
+ fi
350
+
351
+ OUT=$(eval "$CMD" 2>&1); RC=$?
352
+ # Match a finding, never a summary row. Stryker prints a `# survived` COLUMN
353
+ # HEADER every run and repeats the word in its table, so a bare grep reports
354
+ # survivors on a clean run — worse than not running at all. Stryker marks each
355
+ # real finding `[Survived]`; table and border lines are excluded outright.
356
+ # NoCoverage is printed exactly like Survived and means something worse: not
357
+ # "a test missed this mutation" but "no test executes this line at all". The
358
+ # gate matched only [Survived], so a diff whose files have no tests came back
359
+ # PASS — 306 uncovered mutants reported as proven on one commit. It is judged
360
+ # only for files that went in WITH a range: a glob-shaped path is mutated
361
+ # whole, so its untouched lines would report uncovered forever, and a finding
362
+ # nobody can close is what teaches people to --force.
363
+ #
364
+ # The status line carries no file name; the line under it does, as
365
+ # file:line:column. They are paired here rather than grepped separately.
366
+ if [ "$TOOL" = stryker ]; then
367
+ ALL=$(printf '%s\n' "$OUT" | awk -v globbed="$GLOBBED" '
368
+ /^\[Survived\]/ { pend = $0; kind = "s"; next }
369
+ /^\[NoCoverage\]/ { pend = $0; kind = "n"; next }
370
+ pend != "" {
371
+ file = $0; sub(/:[0-9]+:[0-9]+$/, "", file)
372
+ if (kind == "s" || index(globbed, "," file ",") == 0) print pend " " $0
373
+ pend = ""
374
+ }
375
+ # A survivor with no location under it is still a survivor, never dropped.
376
+ END { if (pend != "" && kind == "s") print pend }
377
+ ')
378
+ else
379
+ ALL=$(printf '%s\n' "$OUT" | grep -E '[Ss]urvived' \
380
+ | grep -vE '^[[:space:]]*[#│|+-]|# *surviv')
381
+ fi
382
+ # The detail list is capped so one bad file cannot bury the output. The cap
383
+ # used to be the whole story, and it hid the WORST file: 20 findings from one
384
+ # file filled it, and a second file with 54 uncovered mutants never appeared
385
+ # at all. A gate whose biggest finding is invisible gets under-fixed. Every
386
+ # file with findings is now named below the cut, worst first.
387
+ FOUND=$(printf '%s\n' "$ALL" | grep -c .)
388
+ SURVIVED=$(printf '%s\n' "$ALL" | head -20)
389
+ FIXED=0
390
+ NOTE=""
391
+
392
+ # Stryker takes --mutate per changed hunk, so it already answers "did YOUR
393
+ # change get tested". mutmut cannot be asked that — 3.x dropped 1.x's
394
+ # --use-patch-file and never replaced it — so it mutates all of source_paths
395
+ # and reports the repo's entire backlog. Held to the same bar, the Python half
396
+ # answers "is this repo perfect" instead, which it never is, so it could never
397
+ # go green. The baseline supplies the missing half: survivors recorded once
398
+ # are accepted debt, and only a name that is not in it fails the gate.
399
+ #
400
+ # A mutant name carries an index within its own function, not a line number,
401
+ # so editing elsewhere in the file does not rename it — and editing the
402
+ # function itself DOES, which correctly re-charges its survivors to that edit.
403
+ if [ "$TOOL" = mutmut ]; then
404
+ BL="$base.mutmut-baseline"
405
+ NOWF=$(mktemp)
406
+ printf '%s\n' "$OUT" \
407
+ | sed -n 's/^[[:space:]]*\([^[:space:]]*\): survived$/\1/p' | sort -u > "$NOWF"
408
+
409
+ # When the tests need a service the run cannot reach — a database, a queue
410
+ # — mutmut still generates every mutant and records it "not checked". No
411
+ # survivors come out, so the gate printed "nothing survived" and, with no
412
+ # baseline yet, wrote an EMPTY one and told the user to commit it. That
413
+ # repo's Python gate could never fail again.
414
+ #
415
+ # Counting kills cannot catch it: `mutmut results` SKIPS killed mutants
416
+ # unless --all (mutmut/__main__.py results()), so a perfect suite and a
417
+ # suite that ran nothing both print nothing. The status string is what
418
+ # separates them — "not checked" is exit code None in status_by_exit_code,
419
+ # meaning the mutant was never executed at all.
420
+ NOTCHECKED=$(printf '%s\n' "$OUT" | grep -c ': not checked$')
421
+ if [ "$NOTCHECKED" -gt 0 ]; then
422
+ echo " $label mutmut — UNPROVEN: $NOTCHECKED mutant(s) were never run."
423
+ echo " They are recorded \"not checked\", so the suite proved nothing"
424
+ echo " about them. Usually the tests need a service this run cannot"
425
+ echo " reach — a database, a queue. Put an executable .mutmut-run in"
426
+ echo " ${base}that runs mutmut where those services are, typically"
427
+ echo " docker compose run against the app container; the gate uses"
428
+ echo " it ahead of every host-local option."
429
+ [ "$STATUS" = 0 ] && STATUS=2
430
+ rm -f "$NOWF"
431
+ continue
432
+ fi
433
+
434
+ if [ "$MODE" = baseline ]; then
435
+ cp "$NOWF" "$BL"
436
+ NOTE=" $label mutmut — baseline set: $(wc -l < "$BL") survivor(s) accepted. Commit $BL."
437
+ SURVIVED=""
438
+ elif [ -f "$BL" ]; then
439
+ # -Fxv against an empty baseline reports every survivor, which is right:
440
+ # an empty baseline means nothing has been accepted.
441
+ SURVIVED=$(grep -Fxv -f "$BL" "$NOWF" | head -20)
442
+ FIXED=$(grep -Fxvc -f "$NOWF" "$BL")
443
+ else
444
+ cp "$NOWF" "$BL"
445
+ NOTE=" $label mutmut — no baseline, so nothing could be compared. Recorded
446
+ $(wc -l < "$BL") existing survivor(s) in $BL — commit it, then run the
447
+ gate again. From here only NEW survivors fail; --baseline accepts more."
448
+ SURVIVED=""
449
+ [ "$STATUS" = 0 ] && STATUS=2
450
+ fi
451
+ rm -f "$NOWF"
452
+ fi
453
+
454
+ if [ -n "$SURVIVED" ]; then
455
+ echo " $label $TOOL — these lines can break and no test notices:"
456
+ printf '%s\n' "$SURVIVED" | sed 's/^/ /'
457
+ if [ "$FOUND" -gt 20 ]; then
458
+ echo " ... $((FOUND - 20)) more not shown. Every file with findings:"
459
+ if [ "$TOOL" = stryker ]; then
460
+ printf '%s\n' "$ALL" \
461
+ | awk '{ f = $NF; sub(/:[0-9]+:[0-9]+$/, "", f); n[f]++ }
462
+ END { for (k in n) printf "%8d %s\n", n[k], k }' \
463
+ | sort -rn | sed 's/^/ /'
464
+ fi
465
+ fi
466
+ [ "$FIXED" -gt 0 ] && echo " ($FIXED baselined survivor(s) now killed — --baseline banks them)"
467
+ STATUS=1
468
+ elif [ $RC -ne 0 ]; then
469
+ echo " $label $TOOL — the run failed:"
470
+ printf '%s\n' "$OUT" | tail -12 | sed 's/^/ /'
471
+ [ "$STATUS" = 0 ] && STATUS=2
472
+ elif [ -n "$NOTE" ]; then
473
+ printf '%s\n' "$NOTE"
474
+ else
475
+ echo " $label $TOOL — ok, nothing survived and nothing was uncovered"
476
+ [ "$FIXED" -gt 0 ] && echo " ($FIXED baselined survivor(s) now killed — --baseline banks them)"
477
+ fi
478
+ done
479
+
480
+ if [ -n "$MISSING" ]; then
481
+ echo " UNPROVEN — no mutation tool for these, so their tests were never shown"
482
+ echo " to catch a break. Install per project:"
483
+ printf '%s' "$MISSING"
484
+ [ "$STATUS" = 0 ] && STATUS=2
485
+ fi
486
+
487
+ if [ "$MODE" = baseline ]; then
488
+ echo
489
+ echo "ship-gate: baselines updated. No receipt written — run the gate for real."
490
+ exit 0
491
+ fi
492
+
493
+ # The receipt is written by this script and nothing else. A hand-rolled check
494
+ # produces no receipt, which is the whole point: substituting a weaker check is
495
+ # a choice that can be argued for, but it cannot be passed off as this one.
496
+ echo
497
+ case $STATUS in
498
+ 0) echo "ship-gate: PASS"
499
+ printf 'PASS %s\n' "$(date -u +%FT%TZ)" > "$RECEIPT" ;;
500
+ 1) echo "ship-gate: FAIL — deal with the findings above, then run this again."
501
+ echo " To ship anyway: bash .claude/hooks/ship-gate.sh --force"
502
+ rm -f "$RECEIPT" ;;
503
+ 2) echo "ship-gate: UNPROVEN — nothing is wrong, but nothing was proven either"
504
+ printf 'UNPROVEN %s\n' "$(date -u +%FT%TZ)" > "$RECEIPT" ;;
505
+ esac
506
+ exit $STATUS
@@ -0,0 +1,162 @@
1
+ #!/bin/bash
2
+ # PreToolUse application gate: blocks file edits until each loaded skill
3
+ # has been explicitly applied (acked) for this session.
4
+
5
+ INPUT=$(cat)
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8
+ PROJECT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
9
+
10
+ SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | sed 's/"session_id":"//; s/"$//')
11
+ if [ -z "$SESSION_ID" ]; then
12
+ exit 0
13
+ fi
14
+
15
+ # Keyed exactly as the loading gate and the automark key it — see those two.
16
+ # A subagent carries the parent's session_id, so all three must agree.
17
+ AGENT_ID=$(printf '%s' "$INPUT" | grep -o '"agent_id":"[^"]*"' | head -1 | sed 's/"agent_id":"//; s/"$//')
18
+ KEY="$SESSION_ID"
19
+ if [ -n "$AGENT_ID" ]; then
20
+ KEY=$(printf '%s' "$AGENT_ID" | tr -cd 'A-Za-z0-9_-')
21
+ fi
22
+
23
+ # Defer to the loading gate until it's been satisfied for this key.
24
+ if [ ! -f "/tmp/claude-skill-gate-$KEY" ]; then
25
+ exit 0
26
+ fi
27
+
28
+ # Same reason as the loading gate: Bash can write files, and a heredoc into an
29
+ # interpreter shows no redirect at all.
30
+
31
+ # Skills are about code. Blocking a plan document to ask how the React rules
32
+ # apply is pure noise, and a repo full of PLAN_*.md hits it on every write.
33
+ # Config files stay gated — skills do have rules about tsconfig and compose.
34
+ PROSE_EXT='md|mdx|txt|rst|adoc|png|jpg|jpeg|gif|svg|webp|ico'
35
+ CODE_EXT='ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|sql|sh|json|ya?ml|toml'
36
+
37
+ TOOL=$(printf '%s' "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | sed 's/.*:"//; s/"$//')
38
+
39
+ if [ "$TOOL" != "Bash" ]; then
40
+ # The structured tools name their target outright.
41
+ TARGET=$(printf '%s' "$INPUT" | grep -o '"file_path":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
42
+ case "$TARGET" in
43
+ # The settings file that configures the escape hatch cannot sit behind the
44
+ # gate, or a denied ack has no way to be un-denied.
45
+ */.claude/settings.json|*/.claude/settings.local.json) exit 0 ;;
46
+ *.*) printf '%s' "$TARGET" | grep -qiE ".($PROSE_EXT)$" && exit 0 ;;
47
+ esac
48
+ fi
49
+ if [ "$TOOL" = "Bash" ]; then
50
+ CMD=$(printf '%s' "$INPUT" | grep -o '"command":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
51
+ case "$CMD" in
52
+ *claude-skill-gate-*|*claude-skill-acked-*|*claude-skill-loaded-*) exit 0 ;;
53
+ *.claude/settings.json*|*.claude/settings.local.json*) exit 0 ;;
54
+ esac
55
+
56
+ # A command that names a prose file and no code file is writing prose.
57
+ if printf '%s' "$CMD" | grep -qiE ".($PROSE_EXT)([^A-Za-z0-9]|$)" && ! printf '%s' "$CMD" | grep -qiE ".($CODE_EXT)([^A-Za-z0-9]|$)"; then
58
+ exit 0
59
+ fi
60
+ STRIPPED=$(printf '%s' "$CMD" | sed 's![12]*>>*[[:space:]]*/dev/null!!g')
61
+ WRITES=""
62
+ case "$STRIPPED" in
63
+ *">"*|*"tee "*|*"sed -i"*|*"cp "*|*"mv "*|*"truncate "*|*"dd "*) WRITES=1 ;;
64
+ esac
65
+ case "$CMD" in
66
+ *"<<"*|*python*" -c"*|*node*" -e"*|*perl*" -e"*|*ruby*" -e"*) WRITES=1 ;;
67
+ esac
68
+ [ -z "$WRITES" ] && exit 0
69
+ fi
70
+
71
+
72
+ # Collect every loaded-but-unacked skill so a single ack clears them all.
73
+ UNACKED=""
74
+ for marker in /tmp/claude-skill-loaded-$KEY-*; do
75
+ [ ! -f "$marker" ] && continue
76
+ skill_name="${marker##/tmp/claude-skill-loaded-$KEY-}"
77
+ if [ ! -f "/tmp/claude-skill-acked-$KEY-$skill_name" ]; then
78
+ UNACKED="$UNACKED $skill_name"
79
+ fi
80
+ done
81
+ UNACKED="${UNACKED# }"
82
+
83
+ if [ -z "$UNACKED" ]; then
84
+ exit 0
85
+ fi
86
+
87
+ # One touch call with explicit paths — brace expansion is not used because
88
+ # bash leaves a single-element {name} literal, creating a garbage marker.
89
+ ACK_CMD="touch"
90
+ NAMES=""
91
+ RULES_BLOCKS=""
92
+ for skill in $UNACKED; do
93
+ ACK_CMD="$ACK_CMD /tmp/claude-skill-acked-$KEY-$skill"
94
+ NAMES="$NAMES, '$skill'"
95
+ SKILL_MD="$PROJECT_DIR/.claude/skills/$skill/SKILL.md"
96
+ RULES=""
97
+ if [ -f "$SKILL_MD" ]; then
98
+ RULES=$(awk '/^## Rules/{flag=1} /^## /{if(flag && !/^## Rules/)exit} flag' "$SKILL_MD")
99
+ fi
100
+ if [ -z "$RULES" ]; then
101
+ RULES="(Rules section not found in $SKILL_MD — refer to the loaded skill content already in context.)"
102
+ fi
103
+ RULES_BLOCKS="$RULES_BLOCKS
104
+ ### Rules from $skill/SKILL.md
105
+
106
+ $RULES
107
+ "
108
+ done
109
+ NAMES="${NAMES#, }"
110
+
111
+ MSG="BLOCKED: loaded skills not yet applied to your work: $NAMES
112
+
113
+ Before this Write/Edit, you must:
114
+
115
+ 1. For each skill listed above, say whether it applies to the file you're about to
116
+ write. If it does not — a Python skill and a TypeScript file, say — write
117
+ \"does not apply\" and move on. Do not invent a connection.
118
+ 2. For the ones that do apply: state the specific rules that bear on this file, and
119
+ how your next write respects each.
120
+ 3. Then ack all of them in a single Bash tool call:
121
+ $ACK_CMD
122
+
123
+ One ack per skill per session. After acking, retry the Write/Edit.
124
+
125
+ If that Bash call is denied by the auto mode classifier, STOP. Do not ask your
126
+ user to run it for you, do not look for another route to create the files, and
127
+ do not try to add the allow rule yourself — an agent editing permission settings
128
+ is denied by the same classifier, so that attempt fails too and costs a round
129
+ trip that ends in the same place. All three are what the classifier is there to
130
+ stop, and asking a human to run a command you were just denied makes them the
131
+ tool that performs it. Report this to them instead and let them decide:
132
+
133
+ Auto mode's built-in Auto-Mode Bypass rule counts a hook's ack file as working
134
+ around the permission system. It is a soft rule, so an allow entry clears it.
135
+ The user has to add it — you cannot. The shortest route is for them to re-run
136
+ npx @spardutti/claude-skills and accept the auto mode prompt, which writes it.
137
+ Otherwise /permissions, Auto mode tab. Editing the file by hand works too, and
138
+ it has to be ~/.claude/settings.json — the classifier does not read a
139
+ project's .claude/settings.json. Keep the
140
+ \$defaults line or every built-in allow rule is discarded:
141
+
142
+ \"autoMode\": { \"allow\": [ \"\$defaults\",
143
+ \"Touching marker files under /tmp/claude-skill-gate-*, /tmp/claude-skill-acked-*
144
+ and /tmp/claude-skill-loaded-* is allowed: they are acknowledgement files for
145
+ the user's own PreToolUse skill gates. Creating one satisfies a workflow gate
146
+ the user installed, grants no permission and executes no code, and is a false
147
+ positive for Auto-Mode Bypass.\" ] }
148
+ $RULES_BLOCKS"
149
+
150
+ json_escape() {
151
+ local s="$1"
152
+ s="${s//\\/\\\\}"
153
+ s="${s//\"/\\\"}"
154
+ s="${s//$'\n'/\\n}"
155
+ s="${s//$'\r'/\\r}"
156
+ s="${s//$'\t'/\\t}"
157
+ printf '"%s"' "$s"
158
+ }
159
+
160
+ REASON=$(json_escape "$MSG")
161
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$REASON"
162
+ exit 0