@spardutti/claude-skills 2.30.2 → 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
package/lib/setup-hook.mjs
CHANGED
|
@@ -3,1447 +3,21 @@ import { join, resolve, dirname } from "node:path";
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
// a marker file exists at /tmp/claude-skill-gate-<KEY>, where KEY is the
|
|
8
|
-
// subagent's agent_id when the call comes from one, and the session_id
|
|
9
|
-
// otherwise — a subagent reports the parent's session_id, so keying on that
|
|
10
|
-
// alone let a worker inherit the main thread's cleared gate.
|
|
11
|
-
// Per-session (not per-prompt) so simple confirmations like "yes" don't
|
|
12
|
-
// re-lock the gate after evaluation has already happened in the session.
|
|
13
|
-
// The PostToolUse hook on Skill creates the marker automatically; for
|
|
14
|
-
// all-SKIP cases the model can `touch` the path manually.
|
|
6
|
+
// Installs the hook scripts and registers them in .claude/settings.json.
|
|
15
7
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
fi
|
|
30
|
-
|
|
31
|
-
SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | sed 's/"session_id":"//; s/"$//')
|
|
32
|
-
if [ -z "$SESSION_ID" ]; then
|
|
33
|
-
exit 0
|
|
34
|
-
fi
|
|
35
|
-
|
|
36
|
-
# A subagent reports the PARENT's session_id — measured, not assumed: a probe
|
|
37
|
-
# hook dumped its own stdin from the main thread and from inside a subagent and
|
|
38
|
-
# the two ids were identical. So a worker spawned to implement part of a plan
|
|
39
|
-
# inherited the main thread's cleared markers and edited with none of the
|
|
40
|
-
# skills loaded in its own context.
|
|
41
|
-
#
|
|
42
|
-
# agent_id is present only inside a subagent, and holds steady across that
|
|
43
|
-
# subagent's turns, so keying on it gives each worker its own namespace and
|
|
44
|
-
# costs it one Skill call per skill, not one per turn.
|
|
45
|
-
AGENT_ID=$(printf '%s' "$INPUT" | grep -o '"agent_id":"[^"]*"' | head -1 | sed 's/"agent_id":"//; s/"$//')
|
|
46
|
-
KEY="$SESSION_ID"
|
|
47
|
-
if [ -n "$AGENT_ID" ]; then
|
|
48
|
-
KEY=$(printf '%s' "$AGENT_ID" | tr -cd 'A-Za-z0-9_-')
|
|
49
|
-
fi
|
|
50
|
-
|
|
51
|
-
# The marker is checked at the very bottom now, not here. A skill the edited
|
|
52
|
-
# file's own stack demands is not something an already-cleared gate excuses,
|
|
53
|
-
# so the required-skill scan below runs first.
|
|
54
|
-
MARKER="/tmp/claude-skill-gate-$KEY"
|
|
55
|
-
|
|
56
|
-
# Write|Edit|MultiEdit is not the only way to change a file. A session that edits
|
|
57
|
-
# through \`python3 - <<'PY'\` in Bash walked past this gate entirely — twelve
|
|
58
|
-
# source files, not one prompt — and some harnesses actively tell the model to
|
|
59
|
-
# prefer Bash for edits. So Bash is gated too, but only for commands that can
|
|
60
|
-
# write, and only until the gate is cleared once for the session.
|
|
61
|
-
|
|
62
|
-
# Skills are about code. Blocking a plan document to ask how the React rules
|
|
63
|
-
# apply is pure noise, and a repo full of PLAN_*.md hits it on every write.
|
|
64
|
-
# Config files stay gated — skills do have rules about tsconfig and compose.
|
|
65
|
-
PROSE_EXT='md|mdx|txt|rst|adoc|png|jpg|jpeg|gif|svg|webp|ico'
|
|
66
|
-
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'
|
|
67
|
-
|
|
68
|
-
TOOL=$(printf '%s' "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | sed 's/.*:"//; s/"$//')
|
|
69
|
-
|
|
70
|
-
# Every file this call would write, collected for the required-skill scan below.
|
|
71
|
-
TARGETS=""
|
|
72
|
-
|
|
73
|
-
if [ "$TOOL" != "Bash" ]; then
|
|
74
|
-
# The structured tools name their target outright.
|
|
75
|
-
TARGET=$(printf '%s' "$INPUT" | grep -o '"file_path":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
76
|
-
case "$TARGET" in
|
|
77
|
-
# The settings file that configures the escape hatch cannot sit behind the
|
|
78
|
-
# gate, or a denied ack has no way to be un-denied. The bare relative form
|
|
79
|
-
# matters: a skill claiming **/* gated it, and nothing could re-open it.
|
|
80
|
-
.claude/settings.json|.claude/settings.local.json) exit 0 ;;
|
|
81
|
-
*/.claude/settings.json|*/.claude/settings.local.json) exit 0 ;;
|
|
82
|
-
*.*) printf '%s' "$TARGET" | grep -qiE "\.($PROSE_EXT)$" && exit 0 ;;
|
|
83
|
-
esac
|
|
84
|
-
TARGETS="$TARGET"
|
|
85
|
-
fi
|
|
86
|
-
if [ "$TOOL" = "Bash" ]; then
|
|
87
|
-
CMD=$(printf '%s' "$INPUT" | grep -o '"command":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
88
|
-
|
|
89
|
-
# Never gate the command that clears the gate, or this deadlocks.
|
|
90
|
-
case "$CMD" in
|
|
91
|
-
*claude-skill-gate-*|*claude-skill-acked-*|*claude-skill-loaded-*) exit 0 ;;
|
|
92
|
-
*.claude/settings.json*|*.claude/settings.local.json*) exit 0 ;;
|
|
93
|
-
esac
|
|
94
|
-
|
|
95
|
-
# /dev/null redirects are not file writes; drop them before looking for one.
|
|
96
|
-
|
|
97
|
-
# A command that names a prose file and no code file is writing prose.
|
|
98
|
-
if printf '%s' "$CMD" | grep -qiE "\.($PROSE_EXT)([^A-Za-z0-9]|$)" \
|
|
99
|
-
&& ! printf '%s' "$CMD" | grep -qiE "\.($CODE_EXT)([^A-Za-z0-9]|$)"; then
|
|
100
|
-
exit 0
|
|
101
|
-
fi
|
|
102
|
-
STRIPPED=$(printf '%s' "$CMD" | sed 's![12]*>>*[[:space:]]*/dev/null!!g')
|
|
103
|
-
WRITES=""
|
|
104
|
-
case "$STRIPPED" in
|
|
105
|
-
*">"*|*"tee "*|*"sed -i"*|*"cp "*|*"mv "*|*"truncate "*|*"dd "*) WRITES=1 ;;
|
|
106
|
-
esac
|
|
107
|
-
# An interpreter given inline code or a heredoc can write anything, and the
|
|
108
|
-
# shell shows no redirect at all — this is the shape that got past the gate.
|
|
109
|
-
case "$CMD" in
|
|
110
|
-
*"<<"*|*python*" -c"*|*node*" -e"*|*perl*" -e"*|*ruby*" -e"*) WRITES=1 ;;
|
|
111
|
-
esac
|
|
112
|
-
[ -z "$WRITES" ] && exit 0
|
|
113
|
-
# A Bash write names its files in the command itself. Dockerfile and
|
|
114
|
-
# .dockerignore carry no extension, so they are matched by name.
|
|
115
|
-
TARGETS=$(printf '%s' "$CMD" | grep -oiE "[A-Za-z0-9_./-]+\.($CODE_EXT)|[A-Za-z0-9_./-]*(Dockerfile[A-Za-z0-9_.-]*|\.dockerignore)" 2>/dev/null)
|
|
116
|
-
fi
|
|
117
|
-
|
|
118
|
-
# --- mandatory skills -------------------------------------------------------
|
|
119
|
-
# The generic gate below asks the model to rate every skill ACTIVATE or SKIP,
|
|
120
|
-
# and hands it an all-SKIP escape. A session took that escape, touched the
|
|
121
|
-
# marker, and edited React files with none of the React rules loaded. It had
|
|
122
|
-
# followed the hook exactly: the hook let it decide.
|
|
123
|
-
#
|
|
124
|
-
# So a skill that declares BOTH paths: and tracks: stops getting a vote. If the
|
|
125
|
-
# edited file matches its paths AND this project depends on a package it
|
|
126
|
-
# tracks, that skill is mandatory and only Skill(<name>) clears it. Skills
|
|
127
|
-
# without both fields keep the old behaviour, so this tightens nothing that was
|
|
128
|
-
# not deliberately declared.
|
|
129
|
-
MISSING=""
|
|
130
|
-
MANIFESTS=""
|
|
131
|
-
if [ -n "$TARGETS" ]; then
|
|
132
|
-
MANIFESTS=$(find "$PROJECT_DIR" -maxdepth 4 \\( -name node_modules -o -name .git -o -name .venv -o -name dist \\) -prune -o \\( -name package.json -o -name pyproject.toml -o -name requirements.txt \\) -print 2>/dev/null)
|
|
133
|
-
fi
|
|
134
|
-
if [ -n "$TARGETS" ]; then
|
|
135
|
-
for SKILL_FILE in $(find "$PROJECT_DIR" -path '*/.claude/skills/*/SKILL.md' 2>/dev/null); do
|
|
136
|
-
# Read gate-paths from under metadata:, never a bare paths: key. A bare
|
|
137
|
-
# paths: made Claude Code drop the skill from its registry outright —
|
|
138
|
-
# every Skill(name) call returned "Unknown skill" and, with no SKIP for a
|
|
139
|
-
# mandatory skill, the session could not edit code at all. metadata is
|
|
140
|
-
# documented as free-form and ignored by Claude Code, and the docs say in
|
|
141
|
-
# so many words not to reuse paths as a key inside it.
|
|
142
|
-
#
|
|
143
|
-
# The value must be quoted in YAML either way: a scalar opening with * is
|
|
144
|
-
# an alias indicator.
|
|
145
|
-
UNQUOTE='s/^["'"'"']//; s/["'"'"']$//'
|
|
146
|
-
SPATHS=$(sed -n 's/^[[:space:]]*gate-paths:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
|
|
147
|
-
STRACKS=$(sed -n 's/^tracks:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
|
|
148
|
-
SNAME=$(sed -n 's/^name:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
|
|
149
|
-
[ -z "$SPATHS" ] && continue
|
|
150
|
-
[ -z "$SNAME" ] && continue
|
|
151
|
-
|
|
152
|
-
HIT=""
|
|
153
|
-
for G in $(printf '%s' "$SPATHS" | tr ',' ' '); do
|
|
154
|
-
# Claude Code writes these gitignore-style. A case glob has no **, but *
|
|
155
|
-
# already crosses / here, so dropping the **/ prefix is equivalent.
|
|
156
|
-
G=$(printf '%s' "$G" | sed 's!\\*\\*/!!g')
|
|
157
|
-
case "$G" in \\**|/*) ;; *) G="*$G" ;; esac
|
|
158
|
-
for T in $TARGETS; do
|
|
159
|
-
case "$T" in $G) HIT=1 ;; esac
|
|
160
|
-
done
|
|
161
|
-
done
|
|
162
|
-
[ -z "$HIT" ] && continue
|
|
163
|
-
|
|
164
|
-
# A .tsx file is not proof of React — it could be Astro, Solid or Preact.
|
|
165
|
-
# The packages the skill tracks are the proof, so the manifests decide.
|
|
166
|
-
#
|
|
167
|
-
# Some skills need no such proof: a .sql file is SQL and a Dockerfile is a
|
|
168
|
-
# Dockerfile, and no package declares either. A skill that names paths and
|
|
169
|
-
# no tracks is taken at its word.
|
|
170
|
-
PY=""
|
|
171
|
-
case "$STRACKS" in *pypi*) PY=1 ;; esac
|
|
172
|
-
DEP=""
|
|
173
|
-
[ -z "$STRACKS" ] && DEP=1
|
|
174
|
-
for P in $(printf '%s' "$STRACKS" | tr ',' ' '); do
|
|
175
|
-
case "$P" in *@*) ;; *) continue ;; esac
|
|
176
|
-
PKG=$(printf '%s' "$P" | sed 's/@[^@]*$//')
|
|
177
|
-
[ -z "$PKG" ] && continue
|
|
178
|
-
for M in $MANIFESTS; do
|
|
179
|
-
if [ -n "$PY" ]; then
|
|
180
|
-
grep -qiE "(^|[^A-Za-z0-9_.-])$PKG([^A-Za-z0-9_.-]|$)" "$M" 2>/dev/null && DEP=1
|
|
181
|
-
else
|
|
182
|
-
grep -qs "\\"$PKG\\"" "$M" && DEP=1
|
|
183
|
-
fi
|
|
184
|
-
done
|
|
185
|
-
done
|
|
186
|
-
[ -z "$DEP" ] && continue
|
|
187
|
-
|
|
188
|
-
SAFE=$(printf '%s' "$SNAME" | tr -cd 'A-Za-z0-9_-')
|
|
189
|
-
[ -f "/tmp/claude-skill-loaded-$KEY-$SAFE" ] && continue
|
|
190
|
-
case " $MISSING " in *" $SNAME "*) ;; *) MISSING="$MISSING $SNAME" ;; esac
|
|
191
|
-
done
|
|
192
|
-
fi
|
|
193
|
-
|
|
194
|
-
if [ -n "$MISSING" ]; then
|
|
195
|
-
LIST=$(printf '%s' "$MISSING" | sed 's/^ //; s/ /, /g')
|
|
196
|
-
FIRST=$(printf '%s' "$MISSING" | awk '{print $1}')
|
|
197
|
-
cat <<EOF
|
|
198
|
-
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"BLOCKED: this file's stack has mandatory skills that are not loaded: $LIST.\\n\\nThere is no SKIP for these. The file matches each skill's declared paths AND this project depends on a package that skill tracks, so they apply as a matter of fact, not judgement. Touching the gate marker will not clear them.\\n\\nCall Skill($FIRST) now — then every other name in the list — and retry the edit."}}
|
|
199
|
-
EOF
|
|
200
|
-
exit 0
|
|
201
|
-
fi
|
|
202
|
-
|
|
203
|
-
if [ -f "$MARKER" ]; then
|
|
204
|
-
exit 0
|
|
205
|
-
fi
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
cat <<EOF
|
|
209
|
-
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"BLOCKED: skill evaluation required before file edits in this session.\\n\\nStep 1 — evaluate every available skill as ACTIVATE or SKIP with a one-line reason.\\n\\nStep 2 — you MUST take EXACTLY ONE of these tool actions to clear the gate. Listing skills in text is NOT enough; retrying the edit without doing one of these will be denied again:\\n (a) If any skill is ACTIVATE → call Skill(name) for it. This auto-clears the gate.\\n (b) If ALL skills are SKIP → run this Bash tool call: touch /tmp/claude-skill-gate-$KEY\\n\\nStep 3 — only after Step 2 completes, retry the file edit."}}
|
|
210
|
-
EOF
|
|
211
|
-
exit 0
|
|
212
|
-
`;
|
|
213
|
-
|
|
214
|
-
// PostToolUse on Skill: auto-creates the per-session gate marker AND a
|
|
215
|
-
// per-skill "loaded" marker. The application gate uses the loaded marker
|
|
216
|
-
// to require the model to explicitly apply each loaded skill before the
|
|
217
|
-
// first Write/Edit in this session.
|
|
218
|
-
const AUTO_MARK_SCRIPT = `#!/bin/bash
|
|
219
|
-
# PostToolUse on Skill: marks gate satisfied + records loaded skill.
|
|
220
|
-
|
|
221
|
-
INPUT=$(cat)
|
|
222
|
-
|
|
223
|
-
SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | sed 's/"session_id":"//; s/"$//')
|
|
224
|
-
if [ -z "$SESSION_ID" ]; then
|
|
225
|
-
exit 0
|
|
226
|
-
fi
|
|
227
|
-
|
|
228
|
-
# Keyed exactly as the gate keys it. A subagent carries the parent's
|
|
229
|
-
# session_id, so both scripts fall back to agent_id when one is present — and
|
|
230
|
-
# they must agree, or a loaded skill writes a marker the gate never looks for
|
|
231
|
-
# and every edit is denied forever.
|
|
232
|
-
AGENT_ID=$(printf '%s' "$INPUT" | grep -o '"agent_id":"[^"]*"' | head -1 | sed 's/"agent_id":"//; s/"$//')
|
|
233
|
-
KEY="$SESSION_ID"
|
|
234
|
-
if [ -n "$AGENT_ID" ]; then
|
|
235
|
-
KEY=$(printf '%s' "$AGENT_ID" | tr -cd 'A-Za-z0-9_-')
|
|
236
|
-
fi
|
|
237
|
-
|
|
238
|
-
touch "/tmp/claude-skill-gate-$KEY"
|
|
239
|
-
|
|
240
|
-
SKILL_NAME=$(printf '%s' "$INPUT" | grep -o '"skill":"[^"]*"' | head -1 | sed 's/"skill":"//; s/"$//')
|
|
241
|
-
if [ -n "$SKILL_NAME" ]; then
|
|
242
|
-
# Sanitize: only allow [A-Za-z0-9_-] in the marker filename.
|
|
243
|
-
SAFE_NAME=$(printf '%s' "$SKILL_NAME" | tr -cd 'A-Za-z0-9_-')
|
|
244
|
-
if [ -n "$SAFE_NAME" ]; then
|
|
245
|
-
touch "/tmp/claude-skill-loaded-$KEY-$SAFE_NAME"
|
|
246
|
-
fi
|
|
247
|
-
fi
|
|
248
|
-
|
|
249
|
-
exit 0
|
|
250
|
-
`;
|
|
251
|
-
|
|
252
|
-
// PreToolUse application gate: requires the model to explicitly apply each
|
|
253
|
-
// loaded skill before the first Write/Edit. Reads SKILL.md's Rules section
|
|
254
|
-
// and inlines it in the deny message. One ack per skill per session.
|
|
255
|
-
const APPLICATION_GATE_SCRIPT = `#!/bin/bash
|
|
256
|
-
# PreToolUse application gate: blocks file edits until each loaded skill
|
|
257
|
-
# has been explicitly applied (acked) for this session.
|
|
258
|
-
|
|
259
|
-
INPUT=$(cat)
|
|
260
|
-
|
|
261
|
-
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
262
|
-
PROJECT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
|
263
|
-
|
|
264
|
-
SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | sed 's/"session_id":"//; s/"$//')
|
|
265
|
-
if [ -z "$SESSION_ID" ]; then
|
|
266
|
-
exit 0
|
|
267
|
-
fi
|
|
268
|
-
|
|
269
|
-
# Keyed exactly as the loading gate and the automark key it — see those two.
|
|
270
|
-
# A subagent carries the parent's session_id, so all three must agree.
|
|
271
|
-
AGENT_ID=$(printf '%s' "$INPUT" | grep -o '"agent_id":"[^"]*"' | head -1 | sed 's/"agent_id":"//; s/"$//')
|
|
272
|
-
KEY="$SESSION_ID"
|
|
273
|
-
if [ -n "$AGENT_ID" ]; then
|
|
274
|
-
KEY=$(printf '%s' "$AGENT_ID" | tr -cd 'A-Za-z0-9_-')
|
|
275
|
-
fi
|
|
276
|
-
|
|
277
|
-
# Defer to the loading gate until it's been satisfied for this key.
|
|
278
|
-
if [ ! -f "/tmp/claude-skill-gate-$KEY" ]; then
|
|
279
|
-
exit 0
|
|
280
|
-
fi
|
|
281
|
-
|
|
282
|
-
# Same reason as the loading gate: Bash can write files, and a heredoc into an
|
|
283
|
-
# interpreter shows no redirect at all.
|
|
284
|
-
|
|
285
|
-
# Skills are about code. Blocking a plan document to ask how the React rules
|
|
286
|
-
# apply is pure noise, and a repo full of PLAN_*.md hits it on every write.
|
|
287
|
-
# Config files stay gated — skills do have rules about tsconfig and compose.
|
|
288
|
-
PROSE_EXT='md|mdx|txt|rst|adoc|png|jpg|jpeg|gif|svg|webp|ico'
|
|
289
|
-
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'
|
|
290
|
-
|
|
291
|
-
TOOL=$(printf '%s' "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | sed 's/.*:"//; s/"$//')
|
|
292
|
-
|
|
293
|
-
if [ "$TOOL" != "Bash" ]; then
|
|
294
|
-
# The structured tools name their target outright.
|
|
295
|
-
TARGET=$(printf '%s' "$INPUT" | grep -o '"file_path":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
296
|
-
case "$TARGET" in
|
|
297
|
-
# The settings file that configures the escape hatch cannot sit behind the
|
|
298
|
-
# gate, or a denied ack has no way to be un-denied.
|
|
299
|
-
*/.claude/settings.json|*/.claude/settings.local.json) exit 0 ;;
|
|
300
|
-
*.*) printf '%s' "$TARGET" | grep -qiE "\.($PROSE_EXT)$" && exit 0 ;;
|
|
301
|
-
esac
|
|
302
|
-
fi
|
|
303
|
-
if [ "$TOOL" = "Bash" ]; then
|
|
304
|
-
CMD=$(printf '%s' "$INPUT" | grep -o '"command":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
305
|
-
case "$CMD" in
|
|
306
|
-
*claude-skill-gate-*|*claude-skill-acked-*|*claude-skill-loaded-*) exit 0 ;;
|
|
307
|
-
*.claude/settings.json*|*.claude/settings.local.json*) exit 0 ;;
|
|
308
|
-
esac
|
|
309
|
-
|
|
310
|
-
# A command that names a prose file and no code file is writing prose.
|
|
311
|
-
if printf '%s' "$CMD" | grep -qiE "\.($PROSE_EXT)([^A-Za-z0-9]|$)" \
|
|
312
|
-
&& ! printf '%s' "$CMD" | grep -qiE "\.($CODE_EXT)([^A-Za-z0-9]|$)"; then
|
|
313
|
-
exit 0
|
|
314
|
-
fi
|
|
315
|
-
STRIPPED=$(printf '%s' "$CMD" | sed 's![12]*>>*[[:space:]]*/dev/null!!g')
|
|
316
|
-
WRITES=""
|
|
317
|
-
case "$STRIPPED" in
|
|
318
|
-
*">"*|*"tee "*|*"sed -i"*|*"cp "*|*"mv "*|*"truncate "*|*"dd "*) WRITES=1 ;;
|
|
319
|
-
esac
|
|
320
|
-
case "$CMD" in
|
|
321
|
-
*"<<"*|*python*" -c"*|*node*" -e"*|*perl*" -e"*|*ruby*" -e"*) WRITES=1 ;;
|
|
322
|
-
esac
|
|
323
|
-
[ -z "$WRITES" ] && exit 0
|
|
324
|
-
fi
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
# Collect every loaded-but-unacked skill so a single ack clears them all.
|
|
328
|
-
UNACKED=""
|
|
329
|
-
for marker in /tmp/claude-skill-loaded-$KEY-*; do
|
|
330
|
-
[ ! -f "$marker" ] && continue
|
|
331
|
-
skill_name="\${marker##/tmp/claude-skill-loaded-$KEY-}"
|
|
332
|
-
if [ ! -f "/tmp/claude-skill-acked-$KEY-$skill_name" ]; then
|
|
333
|
-
UNACKED="$UNACKED $skill_name"
|
|
334
|
-
fi
|
|
335
|
-
done
|
|
336
|
-
UNACKED="\${UNACKED# }"
|
|
337
|
-
|
|
338
|
-
if [ -z "$UNACKED" ]; then
|
|
339
|
-
exit 0
|
|
340
|
-
fi
|
|
341
|
-
|
|
342
|
-
# One touch call with explicit paths — brace expansion is not used because
|
|
343
|
-
# bash leaves a single-element {name} literal, creating a garbage marker.
|
|
344
|
-
ACK_CMD="touch"
|
|
345
|
-
NAMES=""
|
|
346
|
-
RULES_BLOCKS=""
|
|
347
|
-
for skill in $UNACKED; do
|
|
348
|
-
ACK_CMD="$ACK_CMD /tmp/claude-skill-acked-$KEY-$skill"
|
|
349
|
-
NAMES="$NAMES, '$skill'"
|
|
350
|
-
SKILL_MD="$PROJECT_DIR/.claude/skills/$skill/SKILL.md"
|
|
351
|
-
RULES=""
|
|
352
|
-
if [ -f "$SKILL_MD" ]; then
|
|
353
|
-
RULES=$(awk '/^## Rules/{flag=1} /^## /{if(flag && !/^## Rules/)exit} flag' "$SKILL_MD")
|
|
354
|
-
fi
|
|
355
|
-
if [ -z "$RULES" ]; then
|
|
356
|
-
RULES="(Rules section not found in $SKILL_MD — refer to the loaded skill content already in context.)"
|
|
357
|
-
fi
|
|
358
|
-
RULES_BLOCKS="$RULES_BLOCKS
|
|
359
|
-
### Rules from $skill/SKILL.md
|
|
360
|
-
|
|
361
|
-
$RULES
|
|
362
|
-
"
|
|
363
|
-
done
|
|
364
|
-
NAMES="\${NAMES#, }"
|
|
365
|
-
|
|
366
|
-
MSG="BLOCKED: loaded skills not yet applied to your work: $NAMES
|
|
367
|
-
|
|
368
|
-
Before this Write/Edit, you must:
|
|
369
|
-
|
|
370
|
-
1. For each skill listed above, say whether it applies to the file you're about to
|
|
371
|
-
write. If it does not — a Python skill and a TypeScript file, say — write
|
|
372
|
-
\\"does not apply\\" and move on. Do not invent a connection.
|
|
373
|
-
2. For the ones that do apply: state the specific rules that bear on this file, and
|
|
374
|
-
how your next write respects each.
|
|
375
|
-
3. Then ack all of them in a single Bash tool call:
|
|
376
|
-
$ACK_CMD
|
|
377
|
-
|
|
378
|
-
One ack per skill per session. After acking, retry the Write/Edit.
|
|
379
|
-
|
|
380
|
-
If that Bash call is denied by the auto mode classifier, STOP. Do not ask your
|
|
381
|
-
user to run it for you, do not look for another route to create the files, and
|
|
382
|
-
do not try to add the allow rule yourself — an agent editing permission settings
|
|
383
|
-
is denied by the same classifier, so that attempt fails too and costs a round
|
|
384
|
-
trip that ends in the same place. All three are what the classifier is there to
|
|
385
|
-
stop, and asking a human to run a command you were just denied makes them the
|
|
386
|
-
tool that performs it. Report this to them instead and let them decide:
|
|
387
|
-
|
|
388
|
-
Auto mode's built-in Auto-Mode Bypass rule counts a hook's ack file as working
|
|
389
|
-
around the permission system. It is a soft rule, so an allow entry clears it.
|
|
390
|
-
The user has to add it — you cannot. The shortest route is for them to re-run
|
|
391
|
-
npx @spardutti/claude-skills and accept the auto mode prompt, which writes it.
|
|
392
|
-
Otherwise /permissions, Auto mode tab. Editing the file by hand works too, and
|
|
393
|
-
it has to be ~/.claude/settings.json — the classifier does not read a
|
|
394
|
-
project's .claude/settings.json. Keep the
|
|
395
|
-
\\\$defaults line or every built-in allow rule is discarded:
|
|
396
|
-
|
|
397
|
-
\\"autoMode\\": { \\"allow\\": [ \\"\\\$defaults\\",
|
|
398
|
-
\\"Touching marker files under /tmp/claude-skill-gate-*, /tmp/claude-skill-acked-*
|
|
399
|
-
and /tmp/claude-skill-loaded-* is allowed: they are acknowledgement files for
|
|
400
|
-
the user's own PreToolUse skill gates. Creating one satisfies a workflow gate
|
|
401
|
-
the user installed, grants no permission and executes no code, and is a false
|
|
402
|
-
positive for Auto-Mode Bypass.\\" ] }
|
|
403
|
-
$RULES_BLOCKS"
|
|
404
|
-
|
|
405
|
-
json_escape() {
|
|
406
|
-
local s="$1"
|
|
407
|
-
s="\${s//\\\\/\\\\\\\\}"
|
|
408
|
-
s="\${s//\\"/\\\\\\"}"
|
|
409
|
-
s="\${s//$'\\n'/\\\\n}"
|
|
410
|
-
s="\${s//$'\\r'/\\\\r}"
|
|
411
|
-
s="\${s//$'\\t'/\\\\t}"
|
|
412
|
-
printf '"%s"' "$s"
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
REASON=$(json_escape "$MSG")
|
|
416
|
-
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\\n' "$REASON"
|
|
417
|
-
exit 0
|
|
418
|
-
`;
|
|
419
|
-
|
|
420
|
-
// Stop hook: fast deterministic gates on the changed diff. Silent when green,
|
|
421
|
-
// blocks the turn when a gate is red so the model fixes it before the user sees
|
|
422
|
-
// it. Canonical source: scripts/gauntlet.sh in the claude-skills repo.
|
|
423
|
-
const GAUNTLET_SCRIPT = `#!/usr/bin/env bash
|
|
424
|
-
# gauntlet.sh — Stop hook: fast deterministic gates on the changed diff.
|
|
425
|
-
#
|
|
426
|
-
# Runs when Claude finishes a turn. Silent when green. On a red gate it exits 2:
|
|
427
|
-
# the turn keeps going, the user is shown why, and Claude is handed the failure.
|
|
428
|
-
#
|
|
429
|
-
# Two things the hook contract makes non-negotiable:
|
|
430
|
-
# - stop_hook_active must short-circuit, or a red gate loops forever.
|
|
431
|
-
# - A hook that hits its timeout (600s default for command hooks) is cancelled
|
|
432
|
-
# and its output discarded, so the turn ends as if the hook never ran. Keep
|
|
433
|
-
# the gates fast; that is a silent pass, not a block.
|
|
434
|
-
#
|
|
435
|
-
# Only cheap gates belong here (target: under ~30s). Mutation testing, deep
|
|
436
|
-
# review, and the spec checklist are the /gauntlet command's job, not this hook's.
|
|
437
|
-
#
|
|
438
|
-
# Skip ladder, cheapest first — quits at the first "no":
|
|
439
|
-
# 1. no changed files
|
|
440
|
-
# 2. no changed *code* files (docs/config only)
|
|
441
|
-
# 3. this exact diff already passed
|
|
442
|
-
# 4. no gates detected for this repo
|
|
443
|
-
#
|
|
444
|
-
# Config, both optional and both KEY=value files that get sourced in this order:
|
|
445
|
-
# ~/.claude/gauntlet.conf your defaults for every project
|
|
446
|
-
# <project>/.claude/gauntlet.conf overrides for this one; wins key by key
|
|
447
|
-
# GAUNTLET_OFF=1 disable entirely
|
|
448
|
-
# GAUNTLET_TYPECHECK="cmd" explicit typecheck command ("" to skip)
|
|
449
|
-
# GAUNTLET_TEST="cmd" explicit test command; $FILES = changed code files
|
|
450
|
-
# GAUNTLET_CODE_EXT="ts|tsx|py" override which extensions count as code
|
|
451
|
-
# GAUNTLET_DEBUG=1 print the outcome to stderr on every run
|
|
452
|
-
# GAUNTLET_REQUIRE_TESTS=1 block when a runner matched 0 test files
|
|
453
|
-
#
|
|
454
|
-
# Setting either command switches the whole repo to explicit mode — auto-detection
|
|
455
|
-
# is off, and only what you set runs.
|
|
456
|
-
#
|
|
457
|
-
# Per-project runners, for tests that cannot run on the host at all:
|
|
458
|
-
# <project>/.gauntlet-test executable; run instead of the detected test
|
|
459
|
-
# command, with $FILES as its arguments
|
|
460
|
-
# <project>/.gauntlet-typecheck executable; run instead of the detected
|
|
461
|
-
# typecheck command
|
|
462
|
-
#
|
|
463
|
-
# <project> is any directory holding a package.json or pyproject.toml, so one
|
|
464
|
-
# half of a monorepo can run on the host while the other runs in a container.
|
|
465
|
-
# Unlike GAUNTLET_TEST these do not disable detection anywhere else. Typically a
|
|
466
|
-
# two-line \`docker compose run --rm -T api pytest "$@"\`. Same shape as
|
|
467
|
-
# ship-gate's .mutmut-run.
|
|
468
|
-
#
|
|
469
|
-
# Every run records why it ended in /tmp/claude-gauntlet-<session>.why, so a green
|
|
470
|
-
# run and a skipped one are told apart without re-running the hook.
|
|
471
|
-
|
|
472
|
-
set -uo pipefail
|
|
473
|
-
|
|
474
|
-
INPUT=$(cat 2>/dev/null || true)
|
|
475
|
-
|
|
476
|
-
# Never re-block a turn we already blocked — this would loop forever.
|
|
477
|
-
case "$INPUT" in
|
|
478
|
-
*'"stop_hook_active":true'*) exit 0 ;;
|
|
479
|
-
esac
|
|
480
|
-
|
|
481
|
-
SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 \\
|
|
482
|
-
| sed 's/"session_id":"//; s/"$//')
|
|
483
|
-
if [ -z "$SESSION_ID" ]; then SESSION_ID="nosession"; fi
|
|
484
|
-
MARKER="/tmp/claude-gauntlet-$SESSION_ID"
|
|
485
|
-
WHY="$MARKER.why"
|
|
486
|
-
|
|
487
|
-
# Records why this run ended, so "green" and "never ran" stop looking identical.
|
|
488
|
-
quit() {
|
|
489
|
-
printf '%s\\n' "$1" > "$WHY" 2>/dev/null
|
|
490
|
-
if [ -n "\${GAUNTLET_DEBUG:-}" ]; then printf 'gauntlet: %s\\n' "$1" >&2; fi
|
|
491
|
-
exit 0
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-}"
|
|
495
|
-
if [ -z "$PROJECT_DIR" ]; then PROJECT_DIR="$PWD"; fi
|
|
496
|
-
cd "$PROJECT_DIR" 2>/dev/null || quit "skipped: project dir unreadable"
|
|
497
|
-
|
|
498
|
-
git rev-parse --git-dir >/dev/null 2>&1 || quit "skipped: not a git repo"
|
|
499
|
-
|
|
500
|
-
GAUNTLET_OFF=""
|
|
501
|
-
GAUNTLET_TYPECHECK="__auto__"
|
|
502
|
-
GAUNTLET_TEST="__auto__"
|
|
503
|
-
GAUNTLET_CODE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|sql"
|
|
504
|
-
# Global defaults first, then the project's on top — project wins, key by key.
|
|
505
|
-
if [ -n "\${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ]; then . "$HOME/.claude/gauntlet.conf"; fi
|
|
506
|
-
if [ -f "$PROJECT_DIR/.claude/gauntlet.conf" ]; then . "$PROJECT_DIR/.claude/gauntlet.conf"; fi
|
|
507
|
-
if [ -n "$GAUNTLET_OFF" ]; then quit "skipped: GAUNTLET_OFF is set"; fi
|
|
508
|
-
|
|
509
|
-
# ---------------------------------------------------------------- skip rule 1
|
|
510
|
-
CHANGED=$( { git diff --name-only HEAD 2>/dev/null; \\
|
|
511
|
-
git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )
|
|
512
|
-
if [ -z "$CHANGED" ] && [ -z "\${GAUNTLET_DRYRUN:-}" ]; then quit "skipped: no changed files"; fi
|
|
513
|
-
|
|
514
|
-
# ---------------------------------------------------------------- skip rule 2
|
|
515
|
-
# .stryker-tmp holds Stryker's sandbox — a full copy of the project. A crashed
|
|
516
|
-
# run leaves it behind, and then every file in it looks like a changed file.
|
|
517
|
-
FILES_NL=$(printf '%s\\n' "$CHANGED" | grep -E "\\.($GAUNTLET_CODE_EXT)$" \\
|
|
518
|
-
| grep -vE '(^|/)\\.stryker-tmp/' || true)
|
|
519
|
-
if [ -z "$FILES_NL" ] && [ -z "\${GAUNTLET_DRYRUN:-}" ]; then
|
|
520
|
-
quit "skipped: no changed code files (docs/config only)"
|
|
521
|
-
fi
|
|
522
|
-
|
|
523
|
-
# ---------------------------------------------------------------- skip rule 3
|
|
524
|
-
# The repo path is part of the hash: one session can touch several repos, and two
|
|
525
|
-
# of them can legitimately hold an identical diff.
|
|
526
|
-
DIFF_HASH=$( { printf '%s\\n' "$PROJECT_DIR"; git diff HEAD 2>/dev/null; printf '%s\\n' "$CHANGED"; } \\
|
|
527
|
-
| git hash-object --stdin 2>/dev/null )
|
|
528
|
-
if [ -f "$MARKER" ] && [ "$(cat "$MARKER")" = "$DIFF_HASH" ] \\
|
|
529
|
-
&& [ -z "\${GAUNTLET_DRYRUN:-}" ]; then
|
|
530
|
-
quit "skipped: this diff already passed"
|
|
531
|
-
fi
|
|
532
|
-
|
|
533
|
-
# ---------------------------------------------------------------- skip rule 4
|
|
534
|
-
# A monorepo can hold more than one stack, so detection collects every gate it
|
|
535
|
-
# finds rather than stopping at the first. Each gate carries the file pattern it
|
|
536
|
-
# applies to, and is skipped entirely when the diff touched none of those files —
|
|
537
|
-
# so a Python-only change never runs the JS suite, and vice versa.
|
|
538
|
-
TC_GATES=""
|
|
539
|
-
TEST_GATES=""
|
|
540
|
-
SKIP_NOTE=""
|
|
541
|
-
|
|
542
|
-
add_gate() { # kind (typecheck|tests), file regex, command
|
|
543
|
-
LINE=$(printf '%s\\t%s\\t%s' "$1" "$2" "$3")
|
|
544
|
-
if [ "$1" = "typecheck" ]; then
|
|
545
|
-
TC_GATES="$TC_GATES$LINE
|
|
546
|
-
"
|
|
547
|
-
else
|
|
548
|
-
TEST_GATES="$TEST_GATES$LINE
|
|
549
|
-
"
|
|
550
|
-
fi
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
JS_EXT='(ts|tsx|js|jsx|mjs|cjs)'
|
|
554
|
-
|
|
555
|
-
# Builds the gates for one directory. "" is the repo root; anything else is a
|
|
556
|
-
# nested project, and its gates match only files under that path. Commands run
|
|
557
|
-
# from inside the directory, which is why the file list is passed as absolute
|
|
558
|
-
# paths — a relative path would not survive the cd.
|
|
559
|
-
add_dir_gates() {
|
|
560
|
-
d="$1"
|
|
561
|
-
if [ -n "$d" ]; then pre="^$d/.*\\\\." ; run="cd $d && " ; else pre='\\.' ; run="" ; fi
|
|
562
|
-
have_tc=""; have_test=""
|
|
563
|
-
[ -n "$d" ] && [ -n "\${ROOT_TC:-}" ] && have_tc="skip"
|
|
564
|
-
[ -n "$d" ] && [ -n "\${ROOT_TEST:-}" ] && have_test="skip"
|
|
565
|
-
|
|
566
|
-
# A project whose tests need a database, a queue, or any other service cannot
|
|
567
|
-
# run them on the host, and detection here finds the host runner every time.
|
|
568
|
-
# An executable runner in the project directory says where they DO run —
|
|
569
|
-
# usually \`docker compose run\` against the app container — and wins over
|
|
570
|
-
# everything detected below.
|
|
571
|
-
#
|
|
572
|
-
# Per project, not per repo: GAUNTLET_TEST switches the WHOLE repo to explicit
|
|
573
|
-
# mode, so a monorepo needing a container for one stack had to hand-write
|
|
574
|
-
# commands for all of them. These let one half run on the host and the other
|
|
575
|
-
# in a container. Same shape as ship-gate's .mutmut-run, deliberately.
|
|
576
|
-
RUNNER_EXT="($JS_EXT|py)"
|
|
577
|
-
if [ -z "$have_tc" ] && [ -x "$d\${d:+/}.gauntlet-typecheck" ]; then
|
|
578
|
-
add_gate typecheck "$pre$RUNNER_EXT\\$" "\${run}./.gauntlet-typecheck"
|
|
579
|
-
have_tc="skip"
|
|
580
|
-
fi
|
|
581
|
-
if [ -z "$have_test" ] && [ -x "$d\${d:+/}.gauntlet-test" ]; then
|
|
582
|
-
add_gate tests "$pre$RUNNER_EXT\\$" "\${run}./.gauntlet-test \\$FILES"
|
|
583
|
-
have_test="skip"
|
|
584
|
-
fi
|
|
585
|
-
|
|
586
|
-
if [ -f "$d\${d:+/}package.json" ]; then
|
|
587
|
-
pkg="$d\${d:+/}package.json"
|
|
588
|
-
if [ -z "$have_tc" ]; then
|
|
589
|
-
if grep -q '"typecheck"[[:space:]]*:' "$pkg"; then
|
|
590
|
-
add_gate typecheck "$pre($JS_EXT)\\$" "\${run}npm run --silent typecheck"
|
|
591
|
-
elif [ -f "$d\${d:+/}tsconfig.json" ]; then
|
|
592
|
-
add_gate typecheck "$pre(ts|tsx)\\$" "\${run}npx --no-install tsc --noEmit"
|
|
593
|
-
fi
|
|
594
|
-
fi
|
|
595
|
-
if [ -z "$have_test" ]; then
|
|
596
|
-
if grep -q '"vitest"' "$pkg"; then
|
|
597
|
-
add_gate tests "$pre($JS_EXT)\\$" "\${run}npx --no-install vitest related --run \\$FILES"
|
|
598
|
-
elif grep -q '"jest"' "$pkg"; then
|
|
599
|
-
add_gate tests "$pre($JS_EXT)\\$" "\${run}npx --no-install jest --findRelatedTests \\$FILES --passWithNoTests"
|
|
600
|
-
elif grep -q '"test"[[:space:]]*:' "$pkg"; then
|
|
601
|
-
add_gate tests "$pre($JS_EXT)\\$" "\${run}CI=1 npm run --silent test"
|
|
602
|
-
fi
|
|
603
|
-
fi
|
|
604
|
-
fi
|
|
605
|
-
|
|
606
|
-
if [ -f "$d\${d:+/}pyproject.toml" ] || [ -f "$d\${d:+/}pytest.ini" ] || [ -f "$d\${d:+/}setup.cfg" ]; then
|
|
607
|
-
if [ -z "$have_tc" ]; then
|
|
608
|
-
if [ -f "$d\${d:+/}mypy.ini" ] || grep -qs 'tool.mypy' "$d\${d:+/}pyproject.toml"; then
|
|
609
|
-
add_gate typecheck "\${pre}py\\$" "\${run}python -m mypy ."
|
|
610
|
-
fi
|
|
611
|
-
fi
|
|
612
|
-
if [ -z "$have_test" ]; then
|
|
613
|
-
add_gate tests "\${pre}py\\$" "\${run}python -m pytest -q"
|
|
614
|
-
fi
|
|
615
|
-
fi
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
detect() {
|
|
619
|
-
# Explicit config wins outright: auto-detection is off for the whole repo.
|
|
620
|
-
if [ "$GAUNTLET_TEST" != "__auto__" ] || [ "$GAUNTLET_TYPECHECK" != "__auto__" ]; then
|
|
621
|
-
if [ "$GAUNTLET_TYPECHECK" != "__auto__" ] && [ -n "$GAUNTLET_TYPECHECK" ]; then
|
|
622
|
-
add_gate typecheck '.' "$GAUNTLET_TYPECHECK"
|
|
623
|
-
fi
|
|
624
|
-
if [ "$GAUNTLET_TEST" != "__auto__" ] && [ -n "$GAUNTLET_TEST" ]; then
|
|
625
|
-
add_gate tests '.' "$GAUNTLET_TEST"
|
|
626
|
-
fi
|
|
627
|
-
return
|
|
628
|
-
fi
|
|
629
|
-
|
|
630
|
-
add_dir_gates ""
|
|
631
|
-
|
|
632
|
-
# Many repos keep no manifest at the root — the code lives in web/, api/,
|
|
633
|
-
# backend/, packages/*. Look two levels down, but only for the kind of gate the
|
|
634
|
-
# root did not already provide, so a repo that works today keeps working.
|
|
635
|
-
ROOT_TC="$TC_GATES"
|
|
636
|
-
ROOT_TEST="$TEST_GATES"
|
|
637
|
-
while IFS= read -r manifest; do
|
|
638
|
-
[ -z "$manifest" ] && continue
|
|
639
|
-
d=$(dirname "$manifest")
|
|
640
|
-
d=\${d#./}
|
|
641
|
-
[ "$d" = "." ] && continue
|
|
642
|
-
add_dir_gates "$d"
|
|
643
|
-
done <<< "$(find . -mindepth 2 -maxdepth 3 \\
|
|
644
|
-
\\( -name package.json -o -name pyproject.toml -o -name pytest.ini \\) \\
|
|
645
|
-
-not -path '*/node_modules/*' -not -path '*/.git/*' \\
|
|
646
|
-
-not -path '*/dist/*' -not -path '*/build/*' \\
|
|
647
|
-
-not -path '*/.venv/*' -not -path '*/venv/*' 2>/dev/null | sort)"
|
|
648
|
-
}
|
|
649
|
-
detect
|
|
650
|
-
|
|
651
|
-
# Typechecks before tests — cheapest first, so the slow gate is usually unpaid.
|
|
652
|
-
GATES="$TC_GATES$TEST_GATES"
|
|
653
|
-
if [ -n "\${GAUNTLET_DRYRUN:-}" ]; then
|
|
654
|
-
if [ -z "$GATES" ]; then quit "would run: nothing — no gates detected"; fi
|
|
655
|
-
quit "would run: $(printf '%s' "$GATES" | cut -f1,3 | tr '\\t' ' ' | tr '\\n' '; ')"
|
|
656
|
-
fi
|
|
657
|
-
if [ -z "$GATES" ]; then
|
|
658
|
-
if [ -n "$SKIP_NOTE" ]; then quit "skipped: $SKIP_NOTE"; fi
|
|
659
|
-
quit "skipped: no gates detected for this repo"
|
|
660
|
-
fi
|
|
661
|
-
|
|
662
|
-
# ------------------------------------------------------------------ the gates
|
|
663
|
-
FAILED=""
|
|
664
|
-
OUTPUT=""
|
|
665
|
-
NO_TEST_MATCH=""
|
|
666
|
-
RAN=""
|
|
667
|
-
# Kept in variables on purpose: a bare \`}\` inside a \${var//pat/repl} replacement
|
|
668
|
-
# closes the expansion early and silently mangles the command.
|
|
669
|
-
FILES_REPL='"\${FILE_ARR[@]}"'
|
|
670
|
-
PAT_BRACE='\${FILES}'
|
|
671
|
-
PAT_PLAIN='$FILES'
|
|
672
|
-
|
|
673
|
-
while IFS=$'\\t' read -r GKIND GEXT GCMD; do
|
|
674
|
-
[ -z "$GKIND" ] && continue
|
|
675
|
-
GFILES=$(printf '%s\\n' "$FILES_NL" | grep -E "$GEXT" || true)
|
|
676
|
-
[ -z "$GFILES" ] && continue
|
|
677
|
-
# $FILES is substituted as a real argument list, not a space-joined string —
|
|
678
|
-
# word splitting would tear "src/my folder/a.ts" into two bogus paths.
|
|
679
|
-
mapfile -t REL_ARR <<< "$GFILES"
|
|
680
|
-
FILE_ARR=()
|
|
681
|
-
for f in "\${REL_ARR[@]}"; do FILE_ARR+=("$PROJECT_DIR/$f"); done
|
|
682
|
-
export FILES=$(printf '%s' "$GFILES" | tr '\\n' ' ')
|
|
683
|
-
GCMD_RUN=\${GCMD//"$PAT_BRACE"/$FILES_REPL}
|
|
684
|
-
GCMD_RUN=\${GCMD_RUN//"$PAT_PLAIN"/$FILES_REPL}
|
|
685
|
-
RAN="$RAN$GKIND "
|
|
686
|
-
GATE_OUT=$(eval "$GCMD_RUN" 2>&1)
|
|
687
|
-
if [ $? -ne 0 ]; then
|
|
688
|
-
# A gate whose tool is not installed is a missing gate, not a failing one.
|
|
689
|
-
# Blocking on it would wall off every turn until the user installs something.
|
|
690
|
-
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
|
|
691
|
-
SKIP_NOTE="the $GKIND tool is not installed"
|
|
692
|
-
RAN="\${RAN%"$GKIND "}"
|
|
693
|
-
continue
|
|
694
|
-
fi
|
|
695
|
-
FAILED="$GKIND"
|
|
696
|
-
OUTPUT="$GATE_OUT"
|
|
697
|
-
break
|
|
698
|
-
fi
|
|
699
|
-
# A runner handed files it has no tests for exits 0 and looks exactly like a
|
|
700
|
-
# pass. It is the loudest false green: the changed code was never executed.
|
|
701
|
-
if [ "$GKIND" = "tests" ] \\
|
|
702
|
-
&& printf '%s' "$GATE_OUT" | grep -qiE 'no test files found|no tests found|no tests ran'; then
|
|
703
|
-
NO_TEST_MATCH="$GCMD"
|
|
704
|
-
OUTPUT="$GATE_OUT"
|
|
705
|
-
fi
|
|
706
|
-
done <<< "$GATES"
|
|
707
|
-
|
|
708
|
-
if [ -z "$FAILED" ] && [ -n "$NO_TEST_MATCH" ] && [ -n "\${GAUNTLET_REQUIRE_TESTS:-}" ]; then
|
|
709
|
-
FAILED="tests"
|
|
710
|
-
OUTPUT="$OUTPUT
|
|
711
|
-
|
|
712
|
-
The runner matched 0 test files for the changed code, so nothing was executed.
|
|
713
|
-
Write a test that covers the change, or unset GAUNTLET_REQUIRE_TESTS."
|
|
714
|
-
fi
|
|
715
|
-
|
|
716
|
-
if [ -z "$FAILED" ]; then
|
|
717
|
-
printf '%s' "$DIFF_HASH" > "$MARKER"
|
|
718
|
-
if [ -z "$RAN" ]; then
|
|
719
|
-
if [ -n "$SKIP_NOTE" ]; then quit "skipped: $SKIP_NOTE"; fi
|
|
720
|
-
quit "skipped: no gate applied to the changed files"
|
|
721
|
-
elif [ -n "$SKIP_NOTE" ]; then
|
|
722
|
-
quit "green: gates passed ($RAN) — but $SKIP_NOTE"
|
|
723
|
-
elif [ -n "$NO_TEST_MATCH" ]; then
|
|
724
|
-
quit "green: gates passed, but a runner matched 0 test files — that code was never executed"
|
|
725
|
-
fi
|
|
726
|
-
quit "green: gates passed ($RAN)"
|
|
727
|
-
fi
|
|
728
|
-
|
|
729
|
-
# ------------------------------------------------------------------ block red
|
|
730
|
-
# Last 60 lines only — the hook's reason is read by a model, not archived.
|
|
731
|
-
TAIL=$(printf '%s\\n' "$OUTPUT" | tail -60)
|
|
732
|
-
MSG="GAUNTLET FAILED: the $FAILED gate is red on the changed files.
|
|
733
|
-
|
|
734
|
-
Fix it before ending the turn. Do not weaken or delete a test to make this pass.
|
|
735
|
-
If the failure is unrelated to your change, say so explicitly and stop.
|
|
736
|
-
|
|
737
|
-
Changed code files:
|
|
738
|
-
$FILES
|
|
739
|
-
|
|
740
|
-
--- $FAILED output (last 60 lines) ---
|
|
741
|
-
$TAIL"
|
|
742
|
-
|
|
743
|
-
printf '%s\\n' "red: the $FAILED gate failed" > "$WHY" 2>/dev/null
|
|
744
|
-
|
|
745
|
-
# Exit 2 is the only path where a red is visible to the user: its stderr is shown
|
|
746
|
-
# to them as the reason the turn is continuing. Exit 0 with {"decision":"continue"}
|
|
747
|
-
# also keeps the turn going, but the reason reaches Claude alone — so a failing
|
|
748
|
-
# gate looked, from the outside, exactly like a gauntlet that does nothing.
|
|
749
|
-
printf '%s\\n' "$MSG" >&2
|
|
750
|
-
exit 2
|
|
751
|
-
`;
|
|
752
|
-
|
|
753
|
-
// The deterministic half of /ship's quality gate: file length and mutation,
|
|
754
|
-
// behind an exit code rather than in prose a model can talk past.
|
|
755
|
-
// Canonical source: scripts/ship-gate.sh.
|
|
756
|
-
const SHIP_GATE_SCRIPT = `#!/usr/bin/env bash
|
|
757
|
-
# ship-gate.sh — the deterministic half of /ship's quality gate.
|
|
758
|
-
#
|
|
759
|
-
# File length and mutation are not judgement calls, so they do not live in prose
|
|
760
|
-
# where a model can decide they are not worth it. They live here, behind an exit
|
|
761
|
-
# code. /ship runs this and obeys the result; the skills audit stays in the
|
|
762
|
-
# command, because that one genuinely needs a model.
|
|
763
|
-
#
|
|
764
|
-
# bash .claude/hooks/ship-gate.sh [base-ref]
|
|
765
|
-
# bash .claude/hooks/ship-gate.sh --key print the receipt key and stop
|
|
766
|
-
# bash .claude/hooks/ship-gate.sh --force write a FORCED receipt, run nothing
|
|
767
|
-
# bash .claude/hooks/ship-gate.sh --baseline record today's mutmut survivors as
|
|
768
|
-
# accepted debt, write no receipt
|
|
769
|
-
#
|
|
770
|
-
# On completion it writes a RECEIPT at /tmp/claude-shipgate-<key>, and the
|
|
771
|
-
# PreToolUse hook refuses \`git commit\` / \`git push\` without a matching one. The
|
|
772
|
-
# point is that a gate has to be evidence rather than an instruction: a sentence
|
|
773
|
-
# saying "run this first" is a sentence an agent can decide is not worth it, and
|
|
774
|
-
# then report a pass it never earned. A receipt cannot be reported, only produced.
|
|
775
|
-
#
|
|
776
|
-
# The key is the content of everything about to ship, so it is the same before
|
|
777
|
-
# and after \`git commit\` — and it changes the moment anything is edited, which is
|
|
778
|
-
# what makes a fix re-gate itself instead of riding on the previous verdict.
|
|
779
|
-
#
|
|
780
|
-
# Exit codes:
|
|
781
|
-
# 0 clean — nothing over the line limit, nothing survived, nothing uncovered
|
|
782
|
-
# 1 findings that must be dealt with before shipping
|
|
783
|
-
# 2 ran, but could not prove the tests (a mutation tool is missing) — report,
|
|
784
|
-
# don't block; the output names exactly what to install and where
|
|
785
|
-
#
|
|
786
|
-
# Config: the same .claude/gauntlet.conf as the Stop hook.
|
|
787
|
-
# GAUNTLET_MAX_LINES=200 the per-file limit; 0 turns the check off
|
|
788
|
-
# GAUNTLET_SOURCE_EXT=... which extensions count as source. Deliberately NOT
|
|
789
|
-
# GAUNTLET_CODE_EXT: the Stop hook's key answers a
|
|
790
|
-
# different question, and a docs repo sets it to .md
|
|
791
|
-
# GAUNTLET_IGNORE_EXT=... extensions that need no gate at all. An extension in
|
|
792
|
-
# neither list is reported UNPROVEN rather than passed
|
|
793
|
-
# GAUNTLET_IGNORE_FILES=.. space-separated globs, matched against /<path>, for
|
|
794
|
-
# files nobody wrote: generated code and vendored
|
|
795
|
-
# components. They are named in the output, never
|
|
796
|
-
# dropped silently. Set it to "" to gate everything
|
|
797
|
-
# GAUNTLET_MUTATE="cmd" one explicit mutation command for the whole repo,
|
|
798
|
-
# replacing per-project detection. $MUTATE_FLAGS holds
|
|
799
|
-
# the --mutate flags, $FILES the changed files.
|
|
800
|
-
|
|
801
|
-
set -uo pipefail
|
|
802
|
-
|
|
803
|
-
MODE=""
|
|
804
|
-
case "\${1:-}" in
|
|
805
|
-
--key) MODE=key; shift ;;
|
|
806
|
-
--force) MODE=force; shift ;;
|
|
807
|
-
--baseline) MODE=baseline; shift ;;
|
|
808
|
-
esac
|
|
809
|
-
|
|
810
|
-
PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
811
|
-
cd "$PROJECT_DIR" 2>/dev/null || { echo "ship-gate: cannot read $PROJECT_DIR"; exit 1; }
|
|
812
|
-
git rev-parse --git-dir >/dev/null 2>&1 || { echo "ship-gate: not a git repo"; exit 1; }
|
|
813
|
-
|
|
814
|
-
# \`git diff --name-only\` prints paths from the repo ROOT, but CLAUDE_PROJECT_DIR
|
|
815
|
-
# can point at a subdirectory of it. Testing those paths from the subdirectory
|
|
816
|
-
# found no files at all, so the gate said "nothing to check" and wrote a PASS
|
|
817
|
-
# receipt over a diff it never looked at. Work from the root and the paths git
|
|
818
|
-
# prints are the paths this script tests. The receipt key moves with it, and the
|
|
819
|
-
# PreToolUse hook asks this script for the key rather than computing its own, so
|
|
820
|
-
# the two cannot disagree.
|
|
821
|
-
ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
822
|
-
if [ -n "$ROOT" ]; then
|
|
823
|
-
cd "$ROOT" || { echo "ship-gate: cannot read $ROOT"; exit 1; }
|
|
824
|
-
PROJECT_DIR="$ROOT"
|
|
825
|
-
fi
|
|
826
|
-
|
|
827
|
-
GAUNTLET_MAX_LINES=200
|
|
828
|
-
GAUNTLET_MUTATE=""
|
|
829
|
-
GAUNTLET_SOURCE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|gd"
|
|
830
|
-
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"
|
|
831
|
-
GAUNTLET_IGNORE_FILES="*.gen.ts *.gen.tsx *.generated.* */migrations/*.py */alembic/versions/*.py */components/ui/*.tsx */*.config.*"
|
|
832
|
-
# Length-checked and skill-audited like everything else, but not mutated.
|
|
833
|
-
# Mutation pays on logic and burns time on presentation: a component's mutants
|
|
834
|
-
# are class names, copy and JSX shape, none of which is behaviour, and one
|
|
835
|
-
# session spent an evening killing them. Logic that lives inside a component is
|
|
836
|
-
# logic that wants extracting — which the 200-line rule already pushes toward.
|
|
837
|
-
# Override in .claude/gauntlet.conf to mutate components too.
|
|
838
|
-
GAUNTLET_NO_MUTATE='\\.(tsx|jsx)$'
|
|
839
|
-
[ -n "\${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ] && . "$HOME/.claude/gauntlet.conf"
|
|
840
|
-
[ -f ".claude/gauntlet.conf" ] && . ".claude/gauntlet.conf"
|
|
841
|
-
|
|
842
|
-
# ------------------------------------------------------------------- the scope
|
|
843
|
-
BASE="\${1:-}"
|
|
844
|
-
if [ -z "$BASE" ]; then
|
|
845
|
-
BASE=$(git merge-base HEAD develop 2>/dev/null \\
|
|
846
|
-
|| git merge-base HEAD main 2>/dev/null \\
|
|
847
|
-
|| git rev-list --max-parents=0 HEAD | head -1)
|
|
848
|
-
fi
|
|
849
|
-
|
|
850
|
-
CHANGED=$( { git diff "$BASE"...HEAD --name-only 2>/dev/null
|
|
851
|
-
git diff HEAD --name-only 2>/dev/null
|
|
852
|
-
git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )
|
|
853
|
-
|
|
854
|
-
# Only the files this gate can actually read go into the key. It used to be every
|
|
855
|
-
# changed file, so a version bump in package.json or a line in a changelog threw
|
|
856
|
-
# away a receipt the gate would have re-earned unchanged — fifteen minutes of
|
|
857
|
-
# mutation testing to prove nothing about the file that moved, at the one moment
|
|
858
|
-
# a release is trying to ship. An ignored extension is one no check here opens,
|
|
859
|
-
# so it cannot change a verdict. Unknown extensions stay in, because UNPROVEN is
|
|
860
|
-
# a verdict about exactly those.
|
|
861
|
-
KEYED=$(printf '%s\\n' "$CHANGED" | grep -viE "\\.($GAUNTLET_IGNORE_EXT)$")
|
|
862
|
-
|
|
863
|
-
# The repo path plus the names and contents of the changed files, in a fixed
|
|
864
|
-
# order. The path is in there because the key is otherwise pure content: two
|
|
865
|
-
# repos holding the same file would share a receipt, and one would be waved
|
|
866
|
-
# through on the other's verdict. Deliberately not a diff: a file moves from "untracked" to "in the diff" at commit time, so a
|
|
867
|
-
# diff-based key changes when the content did not, and the receipt would go stale
|
|
868
|
-
# the moment it was committed. This is identical either side of a commit, and
|
|
869
|
-
# changes the instant any of those files is edited.
|
|
870
|
-
RECEIPT_KEY=$( { printf '%s\\n' "$PROJECT_DIR"
|
|
871
|
-
printf '%s\\n' "$KEYED" | while IFS= read -r f; do
|
|
872
|
-
[ -n "$f" ] || continue
|
|
873
|
-
printf '%s\\n' "$f"
|
|
874
|
-
[ -f "$f" ] && cat "$f"
|
|
875
|
-
done; } | git hash-object --stdin )
|
|
876
|
-
RECEIPT="/tmp/claude-shipgate-$RECEIPT_KEY"
|
|
877
|
-
|
|
878
|
-
if [ "$MODE" = key ]; then printf '%s\\n' "$RECEIPT_KEY"; exit 0; fi
|
|
879
|
-
if [ "$MODE" = force ]; then
|
|
880
|
-
printf 'FORCED %s\\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
|
|
881
|
-
echo "ship-gate: FORCED — receipt written without running any check."
|
|
882
|
-
exit 0
|
|
883
|
-
fi
|
|
884
|
-
SOURCED=$(printf '%s\\n' "$CHANGED" | grep -E "\\.($GAUNTLET_SOURCE_EXT)$" \\
|
|
885
|
-
| grep -vE '(\\.|_)(test|spec)\\.[^.]+$|(^|/)tests?/|(^|/)\\.stryker-tmp/' | while read -r f; do
|
|
886
|
-
[ -f "$f" ] && printf '%s\\n' "$f"
|
|
887
|
-
done)
|
|
888
|
-
|
|
889
|
-
# A generated file cannot be split and a vendored one is not ours to split, so
|
|
890
|
-
# the length check fires on every route regeneration and teaches people to
|
|
891
|
-
# --force. A gate that is always forced is not a gate. These are held out of
|
|
892
|
-
# both checks — and printed, because a silent skip is the same lie as a fake
|
|
893
|
-
# PASS. The leading / is what lets one pattern match components/ui at the repo
|
|
894
|
-
# root and apps/web/src/components/ui alike.
|
|
895
|
-
#
|
|
896
|
-
# */*.config.* is here for the same reason, and became urgent the moment an
|
|
897
|
-
# uncovered line started failing the gate: vitest.config.ts has no tests, will
|
|
898
|
-
# never have tests, and is not a thing anyone writes a test for. Left in, every
|
|
899
|
-
# diff that touches a config reports findings nobody can close.
|
|
900
|
-
# set -f matters: an unquoted list is glob-expanded before it is split, so
|
|
901
|
-
# */components/ui/*.tsx turned itself into the very path it was meant to match
|
|
902
|
-
# and then matched nothing.
|
|
903
|
-
is_ignored() {
|
|
904
|
-
set -f
|
|
905
|
-
for pat in $GAUNTLET_IGNORE_FILES; do
|
|
906
|
-
case "/$1" in $pat) set +f; return 0 ;; esac
|
|
907
|
-
done
|
|
908
|
-
set +f
|
|
909
|
-
return 1
|
|
910
|
-
}
|
|
911
|
-
FILES=$(printf '%s\\n' "$SOURCED" | while IFS= read -r f; do
|
|
912
|
-
[ -n "$f" ] && ! is_ignored "$f" && printf '%s\\n' "$f"
|
|
913
|
-
done)
|
|
914
|
-
IGNORED=$(printf '%s\\n' "$SOURCED" | while IFS= read -r f; do
|
|
915
|
-
[ -n "$f" ] && is_ignored "$f" && printf '%s\\n' "$f"
|
|
916
|
-
done)
|
|
917
|
-
if [ -n "$IGNORED" ]; then
|
|
918
|
-
echo "ship-gate: not gated, matched GAUNTLET_IGNORE_FILES:"
|
|
919
|
-
printf '%s\\n' "$IGNORED" | sed 's/^/ /'
|
|
920
|
-
fi
|
|
921
|
-
|
|
922
|
-
if [ -z "$FILES" ]; then
|
|
923
|
-
# "I recognised nothing" is not "there is nothing", and the gate used to report
|
|
924
|
-
# both as a PASS. A Godot repo changed only .gd files, which no extension in
|
|
925
|
-
# the list named, so zero source files were found and a PASS receipt was
|
|
926
|
-
# written over work nothing had checked. An extension this gate cannot place is
|
|
927
|
-
# UNPROVEN: it does not block, but it never claims a pass either.
|
|
928
|
-
UNKNOWN=$(printf '%s\\n' "$CHANGED" | grep -E '\\.[A-Za-z0-9]+$' \\
|
|
929
|
-
| grep -viE "\\.($GAUNTLET_SOURCE_EXT)$" \\
|
|
930
|
-
| grep -viE "\\.($GAUNTLET_IGNORE_EXT)$" \\
|
|
931
|
-
| sed 's/.*\\.//' | sort -u | tr '\\n' ' ')
|
|
932
|
-
if [ -n "$UNKNOWN" ]; then
|
|
933
|
-
echo "ship-gate: UNPROVEN — nothing here was checked. These changed files are"
|
|
934
|
-
echo " in a language this gate does not know: $UNKNOWN"
|
|
935
|
-
echo " If they are source, add the extension in .claude/gauntlet.conf:"
|
|
936
|
-
echo " GAUNTLET_SOURCE_EXT=\\"\\$GAUNTLET_SOURCE_EXT|\${UNKNOWN%% *}\\""
|
|
937
|
-
echo " If they are not, add it to GAUNTLET_IGNORE_EXT the same way."
|
|
938
|
-
printf 'UNPROVEN %s unknown-extensions\\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
|
|
939
|
-
exit 2
|
|
940
|
-
fi
|
|
941
|
-
echo "ship-gate: no changed code files — nothing to check"
|
|
942
|
-
printf 'PASS %s no-code-changes\\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
|
|
943
|
-
exit 0
|
|
944
|
-
fi
|
|
945
|
-
|
|
946
|
-
echo "ship-gate: $(printf '%s\\n' "$FILES" | wc -l) changed code file(s), base $(git rev-parse --short "$BASE")"
|
|
947
|
-
echo
|
|
948
|
-
STATUS=0
|
|
949
|
-
|
|
950
|
-
# ------------------------------------------------- check 1: file length (hard)
|
|
951
|
-
OVER=""
|
|
952
|
-
LARGEST=0
|
|
953
|
-
if [ "$GAUNTLET_MAX_LINES" -gt 0 ]; then
|
|
954
|
-
while IFS= read -r f; do
|
|
955
|
-
n=$(wc -l < "$f")
|
|
956
|
-
[ "$n" -gt "$LARGEST" ] && LARGEST=$n
|
|
957
|
-
[ "$n" -gt "$GAUNTLET_MAX_LINES" ] && OVER="$OVER $f — $n lines (limit $GAUNTLET_MAX_LINES)
|
|
958
|
-
"
|
|
959
|
-
done <<< "$FILES"
|
|
960
|
-
fi
|
|
961
|
-
|
|
962
|
-
if [ "$GAUNTLET_MAX_LINES" -le 0 ]; then
|
|
963
|
-
echo "FILE LENGTH — off (GAUNTLET_MAX_LINES=0)"
|
|
964
|
-
elif [ -n "$OVER" ]; then
|
|
965
|
-
echo "FILE LENGTH — over the limit:"
|
|
966
|
-
printf '%s' "$OVER"
|
|
967
|
-
echo " Splitting a file is a design decision. Do it deliberately, or ship with --force."
|
|
968
|
-
STATUS=1
|
|
969
|
-
else
|
|
970
|
-
echo "FILE LENGTH — ok, largest is $LARGEST lines"
|
|
971
|
-
fi
|
|
972
|
-
|
|
973
|
-
# ---------------------------------------------------------- which project owns
|
|
974
|
-
# A monorepo holds several projects, each with its own runner and its own
|
|
975
|
-
# mutation tool. Walk up from each changed file to the nearest manifest.
|
|
976
|
-
# mutmut is installed into the project's environment, not onto PATH. Ask the
|
|
977
|
-
# project how to run its own tools before falling back to a bare binary.
|
|
978
|
-
py_mutmut() {
|
|
979
|
-
d="$1"
|
|
980
|
-
# Returned relative to the PROJECT, because the command runs from inside it.
|
|
981
|
-
# Tests that need a database, a queue, or any other service cannot run on the
|
|
982
|
-
# host: mutmut still generates every mutant and reports them all "not
|
|
983
|
-
# checked", which prints exactly like a clean run. An executable .mutmut-run
|
|
984
|
-
# in the project runs mutmut wherever those services are — typically
|
|
985
|
-
# \`docker compose run\` against the app container — and takes precedence over
|
|
986
|
-
# every host-local option below.
|
|
987
|
-
if [ -x "$d.mutmut-run" ]; then printf './.mutmut-run\\n'; return; fi
|
|
988
|
-
if [ -x "$d.venv/bin/mutmut" ]; then printf './.venv/bin/mutmut\\n'; return; fi
|
|
989
|
-
if [ -f "$d""uv.lock" ] && command -v uv >/dev/null 2>&1; then printf 'uv run mutmut\\n'; return; fi
|
|
990
|
-
if [ -f "$d""poetry.lock" ] && command -v poetry >/dev/null 2>&1; then printf 'poetry run mutmut\\n'; return; fi
|
|
991
|
-
command -v mutmut >/dev/null 2>&1 && printf 'mutmut\\n'
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
owner_of() {
|
|
995
|
-
d=$(dirname "$1")
|
|
996
|
-
while :; do
|
|
997
|
-
if [ -f "$d/package.json" ] || [ -f "$d/pyproject.toml" ] \\
|
|
998
|
-
|| [ -f "$d/pytest.ini" ] || [ -f "$d/setup.cfg" ]; then
|
|
999
|
-
printf '%s\\n' "\${d#./}"; return
|
|
1000
|
-
fi
|
|
1001
|
-
[ "$d" = "." ] || [ "$d" = "/" ] && { printf '.\\n'; return; }
|
|
1002
|
-
d=$(dirname "$d")
|
|
1003
|
-
done
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
OWNERS=$(while IFS= read -r f; do owner_of "$f"; done <<< "$FILES" | sort -u)
|
|
1007
|
-
|
|
1008
|
-
# --------------------------------------------- check 2: mutation, per project
|
|
1009
|
-
echo
|
|
1010
|
-
echo "MUTATION — $(printf '%s\\n' "$OWNERS" | wc -l) project(s) in this diff"
|
|
1011
|
-
MISSING=""
|
|
1012
|
-
|
|
1013
|
-
for owner in $OWNERS; do
|
|
1014
|
-
[ "$owner" = "." ] && label="<repo root>" || label="$owner/"
|
|
1015
|
-
OWNED=$(while IFS= read -r f; do
|
|
1016
|
-
[ "$(owner_of "$f")" = "$owner" ] && printf '%s\\n' "$f"
|
|
1017
|
-
done <<< "$FILES" | grep -vE "$GAUNTLET_NO_MUTATE" || true)
|
|
1018
|
-
[ -z "$OWNED" ] && continue
|
|
1019
|
-
|
|
1020
|
-
# ONE comma-joined --mutate value, never a flag per range. Stryker's CLI
|
|
1021
|
-
# parses --mutate with \`(val) => val.split(",")\`, a coercion that ignores the
|
|
1022
|
-
# previous value, so a repeated flag OVERRIDES instead of appending and only
|
|
1023
|
-
# the last one survives. Measured on two files: the repeated form found 1 of
|
|
1024
|
-
# 1808 files and 28 mutants, the joined form 2 files and 51. Every run since
|
|
1025
|
-
# this gate shipped mutated exactly one file and called the rest proven — and
|
|
1026
|
-
# when that one file had no tests, Stryker exited on "No tests were executed"
|
|
1027
|
-
# and the gate reported UNPROVEN, which writes a receipt and blocks nothing.
|
|
1028
|
-
FLAGS=""
|
|
1029
|
-
GLOBBED=","
|
|
1030
|
-
while IFS= read -r f; do
|
|
1031
|
-
hunks=$(git diff -U0 "$BASE" -- "$f" 2>/dev/null \\
|
|
1032
|
-
| grep -oE '^@@ -[0-9,]+ \\+[0-9]+(,[0-9]+)?' | sed 's/.*+//' \\
|
|
1033
|
-
| while IFS=, read -r s l; do l=\${l:-1}; [ "$l" -gt 0 ] && echo "$s-$((s+l-1))"; done)
|
|
1034
|
-
[ -z "$hunks" ] && hunks="1-$(wc -l < "$f")"
|
|
1035
|
-
rel=\${f#$owner/}
|
|
1036
|
-
# A Next.js route folder — [id], [[...slug]] — is a glob expression to
|
|
1037
|
-
# Stryker, which refuses "Cannot combine a glob expression with a mutation
|
|
1038
|
-
# range" and fails the entire run, not just that file. Escaping the brackets
|
|
1039
|
-
# stops the path resolving at all. Replacing each with ? keeps it matching
|
|
1040
|
-
# that one file and drops the range, so the file is mutated whole: a
|
|
1041
|
-
# superset of the diff, slower than a range but never less than what changed.
|
|
1042
|
-
case "$rel" in
|
|
1043
|
-
*[][*?{}]*) FLAGS="$FLAGS,$(printf '%s' "$rel" | sed 's/[][*?{}]/?/g')"
|
|
1044
|
-
GLOBBED="$GLOBBED$rel," ;;
|
|
1045
|
-
*) for r in $hunks; do FLAGS="$FLAGS,$rel:$r"; done ;;
|
|
1046
|
-
esac
|
|
1047
|
-
done <<< "$OWNED"
|
|
1048
|
-
|
|
1049
|
-
base=""; RUN=""
|
|
1050
|
-
if [ "$owner" != "." ]; then base="$owner/"; RUN="cd '$owner' && "; fi
|
|
1051
|
-
if [ -n "$GAUNTLET_MUTATE" ]; then
|
|
1052
|
-
CMD="$GAUNTLET_MUTATE"; TOOL="config"
|
|
1053
|
-
elif [ -f "$base"package.json ] && grep -qs '@stryker-mutator/core' "$base"package.json; then
|
|
1054
|
-
TOOL="stryker"
|
|
1055
|
-
# From inside the owning package: npx resolves against ITS node_modules, and
|
|
1056
|
-
# stryker.config.json lives there too. A monorepo root usually has neither.
|
|
1057
|
-
#
|
|
1058
|
-
# Deliberately NOT --incremental. --force does rerun every mutant in scope,
|
|
1059
|
-
# so the diff itself was always judged fresh — but incremental mode still
|
|
1060
|
-
# merges reports/stryker-incremental.json into the REPORT for everything
|
|
1061
|
-
# out of scope, and this gate greps the whole report. Survivors recorded
|
|
1062
|
-
# months ago came back as findings against a diff that never touched them:
|
|
1063
|
-
# 8 of them on one commit, all long since killed. --mutate already scopes
|
|
1064
|
-
# the run to the changed hunks, so incremental buys nothing here and costs
|
|
1065
|
-
# a cache that goes stale exactly the way mutmut's did.
|
|
1066
|
-
CMD="\${RUN}npx --no-install stryker run --mutate '\${FLAGS#,}'"
|
|
1067
|
-
elif [ -f "$base"package.json ]; then
|
|
1068
|
-
MISSING="$MISSING $label needs Stryker:
|
|
1069
|
-
npm --prefix \${owner} i -D @stryker-mutator/core @stryker-mutator/vitest-runner
|
|
1070
|
-
then \${base}stryker.config.json:
|
|
1071
|
-
{\\"testRunner\\":\\"vitest\\",\\"plugins\\":[\\"@stryker-mutator/vitest-runner\\"],\\"coverageAnalysis\\":\\"perTest\\"}
|
|
1072
|
-
"
|
|
1073
|
-
continue
|
|
1074
|
-
elif [ -n "$(py_mutmut "$base")" ]; then
|
|
1075
|
-
TOOL="mutmut"
|
|
1076
|
-
# mutmut takes its scope from [tool.mutmut] in pyproject, not from a flag:
|
|
1077
|
-
# --paths-to-mutate was 2.x, and 3.x filters by fnmatch globs over mutant
|
|
1078
|
-
# NAMES (app.balance.reserve.*). There is no per-line scoping at all.
|
|
1079
|
-
#
|
|
1080
|
-
# \`mutmut run\` prints 🙁 for a survivor and exits 0 either way — parsing it
|
|
1081
|
-
# reports clean with survivors sitting there, which is a false green and
|
|
1082
|
-
# worse than reporting nothing. \`mutmut results\` is the readable source:
|
|
1083
|
-
# it prints "<mutant name>: survived" per survivor.
|
|
1084
|
-
M="$(py_mutmut "$base")"
|
|
1085
|
-
# mutmut caches verdicts in ./mutants and only invalidates one when the
|
|
1086
|
-
# SOURCE of that function changes. Adding a test does not, so it replays the
|
|
1087
|
-
# old "survived" and calls mutants the new tests kill still alive — measured
|
|
1088
|
-
# at 20 of them on one commit. \`results\` then prints that whole cache, so
|
|
1089
|
-
# survivors turn up for files nowhere near the diff. Deleting the dir first
|
|
1090
|
-
# makes both describe the tree being shipped; \`mutmut run\` rewrites it
|
|
1091
|
-
# anyway, and a cold run is ~35s for ~1000 mutants.
|
|
1092
|
-
# One cd for all three: they run in the same shell, already there.
|
|
1093
|
-
CMD="\${RUN}rm -rf mutants; $M run >/dev/null 2>&1; $M results"
|
|
1094
|
-
else
|
|
1095
|
-
MISSING="$MISSING $label needs mutmut:
|
|
1096
|
-
uv add --dev mutmut # or: poetry add --group dev mutmut, pip install mutmut
|
|
1097
|
-
then \${base}pyproject.toml:
|
|
1098
|
-
[tool.mutmut]
|
|
1099
|
-
source_paths = [\\"src/\\"]
|
|
1100
|
-
pytest_add_cli_args_test_selection = [\\"tests/\\"]
|
|
1101
|
-
(mutmut 3 renamed these — paths_to_mutate/tests_dir are 2.x and are ignored)
|
|
1102
|
-
"
|
|
1103
|
-
continue
|
|
1104
|
-
fi
|
|
1105
|
-
|
|
1106
|
-
OUT=$(eval "$CMD" 2>&1); RC=$?
|
|
1107
|
-
# Match a finding, never a summary row. Stryker prints a \`# survived\` COLUMN
|
|
1108
|
-
# HEADER every run and repeats the word in its table, so a bare grep reports
|
|
1109
|
-
# survivors on a clean run — worse than not running at all. Stryker marks each
|
|
1110
|
-
# real finding \`[Survived]\`; table and border lines are excluded outright.
|
|
1111
|
-
# NoCoverage is printed exactly like Survived and means something worse: not
|
|
1112
|
-
# "a test missed this mutation" but "no test executes this line at all". The
|
|
1113
|
-
# gate matched only [Survived], so a diff whose files have no tests came back
|
|
1114
|
-
# PASS — 306 uncovered mutants reported as proven on one commit. It is judged
|
|
1115
|
-
# only for files that went in WITH a range: a glob-shaped path is mutated
|
|
1116
|
-
# whole, so its untouched lines would report uncovered forever, and a finding
|
|
1117
|
-
# nobody can close is what teaches people to --force.
|
|
1118
|
-
#
|
|
1119
|
-
# The status line carries no file name; the line under it does, as
|
|
1120
|
-
# file:line:column. They are paired here rather than grepped separately.
|
|
1121
|
-
if [ "$TOOL" = stryker ]; then
|
|
1122
|
-
ALL=$(printf '%s\\n' "$OUT" | awk -v globbed="$GLOBBED" '
|
|
1123
|
-
/^\\[Survived\\]/ { pend = $0; kind = "s"; next }
|
|
1124
|
-
/^\\[NoCoverage\\]/ { pend = $0; kind = "n"; next }
|
|
1125
|
-
pend != "" {
|
|
1126
|
-
file = $0; sub(/:[0-9]+:[0-9]+$/, "", file)
|
|
1127
|
-
if (kind == "s" || index(globbed, "," file ",") == 0) print pend " " $0
|
|
1128
|
-
pend = ""
|
|
1129
|
-
}
|
|
1130
|
-
# A survivor with no location under it is still a survivor, never dropped.
|
|
1131
|
-
END { if (pend != "" && kind == "s") print pend }
|
|
1132
|
-
')
|
|
1133
|
-
else
|
|
1134
|
-
ALL=$(printf '%s\\n' "$OUT" | grep -E '[Ss]urvived' \\
|
|
1135
|
-
| grep -vE '^[[:space:]]*[#│|+-]|# *surviv')
|
|
1136
|
-
fi
|
|
1137
|
-
# The detail list is capped so one bad file cannot bury the output. The cap
|
|
1138
|
-
# used to be the whole story, and it hid the WORST file: 20 findings from one
|
|
1139
|
-
# file filled it, and a second file with 54 uncovered mutants never appeared
|
|
1140
|
-
# at all. A gate whose biggest finding is invisible gets under-fixed. Every
|
|
1141
|
-
# file with findings is now named below the cut, worst first.
|
|
1142
|
-
FOUND=$(printf '%s\\n' "$ALL" | grep -c .)
|
|
1143
|
-
SURVIVED=$(printf '%s\\n' "$ALL" | head -20)
|
|
1144
|
-
FIXED=0
|
|
1145
|
-
NOTE=""
|
|
1146
|
-
|
|
1147
|
-
# Stryker takes --mutate per changed hunk, so it already answers "did YOUR
|
|
1148
|
-
# change get tested". mutmut cannot be asked that — 3.x dropped 1.x's
|
|
1149
|
-
# --use-patch-file and never replaced it — so it mutates all of source_paths
|
|
1150
|
-
# and reports the repo's entire backlog. Held to the same bar, the Python half
|
|
1151
|
-
# answers "is this repo perfect" instead, which it never is, so it could never
|
|
1152
|
-
# go green. The baseline supplies the missing half: survivors recorded once
|
|
1153
|
-
# are accepted debt, and only a name that is not in it fails the gate.
|
|
1154
|
-
#
|
|
1155
|
-
# A mutant name carries an index within its own function, not a line number,
|
|
1156
|
-
# so editing elsewhere in the file does not rename it — and editing the
|
|
1157
|
-
# function itself DOES, which correctly re-charges its survivors to that edit.
|
|
1158
|
-
if [ "$TOOL" = mutmut ]; then
|
|
1159
|
-
BL="$base.mutmut-baseline"
|
|
1160
|
-
NOWF=$(mktemp)
|
|
1161
|
-
printf '%s\\n' "$OUT" \\
|
|
1162
|
-
| sed -n 's/^[[:space:]]*\\([^[:space:]]*\\): survived$/\\1/p' | sort -u > "$NOWF"
|
|
1163
|
-
|
|
1164
|
-
# When the tests need a service the run cannot reach — a database, a queue
|
|
1165
|
-
# — mutmut still generates every mutant and records it "not checked". No
|
|
1166
|
-
# survivors come out, so the gate printed "nothing survived" and, with no
|
|
1167
|
-
# baseline yet, wrote an EMPTY one and told the user to commit it. That
|
|
1168
|
-
# repo's Python gate could never fail again.
|
|
1169
|
-
#
|
|
1170
|
-
# Counting kills cannot catch it: \`mutmut results\` SKIPS killed mutants
|
|
1171
|
-
# unless --all (mutmut/__main__.py results()), so a perfect suite and a
|
|
1172
|
-
# suite that ran nothing both print nothing. The status string is what
|
|
1173
|
-
# separates them — "not checked" is exit code None in status_by_exit_code,
|
|
1174
|
-
# meaning the mutant was never executed at all.
|
|
1175
|
-
NOTCHECKED=$(printf '%s\\n' "$OUT" | grep -c ': not checked$')
|
|
1176
|
-
if [ "$NOTCHECKED" -gt 0 ]; then
|
|
1177
|
-
echo " $label mutmut — UNPROVEN: $NOTCHECKED mutant(s) were never run."
|
|
1178
|
-
echo " They are recorded \\"not checked\\", so the suite proved nothing"
|
|
1179
|
-
echo " about them. Usually the tests need a service this run cannot"
|
|
1180
|
-
echo " reach — a database, a queue. Put an executable .mutmut-run in"
|
|
1181
|
-
echo " \${base}that runs mutmut where those services are, typically"
|
|
1182
|
-
echo " docker compose run against the app container; the gate uses"
|
|
1183
|
-
echo " it ahead of every host-local option."
|
|
1184
|
-
[ "$STATUS" = 0 ] && STATUS=2
|
|
1185
|
-
rm -f "$NOWF"
|
|
1186
|
-
continue
|
|
1187
|
-
fi
|
|
1188
|
-
|
|
1189
|
-
if [ "$MODE" = baseline ]; then
|
|
1190
|
-
cp "$NOWF" "$BL"
|
|
1191
|
-
NOTE=" $label mutmut — baseline set: $(wc -l < "$BL") survivor(s) accepted. Commit $BL."
|
|
1192
|
-
SURVIVED=""
|
|
1193
|
-
elif [ -f "$BL" ]; then
|
|
1194
|
-
# -Fxv against an empty baseline reports every survivor, which is right:
|
|
1195
|
-
# an empty baseline means nothing has been accepted.
|
|
1196
|
-
SURVIVED=$(grep -Fxv -f "$BL" "$NOWF" | head -20)
|
|
1197
|
-
FIXED=$(grep -Fxvc -f "$NOWF" "$BL")
|
|
1198
|
-
else
|
|
1199
|
-
cp "$NOWF" "$BL"
|
|
1200
|
-
NOTE=" $label mutmut — no baseline, so nothing could be compared. Recorded
|
|
1201
|
-
$(wc -l < "$BL") existing survivor(s) in $BL — commit it, then run the
|
|
1202
|
-
gate again. From here only NEW survivors fail; --baseline accepts more."
|
|
1203
|
-
SURVIVED=""
|
|
1204
|
-
[ "$STATUS" = 0 ] && STATUS=2
|
|
1205
|
-
fi
|
|
1206
|
-
rm -f "$NOWF"
|
|
1207
|
-
fi
|
|
1208
|
-
|
|
1209
|
-
if [ -n "$SURVIVED" ]; then
|
|
1210
|
-
echo " $label $TOOL — these lines can break and no test notices:"
|
|
1211
|
-
printf '%s\\n' "$SURVIVED" | sed 's/^/ /'
|
|
1212
|
-
if [ "$FOUND" -gt 20 ]; then
|
|
1213
|
-
echo " ... $((FOUND - 20)) more not shown. Every file with findings:"
|
|
1214
|
-
if [ "$TOOL" = stryker ]; then
|
|
1215
|
-
printf '%s\\n' "$ALL" \\
|
|
1216
|
-
| awk '{ f = $NF; sub(/:[0-9]+:[0-9]+$/, "", f); n[f]++ }
|
|
1217
|
-
END { for (k in n) printf "%8d %s\\n", n[k], k }' \\
|
|
1218
|
-
| sort -rn | sed 's/^/ /'
|
|
1219
|
-
fi
|
|
1220
|
-
fi
|
|
1221
|
-
[ "$FIXED" -gt 0 ] && echo " ($FIXED baselined survivor(s) now killed — --baseline banks them)"
|
|
1222
|
-
STATUS=1
|
|
1223
|
-
elif [ $RC -ne 0 ]; then
|
|
1224
|
-
echo " $label $TOOL — the run failed:"
|
|
1225
|
-
printf '%s\\n' "$OUT" | tail -12 | sed 's/^/ /'
|
|
1226
|
-
[ "$STATUS" = 0 ] && STATUS=2
|
|
1227
|
-
elif [ -n "$NOTE" ]; then
|
|
1228
|
-
printf '%s\\n' "$NOTE"
|
|
1229
|
-
else
|
|
1230
|
-
echo " $label $TOOL — ok, nothing survived and nothing was uncovered"
|
|
1231
|
-
[ "$FIXED" -gt 0 ] && echo " ($FIXED baselined survivor(s) now killed — --baseline banks them)"
|
|
1232
|
-
fi
|
|
1233
|
-
done
|
|
1234
|
-
|
|
1235
|
-
if [ -n "$MISSING" ]; then
|
|
1236
|
-
echo " UNPROVEN — no mutation tool for these, so their tests were never shown"
|
|
1237
|
-
echo " to catch a break. Install per project:"
|
|
1238
|
-
printf '%s' "$MISSING"
|
|
1239
|
-
[ "$STATUS" = 0 ] && STATUS=2
|
|
1240
|
-
fi
|
|
1241
|
-
|
|
1242
|
-
if [ "$MODE" = baseline ]; then
|
|
1243
|
-
echo
|
|
1244
|
-
echo "ship-gate: baselines updated. No receipt written — run the gate for real."
|
|
1245
|
-
exit 0
|
|
1246
|
-
fi
|
|
1247
|
-
|
|
1248
|
-
# The receipt is written by this script and nothing else. A hand-rolled check
|
|
1249
|
-
# produces no receipt, which is the whole point: substituting a weaker check is
|
|
1250
|
-
# a choice that can be argued for, but it cannot be passed off as this one.
|
|
1251
|
-
echo
|
|
1252
|
-
case $STATUS in
|
|
1253
|
-
0) echo "ship-gate: PASS"
|
|
1254
|
-
printf 'PASS %s\\n' "$(date -u +%FT%TZ)" > "$RECEIPT" ;;
|
|
1255
|
-
1) echo "ship-gate: FAIL — deal with the findings above, then run this again."
|
|
1256
|
-
echo " To ship anyway: bash .claude/hooks/ship-gate.sh --force"
|
|
1257
|
-
rm -f "$RECEIPT" ;;
|
|
1258
|
-
2) echo "ship-gate: UNPROVEN — nothing is wrong, but nothing was proven either"
|
|
1259
|
-
printf 'UNPROVEN %s\\n' "$(date -u +%FT%TZ)" > "$RECEIPT" ;;
|
|
1260
|
-
esac
|
|
1261
|
-
exit $STATUS
|
|
1262
|
-
`;
|
|
1263
|
-
|
|
1264
|
-
// PreToolUse on Bash: refuses git commit/push without a ship-gate receipt.
|
|
1265
|
-
// Canonical source: scripts/ship-gate-hook.sh.
|
|
1266
|
-
const SHIP_GATE_HOOK_SCRIPT = `#!/usr/bin/env bash
|
|
1267
|
-
# ship-gate-hook.sh — PreToolUse on Bash. Refuses the commands that publish work
|
|
1268
|
-
# unless ship-gate.sh has left a receipt for exactly these changes.
|
|
1269
|
-
#
|
|
1270
|
-
# A gate written as an instruction is a gate an agent can decide is not worth the
|
|
1271
|
-
# time — and then report a pass it never earned, which is the part you cannot see
|
|
1272
|
-
# from the outside. This removes the claim entirely: either the receipt exists for
|
|
1273
|
-
# this exact content, or git does not run.
|
|
1274
|
-
#
|
|
1275
|
-
# The receipt key covers everything about to ship, so it survives \`git commit\`
|
|
1276
|
-
# and still matches at push time, and it changes the moment any file is edited —
|
|
1277
|
-
# a fix has to be re-gated instead of riding on the previous verdict.
|
|
1278
|
-
#
|
|
1279
|
-
# It gated \`git commit\` and \`git push\` until 2.23.0, and that was the wrong
|
|
1280
|
-
# moment. The gate's scope is merge-base..HEAD — the whole branch, because the
|
|
1281
|
-
# whole branch is what a PR ships — so a branch touching 46 files re-mutated all
|
|
1282
|
-
# 46 on every commit. Fifteen minutes, twenty times, for one branch. Neither a
|
|
1283
|
-
# commit nor a push to a feature branch publishes anything, so neither is gated
|
|
1284
|
-
# now: the cost is paid once, at the point work actually ships.
|
|
1285
|
-
|
|
1286
|
-
INPUT=$(cat)
|
|
1287
|
-
|
|
1288
|
-
CMD=$(printf '%s' "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' \\
|
|
1289
|
-
| head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
1290
|
-
|
|
1291
|
-
PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
1292
|
-
cd "$PROJECT_DIR" 2>/dev/null || exit 0
|
|
1293
|
-
|
|
1294
|
-
# Opening or merging a PR publishes. So does pushing while standing on a
|
|
1295
|
-
# protected branch — that is a direct ship with no PR in front of it. The branch
|
|
1296
|
-
# is read from git rather than parsed out of the command, because \`git push\` with
|
|
1297
|
-
# no arguments names no branch at all.
|
|
1298
|
-
case "$CMD" in
|
|
1299
|
-
*"gh pr create"*|*"gh pr merge"*) ;;
|
|
1300
|
-
*"git push"*)
|
|
1301
|
-
BRANCH=$(git branch --show-current 2>/dev/null)
|
|
1302
|
-
case "$BRANCH" in
|
|
1303
|
-
main|master|develop|development|dev) ;;
|
|
1304
|
-
*) exit 0 ;;
|
|
1305
|
-
esac
|
|
1306
|
-
;;
|
|
1307
|
-
*) exit 0 ;;
|
|
1308
|
-
esac
|
|
1309
|
-
|
|
1310
|
-
GATE="$PROJECT_DIR/.claude/hooks/ship-gate.sh"
|
|
1311
|
-
[ -x "$GATE" ] || exit 0
|
|
1312
|
-
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
|
1313
|
-
|
|
1314
|
-
# Ask the gate for the key rather than recomputing it here. Two implementations
|
|
1315
|
-
# of the same rule drift, and the drift is silent.
|
|
1316
|
-
KEY=$(bash "$GATE" --key 2>/dev/null)
|
|
1317
|
-
[ -z "$KEY" ] && exit 0
|
|
1318
|
-
|
|
1319
|
-
RECEIPT="/tmp/claude-shipgate-$KEY"
|
|
1320
|
-
if [ -f "$RECEIPT" ]; then
|
|
1321
|
-
exit 0
|
|
1322
|
-
fi
|
|
1323
|
-
|
|
1324
|
-
MSG="BLOCKED: no ship-gate receipt for these changes.
|
|
1325
|
-
|
|
1326
|
-
The quality gate has not run against the code you are about to publish, or the
|
|
1327
|
-
code changed since it last did. Run it:
|
|
1328
|
-
|
|
1329
|
-
bash .claude/hooks/ship-gate.sh
|
|
1330
|
-
|
|
1331
|
-
It checks file length and mutation-tests the changed lines, and writes a receipt
|
|
1332
|
-
this hook can see. Fix what it reports and run it again — a fix invalidates the
|
|
1333
|
-
previous receipt on purpose.
|
|
1334
|
-
|
|
1335
|
-
Do NOT hand-roll a substitute check: only ship-gate.sh writes a receipt, so a
|
|
1336
|
-
weaker check you designed yourself cannot be passed off as this one.
|
|
1337
|
-
|
|
1338
|
-
To publish without the gate, say so out loud and run:
|
|
1339
|
-
|
|
1340
|
-
bash .claude/hooks/ship-gate.sh --force"
|
|
1341
|
-
|
|
1342
|
-
json_escape() {
|
|
1343
|
-
local s="$1"
|
|
1344
|
-
s="\${s//\\\\/\\\\\\\\}"
|
|
1345
|
-
s="\${s//\\"/\\\\\\"}"
|
|
1346
|
-
s="\${s//$'\\n'/\\\\n}"
|
|
1347
|
-
s="\${s//$'\\r'/\\\\r}"
|
|
1348
|
-
s="\${s//$'\\t'/\\\\t}"
|
|
1349
|
-
printf '"%s"' "$s"
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\\n' "$(json_escape "$MSG")"
|
|
1353
|
-
exit 0
|
|
1354
|
-
`;
|
|
1355
|
-
|
|
1356
|
-
// SessionStart: a once-a-day line when a newer catalog has been published.
|
|
1357
|
-
// Canonical source: scripts/version-check.sh.
|
|
1358
|
-
const VERSION_CHECK_SCRIPT = `#!/usr/bin/env bash
|
|
1359
|
-
# version-check.sh — SessionStart. Says once a day when a newer catalog exists.
|
|
1360
|
-
#
|
|
1361
|
-
# SessionStart stdout is added to the model's context, so this does not draw a
|
|
1362
|
-
# banner: it tells the assistant, and the assistant mentions it. That is the
|
|
1363
|
-
# whole reach of this hook — it never installs anything and never asks a
|
|
1364
|
-
# question the user has to answer before working.
|
|
1365
|
-
#
|
|
1366
|
-
# The rule that keeps it invisible: NEVER touch the network in the foreground.
|
|
1367
|
-
# A session start that waits on npm is a session start that hangs on a bad
|
|
1368
|
-
# connection, and a version nudge is not worth one second of that. So it reads
|
|
1369
|
-
# a cache written by the PREVIOUS session's background refresh, and kicks off
|
|
1370
|
-
# the next refresh detached. The first session after an install therefore says
|
|
1371
|
-
# nothing, and after that the answer is at worst a day old — which is the right
|
|
1372
|
-
# resolution for "there is a new version" anyway.
|
|
1373
|
-
#
|
|
1374
|
-
# Silent when: no manifest, no cache yet, offline, already current, or the user
|
|
1375
|
-
# has already been told today.
|
|
1376
|
-
#
|
|
1377
|
-
# CLAUDE_SKILLS_NO_VERSION_CHECK=1 turns it off entirely
|
|
1378
|
-
|
|
1379
|
-
set -uo pipefail
|
|
1380
|
-
|
|
1381
|
-
[ -n "\${CLAUDE_SKILLS_NO_VERSION_CHECK:-}" ] && exit 0
|
|
1382
|
-
|
|
1383
|
-
PKG="@spardutti/claude-skills"
|
|
1384
|
-
PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
1385
|
-
MANIFEST="$PROJECT_DIR/.claude/.claude-skills.json"
|
|
1386
|
-
|
|
1387
|
-
# No manifest means the CLI never installed here, so there is nothing to update.
|
|
1388
|
-
[ -f "$MANIFEST" ] || exit 0
|
|
1389
|
-
|
|
1390
|
-
INSTALLED=$(sed -n 's/.*"catalogVersion"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' "$MANIFEST" | head -1)
|
|
1391
|
-
[ -n "$INSTALLED" ] || exit 0
|
|
1392
|
-
|
|
1393
|
-
# One cache for the whole machine, not one per project: the answer is the same
|
|
1394
|
-
# everywhere, and a developer with ten repos should still cost one request.
|
|
1395
|
-
CACHE_DIR="\${HOME:-/tmp}/.claude"
|
|
1396
|
-
CACHE="$CACHE_DIR/.claude-skills-version"
|
|
1397
|
-
STAMP="$CACHE_DIR/.claude-skills-version-told"
|
|
1398
|
-
mkdir -p "$CACHE_DIR" 2>/dev/null
|
|
1399
|
-
|
|
1400
|
-
NOW=$(date +%s 2>/dev/null || echo 0)
|
|
1401
|
-
DAY=86400
|
|
1402
|
-
|
|
1403
|
-
# Refresh in the background, fully detached. Nothing downstream waits on it: all
|
|
1404
|
-
# three streams are closed, or the session start blocks until curl gives up.
|
|
1405
|
-
refresh() {
|
|
1406
|
-
AGE=$DAY
|
|
1407
|
-
if [ -f "$CACHE" ]; then
|
|
1408
|
-
MTIME=$(date -r "$CACHE" +%s 2>/dev/null || echo 0)
|
|
1409
|
-
AGE=$((NOW - MTIME))
|
|
1410
|
-
fi
|
|
1411
|
-
[ "$AGE" -lt "$DAY" ] && return
|
|
1412
|
-
command -v curl >/dev/null 2>&1 || return
|
|
1413
|
-
( curl -fsS --max-time 5 "https://registry.npmjs.org/$PKG/latest" \\
|
|
1414
|
-
| sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' \\
|
|
1415
|
-
| head -1 > "$CACHE.tmp" 2>/dev/null
|
|
1416
|
-
# Only replace the cache with something that parsed. A truncated reply that
|
|
1417
|
-
# overwrote it would nudge toward a version that does not exist.
|
|
1418
|
-
if [ -s "$CACHE.tmp" ]; then mv "$CACHE.tmp" "$CACHE"; else rm -f "$CACHE.tmp"; fi
|
|
1419
|
-
) </dev/null >/dev/null 2>&1 &
|
|
1420
|
-
}
|
|
1421
|
-
refresh
|
|
1422
|
-
|
|
1423
|
-
[ -f "$CACHE" ] || exit 0
|
|
1424
|
-
LATEST=$(head -1 "$CACHE" | tr -d '[:space:]')
|
|
1425
|
-
[ -n "$LATEST" ] || exit 0
|
|
1426
|
-
[ "$LATEST" = "$INSTALLED" ] && exit 0
|
|
1427
|
-
|
|
1428
|
-
# sort -V puts the newer one last. Equal versions were already handled, so the
|
|
1429
|
-
# only case left to reject is a cache that is BEHIND the install — which happens
|
|
1430
|
-
# normally right after publishing from the machine that publishes.
|
|
1431
|
-
NEWER=$(printf '%s\\n%s\\n' "$INSTALLED" "$LATEST" | sort -V | tail -1)
|
|
1432
|
-
[ "$NEWER" = "$LATEST" ] || exit 0
|
|
1433
|
-
|
|
1434
|
-
# Once a day, however many sessions that day holds.
|
|
1435
|
-
if [ -f "$STAMP" ]; then
|
|
1436
|
-
TOLD=$(date -r "$STAMP" +%s 2>/dev/null || echo 0)
|
|
1437
|
-
[ $((NOW - TOLD)) -lt "$DAY" ] && exit 0
|
|
1438
|
-
fi
|
|
1439
|
-
: > "$STAMP"
|
|
1440
|
-
|
|
1441
|
-
echo "A newer version of the $PKG catalog is available: $INSTALLED installed, $LATEST published."
|
|
1442
|
-
echo "Mention this to the user ONCE, in a single line, and only when the current task is done."
|
|
1443
|
-
echo "Do not interrupt what they are doing, and do not run the installer unless they ask."
|
|
1444
|
-
echo "To update, they run: npx $PKG"
|
|
1445
|
-
exit 0
|
|
1446
|
-
`;
|
|
8
|
+
// The scripts themselves live in scripts/ at the repo root and are read from
|
|
9
|
+
// disk, never pasted in here. What each one does is documented at the top of
|
|
10
|
+
// its own file:
|
|
11
|
+
//
|
|
12
|
+
// skill-gate.sh PreToolUse — blocks edits until the skills the
|
|
13
|
+
// edited file's stack demands are loaded
|
|
14
|
+
// skill-gate-automark.sh PostToolUse on Skill — records what was loaded
|
|
15
|
+
// skill-application-gate.sh PreToolUse — requires each loaded skill to be
|
|
16
|
+
// explicitly applied before the first edit
|
|
17
|
+
// gauntlet.sh Stop — fast typecheck and tests on the diff
|
|
18
|
+
// ship-gate.sh file length and mutation, behind an exit code
|
|
19
|
+
// ship-gate-hook.sh PreToolUse on Bash — no commit without a receipt
|
|
20
|
+
// version-check.sh SessionStart — a line when a newer catalog exists
|
|
1447
21
|
|
|
1448
22
|
const GATE_FILENAME = "skill-gate.sh";
|
|
1449
23
|
const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
|
|
@@ -1455,6 +29,27 @@ const VERSION_CHECK_FILENAME = "version-check.sh";
|
|
|
1455
29
|
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
1456
30
|
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
1457
31
|
|
|
32
|
+
// The hook scripts used to live here as template literals — a second copy of
|
|
33
|
+
// every file in scripts/, which is why preflight needed a step to check the two
|
|
34
|
+
// had not drifted, why editing a hook meant escaping backslashes, backticks and
|
|
35
|
+
// ${, and why this file was 1740 lines. They are read from disk now, so there
|
|
36
|
+
// is one copy and nothing to drift.
|
|
37
|
+
//
|
|
38
|
+
// Two locations, because there are two ways this runs. `npm pack` copies
|
|
39
|
+
// scripts/*.sh into cli/hooks/ (see the prepack script), so the published
|
|
40
|
+
// package has them beside lib/. Running from a clone has no cli/hooks/, and
|
|
41
|
+
// falls back to the repo's scripts/ — which is the same file the self-tests and
|
|
42
|
+
// preflight read, so a test cannot pass against a copy the package would not
|
|
43
|
+
// ship.
|
|
44
|
+
async function readHookSource(name) {
|
|
45
|
+
const packaged = new URL(`../hooks/${name}`, import.meta.url);
|
|
46
|
+
try {
|
|
47
|
+
return await readFile(packaged, "utf-8");
|
|
48
|
+
} catch {
|
|
49
|
+
return await readFile(new URL(`../../scripts/${name}`, import.meta.url), "utf-8");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
1458
53
|
// Asks the installed hook what it would run, rather than reimplementing its
|
|
1459
54
|
// detection here. A second copy drifts: the JS one reported "no test runner"
|
|
1460
55
|
// for repos the shell script had already learned to handle.
|
|
@@ -1510,30 +105,22 @@ function upsert(list, filename, entry) {
|
|
|
1510
105
|
export async function setupHook(targetDir = process.cwd()) {
|
|
1511
106
|
const resolved = resolve(targetDir);
|
|
1512
107
|
const hooksDir = join(resolved, ".claude", "hooks");
|
|
1513
|
-
const gatePath = join(hooksDir, GATE_FILENAME);
|
|
1514
|
-
const autoMarkPath = join(hooksDir, AUTO_MARK_FILENAME);
|
|
1515
|
-
const applicationGatePath = join(hooksDir, APPLICATION_GATE_FILENAME);
|
|
1516
|
-
const gauntletPath = join(hooksDir, GAUNTLET_FILENAME);
|
|
1517
|
-
const shipGatePath = join(hooksDir, SHIP_GATE_FILENAME);
|
|
1518
|
-
const shipGateHookPath = join(hooksDir, SHIP_GATE_HOOK_FILENAME);
|
|
1519
|
-
const versionCheckPath = join(hooksDir, VERSION_CHECK_FILENAME);
|
|
1520
108
|
const settingsPath = join(resolved, ".claude", "settings.json");
|
|
1521
109
|
|
|
1522
110
|
await mkdir(hooksDir, { recursive: true });
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
await chmod(versionCheckPath, 0o755);
|
|
111
|
+
for (const name of [
|
|
112
|
+
GATE_FILENAME,
|
|
113
|
+
AUTO_MARK_FILENAME,
|
|
114
|
+
APPLICATION_GATE_FILENAME,
|
|
115
|
+
GAUNTLET_FILENAME,
|
|
116
|
+
SHIP_GATE_FILENAME,
|
|
117
|
+
SHIP_GATE_HOOK_FILENAME,
|
|
118
|
+
VERSION_CHECK_FILENAME,
|
|
119
|
+
]) {
|
|
120
|
+
const dest = join(hooksDir, name);
|
|
121
|
+
await writeFile(dest, await readHookSource(name), { mode: 0o755 });
|
|
122
|
+
await chmod(dest, 0o755);
|
|
123
|
+
}
|
|
1537
124
|
|
|
1538
125
|
// Remove the legacy audit-runner hook file from prior installs (2.3.x).
|
|
1539
126
|
try {
|