@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.
- package/hooks/gauntlet.sh +328 -0
- package/hooks/ship-gate-hook.sh +88 -0
- package/hooks/ship-gate.sh +506 -0
- package/hooks/skill-application-gate.sh +162 -0
- package/hooks/skill-gate-automark.sh +32 -0
- package/hooks/skill-gate.sh +192 -0
- package/hooks/version-check.sh +88 -0
- package/lib/setup-hook.mjs +48 -1461
- package/package.json +5 -4
|
@@ -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
|