@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.
@@ -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
- // PreToolUse gate on Write|Edit|MultiEdit. Blocks the tool call unless
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
- // Pass-through cases:
17
- // - project has no .claude/skills/*/SKILL.md files
18
- // - session_id missing from hook input
19
- const GATE_SCRIPT = `#!/bin/bash
20
- # PreToolUse gate: forces skill evaluation before file-writing tools run.
21
-
22
- INPUT=$(cat)
23
-
24
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
25
- PROJECT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
26
-
27
- if ! find "$PROJECT_DIR" -path '*/.claude/skills/*/SKILL.md' 2>/dev/null | grep -q .; then
28
- exit 0
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
- await writeFile(gatePath, GATE_SCRIPT, { mode: 0o755 });
1524
- await chmod(gatePath, 0o755);
1525
- await writeFile(autoMarkPath, AUTO_MARK_SCRIPT, { mode: 0o755 });
1526
- await chmod(autoMarkPath, 0o755);
1527
- await writeFile(applicationGatePath, APPLICATION_GATE_SCRIPT, { mode: 0o755 });
1528
- await chmod(applicationGatePath, 0o755);
1529
- await writeFile(gauntletPath, GAUNTLET_SCRIPT, { mode: 0o755 });
1530
- await chmod(gauntletPath, 0o755);
1531
- await writeFile(shipGatePath, SHIP_GATE_SCRIPT, { mode: 0o755 });
1532
- await chmod(shipGatePath, 0o755);
1533
- await writeFile(shipGateHookPath, SHIP_GATE_HOOK_SCRIPT, { mode: 0o755 });
1534
- await chmod(shipGateHookPath, 0o755);
1535
- await writeFile(versionCheckPath, VERSION_CHECK_SCRIPT, { mode: 0o755 });
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 {