@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,328 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# gauntlet.sh — Stop hook: fast deterministic gates on the changed diff.
|
|
3
|
+
#
|
|
4
|
+
# Runs when Claude finishes a turn. Silent when green. On a red gate it exits 2:
|
|
5
|
+
# the turn keeps going, the user is shown why, and Claude is handed the failure.
|
|
6
|
+
#
|
|
7
|
+
# Two things the hook contract makes non-negotiable:
|
|
8
|
+
# - stop_hook_active must short-circuit, or a red gate loops forever.
|
|
9
|
+
# - A hook that hits its timeout (600s default for command hooks) is cancelled
|
|
10
|
+
# and its output discarded, so the turn ends as if the hook never ran. Keep
|
|
11
|
+
# the gates fast; that is a silent pass, not a block.
|
|
12
|
+
#
|
|
13
|
+
# Only cheap gates belong here (target: under ~30s). Mutation testing, deep
|
|
14
|
+
# review, and the spec checklist are the /gauntlet command's job, not this hook's.
|
|
15
|
+
#
|
|
16
|
+
# Skip ladder, cheapest first — quits at the first "no":
|
|
17
|
+
# 1. no changed files
|
|
18
|
+
# 2. no changed *code* files (docs/config only)
|
|
19
|
+
# 3. this exact diff already passed
|
|
20
|
+
# 4. no gates detected for this repo
|
|
21
|
+
#
|
|
22
|
+
# Config, both optional and both KEY=value files that get sourced in this order:
|
|
23
|
+
# ~/.claude/gauntlet.conf your defaults for every project
|
|
24
|
+
# <project>/.claude/gauntlet.conf overrides for this one; wins key by key
|
|
25
|
+
# GAUNTLET_OFF=1 disable entirely
|
|
26
|
+
# GAUNTLET_TYPECHECK="cmd" explicit typecheck command ("" to skip)
|
|
27
|
+
# GAUNTLET_TEST="cmd" explicit test command; $FILES = changed code files
|
|
28
|
+
# GAUNTLET_CODE_EXT="ts|tsx|py" override which extensions count as code
|
|
29
|
+
# GAUNTLET_DEBUG=1 print the outcome to stderr on every run
|
|
30
|
+
# GAUNTLET_REQUIRE_TESTS=1 block when a runner matched 0 test files
|
|
31
|
+
#
|
|
32
|
+
# Setting either command switches the whole repo to explicit mode — auto-detection
|
|
33
|
+
# is off, and only what you set runs.
|
|
34
|
+
#
|
|
35
|
+
# Per-project runners, for tests that cannot run on the host at all:
|
|
36
|
+
# <project>/.gauntlet-test executable; run instead of the detected test
|
|
37
|
+
# command, with $FILES as its arguments
|
|
38
|
+
# <project>/.gauntlet-typecheck executable; run instead of the detected
|
|
39
|
+
# typecheck command
|
|
40
|
+
#
|
|
41
|
+
# <project> is any directory holding a package.json or pyproject.toml, so one
|
|
42
|
+
# half of a monorepo can run on the host while the other runs in a container.
|
|
43
|
+
# Unlike GAUNTLET_TEST these do not disable detection anywhere else. Typically a
|
|
44
|
+
# two-line `docker compose run --rm -T api pytest "$@"`. Same shape as
|
|
45
|
+
# ship-gate's .mutmut-run.
|
|
46
|
+
#
|
|
47
|
+
# Every run records why it ended in /tmp/claude-gauntlet-<session>.why, so a green
|
|
48
|
+
# run and a skipped one are told apart without re-running the hook.
|
|
49
|
+
|
|
50
|
+
set -uo pipefail
|
|
51
|
+
|
|
52
|
+
INPUT=$(cat 2>/dev/null || true)
|
|
53
|
+
|
|
54
|
+
# Never re-block a turn we already blocked — this would loop forever.
|
|
55
|
+
case "$INPUT" in
|
|
56
|
+
*'"stop_hook_active":true'*) exit 0 ;;
|
|
57
|
+
esac
|
|
58
|
+
|
|
59
|
+
SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 \
|
|
60
|
+
| sed 's/"session_id":"//; s/"$//')
|
|
61
|
+
if [ -z "$SESSION_ID" ]; then SESSION_ID="nosession"; fi
|
|
62
|
+
MARKER="/tmp/claude-gauntlet-$SESSION_ID"
|
|
63
|
+
WHY="$MARKER.why"
|
|
64
|
+
|
|
65
|
+
# Records why this run ended, so "green" and "never ran" stop looking identical.
|
|
66
|
+
quit() {
|
|
67
|
+
printf '%s\n' "$1" > "$WHY" 2>/dev/null
|
|
68
|
+
if [ -n "${GAUNTLET_DEBUG:-}" ]; then printf 'gauntlet: %s\n' "$1" >&2; fi
|
|
69
|
+
exit 0
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-}"
|
|
73
|
+
if [ -z "$PROJECT_DIR" ]; then PROJECT_DIR="$PWD"; fi
|
|
74
|
+
cd "$PROJECT_DIR" 2>/dev/null || quit "skipped: project dir unreadable"
|
|
75
|
+
|
|
76
|
+
git rev-parse --git-dir >/dev/null 2>&1 || quit "skipped: not a git repo"
|
|
77
|
+
|
|
78
|
+
GAUNTLET_OFF=""
|
|
79
|
+
GAUNTLET_TYPECHECK="__auto__"
|
|
80
|
+
GAUNTLET_TEST="__auto__"
|
|
81
|
+
GAUNTLET_CODE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|sql"
|
|
82
|
+
# Global defaults first, then the project's on top — project wins, key by key.
|
|
83
|
+
if [ -n "${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ]; then . "$HOME/.claude/gauntlet.conf"; fi
|
|
84
|
+
if [ -f "$PROJECT_DIR/.claude/gauntlet.conf" ]; then . "$PROJECT_DIR/.claude/gauntlet.conf"; fi
|
|
85
|
+
if [ -n "$GAUNTLET_OFF" ]; then quit "skipped: GAUNTLET_OFF is set"; fi
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------- skip rule 1
|
|
88
|
+
CHANGED=$( { git diff --name-only HEAD 2>/dev/null; \
|
|
89
|
+
git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )
|
|
90
|
+
if [ -z "$CHANGED" ] && [ -z "${GAUNTLET_DRYRUN:-}" ]; then quit "skipped: no changed files"; fi
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------- skip rule 2
|
|
93
|
+
# .stryker-tmp holds Stryker's sandbox — a full copy of the project. A crashed
|
|
94
|
+
# run leaves it behind, and then every file in it looks like a changed file.
|
|
95
|
+
FILES_NL=$(printf '%s\n' "$CHANGED" | grep -E "\.($GAUNTLET_CODE_EXT)$" \
|
|
96
|
+
| grep -vE '(^|/)\.stryker-tmp/' || true)
|
|
97
|
+
if [ -z "$FILES_NL" ] && [ -z "${GAUNTLET_DRYRUN:-}" ]; then
|
|
98
|
+
quit "skipped: no changed code files (docs/config only)"
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------- skip rule 3
|
|
102
|
+
# The repo path is part of the hash: one session can touch several repos, and two
|
|
103
|
+
# of them can legitimately hold an identical diff.
|
|
104
|
+
DIFF_HASH=$( { printf '%s\n' "$PROJECT_DIR"; git diff HEAD 2>/dev/null; printf '%s\n' "$CHANGED"; } \
|
|
105
|
+
| git hash-object --stdin 2>/dev/null )
|
|
106
|
+
if [ -f "$MARKER" ] && [ "$(cat "$MARKER")" = "$DIFF_HASH" ] \
|
|
107
|
+
&& [ -z "${GAUNTLET_DRYRUN:-}" ]; then
|
|
108
|
+
quit "skipped: this diff already passed"
|
|
109
|
+
fi
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------- skip rule 4
|
|
112
|
+
# A monorepo can hold more than one stack, so detection collects every gate it
|
|
113
|
+
# finds rather than stopping at the first. Each gate carries the file pattern it
|
|
114
|
+
# applies to, and is skipped entirely when the diff touched none of those files —
|
|
115
|
+
# so a Python-only change never runs the JS suite, and vice versa.
|
|
116
|
+
TC_GATES=""
|
|
117
|
+
TEST_GATES=""
|
|
118
|
+
SKIP_NOTE=""
|
|
119
|
+
|
|
120
|
+
add_gate() { # kind (typecheck|tests), file regex, command
|
|
121
|
+
LINE=$(printf '%s\t%s\t%s' "$1" "$2" "$3")
|
|
122
|
+
if [ "$1" = "typecheck" ]; then
|
|
123
|
+
TC_GATES="$TC_GATES$LINE
|
|
124
|
+
"
|
|
125
|
+
else
|
|
126
|
+
TEST_GATES="$TEST_GATES$LINE
|
|
127
|
+
"
|
|
128
|
+
fi
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
JS_EXT='(ts|tsx|js|jsx|mjs|cjs)'
|
|
132
|
+
|
|
133
|
+
# Builds the gates for one directory. "" is the repo root; anything else is a
|
|
134
|
+
# nested project, and its gates match only files under that path. Commands run
|
|
135
|
+
# from inside the directory, which is why the file list is passed as absolute
|
|
136
|
+
# paths — a relative path would not survive the cd.
|
|
137
|
+
add_dir_gates() {
|
|
138
|
+
d="$1"
|
|
139
|
+
if [ -n "$d" ]; then pre="^$d/.*\\." ; run="cd $d && " ; else pre='\.' ; run="" ; fi
|
|
140
|
+
have_tc=""; have_test=""
|
|
141
|
+
[ -n "$d" ] && [ -n "${ROOT_TC:-}" ] && have_tc="skip"
|
|
142
|
+
[ -n "$d" ] && [ -n "${ROOT_TEST:-}" ] && have_test="skip"
|
|
143
|
+
|
|
144
|
+
# A project whose tests need a database, a queue, or any other service cannot
|
|
145
|
+
# run them on the host, and detection here finds the host runner every time.
|
|
146
|
+
# An executable runner in the project directory says where they DO run —
|
|
147
|
+
# usually `docker compose run` against the app container — and wins over
|
|
148
|
+
# everything detected below.
|
|
149
|
+
#
|
|
150
|
+
# Per project, not per repo: GAUNTLET_TEST switches the WHOLE repo to explicit
|
|
151
|
+
# mode, so a monorepo needing a container for one stack had to hand-write
|
|
152
|
+
# commands for all of them. These let one half run on the host and the other
|
|
153
|
+
# in a container. Same shape as ship-gate's .mutmut-run, deliberately.
|
|
154
|
+
RUNNER_EXT="($JS_EXT|py)"
|
|
155
|
+
if [ -z "$have_tc" ] && [ -x "$d${d:+/}.gauntlet-typecheck" ]; then
|
|
156
|
+
add_gate typecheck "$pre$RUNNER_EXT\$" "${run}./.gauntlet-typecheck"
|
|
157
|
+
have_tc="skip"
|
|
158
|
+
fi
|
|
159
|
+
if [ -z "$have_test" ] && [ -x "$d${d:+/}.gauntlet-test" ]; then
|
|
160
|
+
add_gate tests "$pre$RUNNER_EXT\$" "${run}./.gauntlet-test \$FILES"
|
|
161
|
+
have_test="skip"
|
|
162
|
+
fi
|
|
163
|
+
|
|
164
|
+
if [ -f "$d${d:+/}package.json" ]; then
|
|
165
|
+
pkg="$d${d:+/}package.json"
|
|
166
|
+
if [ -z "$have_tc" ]; then
|
|
167
|
+
if grep -q '"typecheck"[[:space:]]*:' "$pkg"; then
|
|
168
|
+
add_gate typecheck "$pre($JS_EXT)\$" "${run}npm run --silent typecheck"
|
|
169
|
+
elif [ -f "$d${d:+/}tsconfig.json" ]; then
|
|
170
|
+
add_gate typecheck "$pre(ts|tsx)\$" "${run}npx --no-install tsc --noEmit"
|
|
171
|
+
fi
|
|
172
|
+
fi
|
|
173
|
+
if [ -z "$have_test" ]; then
|
|
174
|
+
if grep -q '"vitest"' "$pkg"; then
|
|
175
|
+
add_gate tests "$pre($JS_EXT)\$" "${run}npx --no-install vitest related --run \$FILES"
|
|
176
|
+
elif grep -q '"jest"' "$pkg"; then
|
|
177
|
+
add_gate tests "$pre($JS_EXT)\$" "${run}npx --no-install jest --findRelatedTests \$FILES --passWithNoTests"
|
|
178
|
+
elif grep -q '"test"[[:space:]]*:' "$pkg"; then
|
|
179
|
+
add_gate tests "$pre($JS_EXT)\$" "${run}CI=1 npm run --silent test"
|
|
180
|
+
fi
|
|
181
|
+
fi
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
if [ -f "$d${d:+/}pyproject.toml" ] || [ -f "$d${d:+/}pytest.ini" ] || [ -f "$d${d:+/}setup.cfg" ]; then
|
|
185
|
+
if [ -z "$have_tc" ]; then
|
|
186
|
+
if [ -f "$d${d:+/}mypy.ini" ] || grep -qs 'tool.mypy' "$d${d:+/}pyproject.toml"; then
|
|
187
|
+
add_gate typecheck "${pre}py\$" "${run}python -m mypy ."
|
|
188
|
+
fi
|
|
189
|
+
fi
|
|
190
|
+
if [ -z "$have_test" ]; then
|
|
191
|
+
add_gate tests "${pre}py\$" "${run}python -m pytest -q"
|
|
192
|
+
fi
|
|
193
|
+
fi
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
detect() {
|
|
197
|
+
# Explicit config wins outright: auto-detection is off for the whole repo.
|
|
198
|
+
if [ "$GAUNTLET_TEST" != "__auto__" ] || [ "$GAUNTLET_TYPECHECK" != "__auto__" ]; then
|
|
199
|
+
if [ "$GAUNTLET_TYPECHECK" != "__auto__" ] && [ -n "$GAUNTLET_TYPECHECK" ]; then
|
|
200
|
+
add_gate typecheck '.' "$GAUNTLET_TYPECHECK"
|
|
201
|
+
fi
|
|
202
|
+
if [ "$GAUNTLET_TEST" != "__auto__" ] && [ -n "$GAUNTLET_TEST" ]; then
|
|
203
|
+
add_gate tests '.' "$GAUNTLET_TEST"
|
|
204
|
+
fi
|
|
205
|
+
return
|
|
206
|
+
fi
|
|
207
|
+
|
|
208
|
+
add_dir_gates ""
|
|
209
|
+
|
|
210
|
+
# Many repos keep no manifest at the root — the code lives in web/, api/,
|
|
211
|
+
# backend/, packages/*. Look two levels down, but only for the kind of gate the
|
|
212
|
+
# root did not already provide, so a repo that works today keeps working.
|
|
213
|
+
ROOT_TC="$TC_GATES"
|
|
214
|
+
ROOT_TEST="$TEST_GATES"
|
|
215
|
+
while IFS= read -r manifest; do
|
|
216
|
+
[ -z "$manifest" ] && continue
|
|
217
|
+
d=$(dirname "$manifest")
|
|
218
|
+
d=${d#./}
|
|
219
|
+
[ "$d" = "." ] && continue
|
|
220
|
+
add_dir_gates "$d"
|
|
221
|
+
done <<< "$(find . -mindepth 2 -maxdepth 3 \
|
|
222
|
+
\( -name package.json -o -name pyproject.toml -o -name pytest.ini \) \
|
|
223
|
+
-not -path '*/node_modules/*' -not -path '*/.git/*' \
|
|
224
|
+
-not -path '*/dist/*' -not -path '*/build/*' \
|
|
225
|
+
-not -path '*/.venv/*' -not -path '*/venv/*' 2>/dev/null | sort)"
|
|
226
|
+
}
|
|
227
|
+
detect
|
|
228
|
+
|
|
229
|
+
# Typechecks before tests — cheapest first, so the slow gate is usually unpaid.
|
|
230
|
+
GATES="$TC_GATES$TEST_GATES"
|
|
231
|
+
if [ -n "${GAUNTLET_DRYRUN:-}" ]; then
|
|
232
|
+
if [ -z "$GATES" ]; then quit "would run: nothing — no gates detected"; fi
|
|
233
|
+
quit "would run: $(printf '%s' "$GATES" | cut -f1,3 | tr '\t' ' ' | tr '\n' '; ')"
|
|
234
|
+
fi
|
|
235
|
+
if [ -z "$GATES" ]; then
|
|
236
|
+
if [ -n "$SKIP_NOTE" ]; then quit "skipped: $SKIP_NOTE"; fi
|
|
237
|
+
quit "skipped: no gates detected for this repo"
|
|
238
|
+
fi
|
|
239
|
+
|
|
240
|
+
# ------------------------------------------------------------------ the gates
|
|
241
|
+
FAILED=""
|
|
242
|
+
OUTPUT=""
|
|
243
|
+
NO_TEST_MATCH=""
|
|
244
|
+
RAN=""
|
|
245
|
+
# Kept in variables on purpose: a bare `}` inside a ${var//pat/repl} replacement
|
|
246
|
+
# closes the expansion early and silently mangles the command.
|
|
247
|
+
FILES_REPL='"${FILE_ARR[@]}"'
|
|
248
|
+
PAT_BRACE='${FILES}'
|
|
249
|
+
PAT_PLAIN='$FILES'
|
|
250
|
+
|
|
251
|
+
while IFS=$'\t' read -r GKIND GEXT GCMD; do
|
|
252
|
+
[ -z "$GKIND" ] && continue
|
|
253
|
+
GFILES=$(printf '%s\n' "$FILES_NL" | grep -E "$GEXT" || true)
|
|
254
|
+
[ -z "$GFILES" ] && continue
|
|
255
|
+
# $FILES is substituted as a real argument list, not a space-joined string —
|
|
256
|
+
# word splitting would tear "src/my folder/a.ts" into two bogus paths.
|
|
257
|
+
mapfile -t REL_ARR <<< "$GFILES"
|
|
258
|
+
FILE_ARR=()
|
|
259
|
+
for f in "${REL_ARR[@]}"; do FILE_ARR+=("$PROJECT_DIR/$f"); done
|
|
260
|
+
export FILES=$(printf '%s' "$GFILES" | tr '\n' ' ')
|
|
261
|
+
GCMD_RUN=${GCMD//"$PAT_BRACE"/$FILES_REPL}
|
|
262
|
+
GCMD_RUN=${GCMD_RUN//"$PAT_PLAIN"/$FILES_REPL}
|
|
263
|
+
RAN="$RAN$GKIND "
|
|
264
|
+
GATE_OUT=$(eval "$GCMD_RUN" 2>&1)
|
|
265
|
+
if [ $? -ne 0 ]; then
|
|
266
|
+
# A gate whose tool is not installed is a missing gate, not a failing one.
|
|
267
|
+
# Blocking on it would wall off every turn until the user installs something.
|
|
268
|
+
if printf '%s' "$GATE_OUT" | grep -qiE 'command not found|no module named|cannot find module|could not determine executable|canceled due to missing packages|missing script|is not recognized'; then
|
|
269
|
+
SKIP_NOTE="the $GKIND tool is not installed"
|
|
270
|
+
RAN="${RAN%"$GKIND "}"
|
|
271
|
+
continue
|
|
272
|
+
fi
|
|
273
|
+
FAILED="$GKIND"
|
|
274
|
+
OUTPUT="$GATE_OUT"
|
|
275
|
+
break
|
|
276
|
+
fi
|
|
277
|
+
# A runner handed files it has no tests for exits 0 and looks exactly like a
|
|
278
|
+
# pass. It is the loudest false green: the changed code was never executed.
|
|
279
|
+
if [ "$GKIND" = "tests" ] \
|
|
280
|
+
&& printf '%s' "$GATE_OUT" | grep -qiE 'no test files found|no tests found|no tests ran'; then
|
|
281
|
+
NO_TEST_MATCH="$GCMD"
|
|
282
|
+
OUTPUT="$GATE_OUT"
|
|
283
|
+
fi
|
|
284
|
+
done <<< "$GATES"
|
|
285
|
+
|
|
286
|
+
if [ -z "$FAILED" ] && [ -n "$NO_TEST_MATCH" ] && [ -n "${GAUNTLET_REQUIRE_TESTS:-}" ]; then
|
|
287
|
+
FAILED="tests"
|
|
288
|
+
OUTPUT="$OUTPUT
|
|
289
|
+
|
|
290
|
+
The runner matched 0 test files for the changed code, so nothing was executed.
|
|
291
|
+
Write a test that covers the change, or unset GAUNTLET_REQUIRE_TESTS."
|
|
292
|
+
fi
|
|
293
|
+
|
|
294
|
+
if [ -z "$FAILED" ]; then
|
|
295
|
+
printf '%s' "$DIFF_HASH" > "$MARKER"
|
|
296
|
+
if [ -z "$RAN" ]; then
|
|
297
|
+
if [ -n "$SKIP_NOTE" ]; then quit "skipped: $SKIP_NOTE"; fi
|
|
298
|
+
quit "skipped: no gate applied to the changed files"
|
|
299
|
+
elif [ -n "$SKIP_NOTE" ]; then
|
|
300
|
+
quit "green: gates passed ($RAN) — but $SKIP_NOTE"
|
|
301
|
+
elif [ -n "$NO_TEST_MATCH" ]; then
|
|
302
|
+
quit "green: gates passed, but a runner matched 0 test files — that code was never executed"
|
|
303
|
+
fi
|
|
304
|
+
quit "green: gates passed ($RAN)"
|
|
305
|
+
fi
|
|
306
|
+
|
|
307
|
+
# ------------------------------------------------------------------ block red
|
|
308
|
+
# Last 60 lines only — the hook's reason is read by a model, not archived.
|
|
309
|
+
TAIL=$(printf '%s\n' "$OUTPUT" | tail -60)
|
|
310
|
+
MSG="GAUNTLET FAILED: the $FAILED gate is red on the changed files.
|
|
311
|
+
|
|
312
|
+
Fix it before ending the turn. Do not weaken or delete a test to make this pass.
|
|
313
|
+
If the failure is unrelated to your change, say so explicitly and stop.
|
|
314
|
+
|
|
315
|
+
Changed code files:
|
|
316
|
+
$FILES
|
|
317
|
+
|
|
318
|
+
--- $FAILED output (last 60 lines) ---
|
|
319
|
+
$TAIL"
|
|
320
|
+
|
|
321
|
+
printf '%s\n' "red: the $FAILED gate failed" > "$WHY" 2>/dev/null
|
|
322
|
+
|
|
323
|
+
# Exit 2 is the only path where a red is visible to the user: its stderr is shown
|
|
324
|
+
# to them as the reason the turn is continuing. Exit 0 with {"decision":"continue"}
|
|
325
|
+
# also keeps the turn going, but the reason reaches Claude alone — so a failing
|
|
326
|
+
# gate looked, from the outside, exactly like a gauntlet that does nothing.
|
|
327
|
+
printf '%s\n' "$MSG" >&2
|
|
328
|
+
exit 2
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ship-gate-hook.sh — PreToolUse on Bash. Refuses the commands that publish work
|
|
3
|
+
# unless ship-gate.sh has left a receipt for exactly these changes.
|
|
4
|
+
#
|
|
5
|
+
# A gate written as an instruction is a gate an agent can decide is not worth the
|
|
6
|
+
# time — and then report a pass it never earned, which is the part you cannot see
|
|
7
|
+
# from the outside. This removes the claim entirely: either the receipt exists for
|
|
8
|
+
# this exact content, or git does not run.
|
|
9
|
+
#
|
|
10
|
+
# The receipt key covers everything about to ship, so it survives `git commit`
|
|
11
|
+
# and still matches at push time, and it changes the moment any file is edited —
|
|
12
|
+
# a fix has to be re-gated instead of riding on the previous verdict.
|
|
13
|
+
#
|
|
14
|
+
# It gated `git commit` and `git push` until 2.23.0, and that was the wrong
|
|
15
|
+
# moment. The gate's scope is merge-base..HEAD — the whole branch, because the
|
|
16
|
+
# whole branch is what a PR ships — so a branch touching 46 files re-mutated all
|
|
17
|
+
# 46 on every commit. Fifteen minutes, twenty times, for one branch. Neither a
|
|
18
|
+
# commit nor a push to a feature branch publishes anything, so neither is gated
|
|
19
|
+
# now: the cost is paid once, at the point work actually ships.
|
|
20
|
+
|
|
21
|
+
INPUT=$(cat)
|
|
22
|
+
|
|
23
|
+
CMD=$(printf '%s' "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
|
24
|
+
| head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
25
|
+
|
|
26
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
27
|
+
cd "$PROJECT_DIR" 2>/dev/null || exit 0
|
|
28
|
+
|
|
29
|
+
# Opening or merging a PR publishes. So does pushing while standing on a
|
|
30
|
+
# protected branch — that is a direct ship with no PR in front of it. The branch
|
|
31
|
+
# is read from git rather than parsed out of the command, because `git push` with
|
|
32
|
+
# no arguments names no branch at all.
|
|
33
|
+
case "$CMD" in
|
|
34
|
+
*"gh pr create"*|*"gh pr merge"*) ;;
|
|
35
|
+
*"git push"*)
|
|
36
|
+
BRANCH=$(git branch --show-current 2>/dev/null)
|
|
37
|
+
case "$BRANCH" in
|
|
38
|
+
main|master|develop|development|dev) ;;
|
|
39
|
+
*) exit 0 ;;
|
|
40
|
+
esac
|
|
41
|
+
;;
|
|
42
|
+
*) exit 0 ;;
|
|
43
|
+
esac
|
|
44
|
+
|
|
45
|
+
GATE="$PROJECT_DIR/.claude/hooks/ship-gate.sh"
|
|
46
|
+
[ -x "$GATE" ] || exit 0
|
|
47
|
+
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
|
48
|
+
|
|
49
|
+
# Ask the gate for the key rather than recomputing it here. Two implementations
|
|
50
|
+
# of the same rule drift, and the drift is silent.
|
|
51
|
+
KEY=$(bash "$GATE" --key 2>/dev/null)
|
|
52
|
+
[ -z "$KEY" ] && exit 0
|
|
53
|
+
|
|
54
|
+
RECEIPT="/tmp/claude-shipgate-$KEY"
|
|
55
|
+
if [ -f "$RECEIPT" ]; then
|
|
56
|
+
exit 0
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
MSG="BLOCKED: no ship-gate receipt for these changes.
|
|
60
|
+
|
|
61
|
+
The quality gate has not run against the code you are about to publish, or the
|
|
62
|
+
code changed since it last did. Run it:
|
|
63
|
+
|
|
64
|
+
bash .claude/hooks/ship-gate.sh
|
|
65
|
+
|
|
66
|
+
It checks file length and mutation-tests the changed lines, and writes a receipt
|
|
67
|
+
this hook can see. Fix what it reports and run it again — a fix invalidates the
|
|
68
|
+
previous receipt on purpose.
|
|
69
|
+
|
|
70
|
+
Do NOT hand-roll a substitute check: only ship-gate.sh writes a receipt, so a
|
|
71
|
+
weaker check you designed yourself cannot be passed off as this one.
|
|
72
|
+
|
|
73
|
+
To publish without the gate, say so out loud and run:
|
|
74
|
+
|
|
75
|
+
bash .claude/hooks/ship-gate.sh --force"
|
|
76
|
+
|
|
77
|
+
json_escape() {
|
|
78
|
+
local s="$1"
|
|
79
|
+
s="${s//\\/\\\\}"
|
|
80
|
+
s="${s//\"/\\\"}"
|
|
81
|
+
s="${s//$'\n'/\\n}"
|
|
82
|
+
s="${s//$'\r'/\\r}"
|
|
83
|
+
s="${s//$'\t'/\\t}"
|
|
84
|
+
printf '"%s"' "$s"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$(json_escape "$MSG")"
|
|
88
|
+
exit 0
|