@spardutti/claude-skills 2.9.0 → 2.10.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/README.md CHANGED
@@ -174,8 +174,13 @@ It registers in `.claude/settings.json`:
174
174
  ### Automatic Verification (Gauntlet)
175
175
 
176
176
  The same install adds `gauntlet.sh` — a `Stop` hook that runs the cheap, deterministic
177
- gates on your changed files every time Claude finishes a turn. Green is silent; red
178
- blocks the turn and hands the failure back to Claude to fix before you ever see it.
177
+ gates on your changed files every time Claude finishes a turn. Green is silent; red returns
178
+ `{"decision":"continue"}` so the turn doesn't end, handing the failure back to Claude to
179
+ fix before you ever see it.
180
+
181
+ Keep the gates fast for a reason beyond patience: a `command` hook that reaches its timeout
182
+ (600s by default) is cancelled and its **output discarded**, so the turn ends as if the hook
183
+ never ran. A stalled gate is a silent pass, not a block.
179
184
 
180
185
  Only fast gates live here (target: under ~30s). Mutation testing and deep review stay
181
186
  manual — they belong to `/test-review` and `/deep-review`, not to every turn.
@@ -197,12 +202,44 @@ It runs a skip ladder first, cheapest check first, and quits at the first "no":
197
202
 
198
203
  1. no changed files (planning turns, questions)
199
204
  2. no changed **code** files (docs/config only)
200
- 3. this exact diff already passed — the hash of a green run is cached per session
201
- 4. no test runner detected in the repo
205
+ 3. this exact diff already passed — the hash of a green run is cached per session and repo
206
+ 4. no gates detected for this repo
202
207
 
203
208
  Rule 3 is what makes repeated `/ship` runs free: the gates run once per distinct diff.
204
209
 
205
- The stack is auto-detected (vitest / jest / pytest, `tsc` / `mypy`). Override anything in
210
+ Every run records why it ended in `/tmp/claude-gauntlet-<session>.why`, so a green run
211
+ and a skipped one are told apart without re-running the hook. `GAUNTLET_DEBUG=1` prints
212
+ the same line to stderr:
213
+
214
+ ```text
215
+ gauntlet: green: gates passed
216
+ gauntlet: skipped: no changed code files (docs/config only)
217
+ gauntlet: green: gates passed, but the runner matched 0 test files — the changed code was never executed
218
+ ```
219
+
220
+ That last one is the false green worth knowing about. A test runner given files it has no
221
+ tests for exits 0 and looks exactly like a pass — in a monorepo whose vitest workspace
222
+ excludes a package, **no** change in that package can ever be covered. The hook names it
223
+ instead of reporting green. Set `GAUNTLET_REQUIRE_TESTS=1` to make it block instead.
224
+
225
+ The stack is auto-detected: vitest / jest / pytest for tests, and for typechecking the
226
+ repo's own `typecheck` script if it has one, else bare `tsc --noEmit` or `mypy`. The script
227
+ is preferred because a workspace root `tsconfig.json` is often solution-style, where
228
+ `tsc --noEmit` does not mean what the repo's `tsc -b` means.
229
+
230
+ **Monorepos with more than one stack work.** Detection collects every gate it finds rather
231
+ than stopping at the first, and each gate carries the file pattern it applies to — so a
232
+ JS + Python repo gets four gates, and a turn that touched only `.py` files runs the Python
233
+ two and skips the JS two entirely. Typechecks always run before tests, and the first red
234
+ stops the rest.
235
+
236
+ Setting `GAUNTLET_TEST` or `GAUNTLET_TYPECHECK` switches the repo to explicit mode:
237
+ auto-detection is off and only what you set runs.
238
+
239
+ **A missing tool is a skip, not a red.** A fresh clone with no `node_modules`, or a venv
240
+ without pytest, would otherwise fail every gate and block every turn — so the hook reports
241
+ `skipped: node_modules is missing — run your install first` and gets out of the way. Only
242
+ a gate that actually ran and actually failed blocks. Override anything in
206
243
  a config file — two are read, in this order, and the project's wins key by key:
207
244
 
208
245
  ```text
@@ -215,6 +252,8 @@ GAUNTLET_OFF=1 # disable entirely
215
252
  GAUNTLET_TYPECHECK='npm run check' # "" to skip the gate
216
253
  GAUNTLET_TEST='npm test -- $FILES' # $FILES = changed code files
217
254
  GAUNTLET_CODE_EXT='ts|tsx|py' # what counts as code
255
+ GAUNTLET_DEBUG=1 # print the outcome to stderr every run
256
+ GAUNTLET_REQUIRE_TESTS=1 # block when the runner matched 0 test files
218
257
  ```
219
258
 
220
259
  Set your house rules once in `~/.claude/gauntlet.conf`; only reach for the project file
@@ -161,8 +161,15 @@ exit 0
161
161
  const GAUNTLET_SCRIPT = `#!/usr/bin/env bash
162
162
  # gauntlet.sh — Stop hook: fast deterministic gates on the changed diff.
163
163
  #
164
- # Runs when Claude finishes a turn. Silent when green. Blocks the turn when a
165
- # gate is red, handing the failure back to Claude to fix before the user sees it.
164
+ # Runs when Claude finishes a turn. Silent when green. On a red gate it returns
165
+ # {"decision":"continue"} so the turn does not end, handing the failure back to
166
+ # Claude to fix before the user sees it.
167
+ #
168
+ # Two things the hook contract makes non-negotiable:
169
+ # - stop_hook_active must short-circuit, or a red gate loops forever.
170
+ # - A hook that hits its timeout (600s default for command hooks) is cancelled
171
+ # and its output discarded, so the turn ends as if the hook never ran. Keep
172
+ # the gates fast; that is a silent pass, not a block.
166
173
  #
167
174
  # Only cheap gates belong here (target: under ~30s). Mutation testing, deep
168
175
  # review, and the spec checklist are the /gauntlet command's job, not this hook's.
@@ -171,15 +178,23 @@ const GAUNTLET_SCRIPT = `#!/usr/bin/env bash
171
178
  # 1. no changed files
172
179
  # 2. no changed *code* files (docs/config only)
173
180
  # 3. this exact diff already passed
174
- # 4. no test runner detected
181
+ # 4. no gates detected for this repo
175
182
  #
176
183
  # Config, both optional and both KEY=value files that get sourced in this order:
177
184
  # ~/.claude/gauntlet.conf your defaults for every project
178
185
  # <project>/.claude/gauntlet.conf overrides for this one; wins key by key
179
186
  # GAUNTLET_OFF=1 disable entirely
180
- # GAUNTLET_TYPECHECK="cmd" override the typecheck command ("" to skip)
181
- # GAUNTLET_TEST="cmd" override the test command; $FILES = changed code files
187
+ # GAUNTLET_TYPECHECK="cmd" explicit typecheck command ("" to skip)
188
+ # GAUNTLET_TEST="cmd" explicit test command; $FILES = changed code files
182
189
  # GAUNTLET_CODE_EXT="ts|tsx|py" override which extensions count as code
190
+ # GAUNTLET_DEBUG=1 print the outcome to stderr on every run
191
+ # GAUNTLET_REQUIRE_TESTS=1 block when a runner matched 0 test files
192
+ #
193
+ # Setting either command switches the whole repo to explicit mode — auto-detection
194
+ # is off, and only what you set runs.
195
+ #
196
+ # Every run records why it ended in /tmp/claude-gauntlet-<session>.why, so a green
197
+ # run and a skipped one are told apart without re-running the hook.
183
198
 
184
199
  set -uo pipefail
185
200
 
@@ -190,11 +205,24 @@ case "$INPUT" in
190
205
  *'"stop_hook_active":true'*) exit 0 ;;
191
206
  esac
192
207
 
208
+ SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 \\
209
+ | sed 's/"session_id":"//; s/"$//')
210
+ if [ -z "$SESSION_ID" ]; then SESSION_ID="nosession"; fi
211
+ MARKER="/tmp/claude-gauntlet-$SESSION_ID"
212
+ WHY="$MARKER.why"
213
+
214
+ # Records why this run ended, so "green" and "never ran" stop looking identical.
215
+ quit() {
216
+ printf '%s\\n' "$1" > "$WHY" 2>/dev/null
217
+ if [ -n "\${GAUNTLET_DEBUG:-}" ]; then printf 'gauntlet: %s\\n' "$1" >&2; fi
218
+ exit 0
219
+ }
220
+
193
221
  PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-}"
194
222
  if [ -z "$PROJECT_DIR" ]; then PROJECT_DIR="$PWD"; fi
195
- cd "$PROJECT_DIR" 2>/dev/null || exit 0
223
+ cd "$PROJECT_DIR" 2>/dev/null || quit "skipped: project dir unreadable"
196
224
 
197
- git rev-parse --git-dir >/dev/null 2>&1 || exit 0
225
+ git rev-parse --git-dir >/dev/null 2>&1 || quit "skipped: not a git repo"
198
226
 
199
227
  GAUNTLET_OFF=""
200
228
  GAUNTLET_TYPECHECK="__auto__"
@@ -203,78 +231,159 @@ GAUNTLET_CODE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|
203
231
  # Global defaults first, then the project's on top — project wins, key by key.
204
232
  if [ -n "\${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ]; then . "$HOME/.claude/gauntlet.conf"; fi
205
233
  if [ -f "$PROJECT_DIR/.claude/gauntlet.conf" ]; then . "$PROJECT_DIR/.claude/gauntlet.conf"; fi
206
- if [ -n "$GAUNTLET_OFF" ]; then exit 0; fi
234
+ if [ -n "$GAUNTLET_OFF" ]; then quit "skipped: GAUNTLET_OFF is set"; fi
207
235
 
208
236
  # ---------------------------------------------------------------- skip rule 1
209
237
  CHANGED=$( { git diff --name-only HEAD 2>/dev/null; \\
210
238
  git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )
211
- if [ -z "$CHANGED" ]; then exit 0; fi
239
+ if [ -z "$CHANGED" ]; then quit "skipped: no changed files"; fi
212
240
 
213
241
  # ---------------------------------------------------------------- skip rule 2
214
- FILES=$(printf '%s\\n' "$CHANGED" | grep -E "\\.($GAUNTLET_CODE_EXT)$" || true)
215
- if [ -z "$FILES" ]; then exit 0; fi
242
+ FILES_NL=$(printf '%s\\n' "$CHANGED" | grep -E "\\.($GAUNTLET_CODE_EXT)$" || true)
243
+ if [ -z "$FILES_NL" ]; then quit "skipped: no changed code files (docs/config only)"; fi
216
244
 
217
245
  # ---------------------------------------------------------------- skip rule 3
218
- DIFF_HASH=$( { git diff HEAD 2>/dev/null; printf '%s\\n' "$CHANGED"; } \\
246
+ # The repo path is part of the hash: one session can touch several repos, and two
247
+ # of them can legitimately hold an identical diff.
248
+ DIFF_HASH=$( { printf '%s\\n' "$PROJECT_DIR"; git diff HEAD 2>/dev/null; printf '%s\\n' "$CHANGED"; } \\
219
249
  | git hash-object --stdin 2>/dev/null )
220
- SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 \\
221
- | sed 's/"session_id":"//; s/"$//')
222
- if [ -z "$SESSION_ID" ]; then SESSION_ID="nosession"; fi
223
- MARKER="/tmp/claude-gauntlet-$SESSION_ID"
224
- if [ -f "$MARKER" ] && [ "$(cat "$MARKER")" = "$DIFF_HASH" ]; then exit 0; fi
250
+ if [ -f "$MARKER" ] && [ "$(cat "$MARKER")" = "$DIFF_HASH" ]; then
251
+ quit "skipped: this diff already passed"
252
+ fi
225
253
 
226
254
  # ---------------------------------------------------------------- skip rule 4
227
- # Detect the stack once. Auto-detection is a default, not a decision — the conf
228
- # file wins whenever a repo does something unusual.
255
+ # A monorepo can hold more than one stack, so detection collects every gate it
256
+ # finds rather than stopping at the first. Each gate carries the file pattern it
257
+ # applies to, and is skipped entirely when the diff touched none of those files —
258
+ # so a Python-only change never runs the JS suite, and vice versa.
259
+ TC_GATES=""
260
+ TEST_GATES=""
261
+ SKIP_NOTE=""
262
+
263
+ add_gate() { # kind (typecheck|tests), file regex, command
264
+ LINE=$(printf '%s\\t%s\\t%s' "$1" "$2" "$3")
265
+ if [ "$1" = "typecheck" ]; then
266
+ TC_GATES="$TC_GATES$LINE
267
+ "
268
+ else
269
+ TEST_GATES="$TEST_GATES$LINE
270
+ "
271
+ fi
272
+ }
273
+
274
+ JS_EXT='\\.(ts|tsx|js|jsx|mjs|cjs)$'
275
+
229
276
  detect() {
230
- if [ "$GAUNTLET_TEST" != "__auto__" ]; then return; fi
231
- GAUNTLET_TEST=""
232
- GAUNTLET_TYPECHECK=""
233
- if [ -f package.json ]; then
277
+ # Explicit config wins outright: auto-detection is off for the whole repo.
278
+ if [ "$GAUNTLET_TEST" != "__auto__" ] || [ "$GAUNTLET_TYPECHECK" != "__auto__" ]; then
279
+ if [ "$GAUNTLET_TYPECHECK" != "__auto__" ] && [ -n "$GAUNTLET_TYPECHECK" ]; then
280
+ add_gate typecheck '.' "$GAUNTLET_TYPECHECK"
281
+ fi
282
+ if [ "$GAUNTLET_TEST" != "__auto__" ] && [ -n "$GAUNTLET_TEST" ]; then
283
+ add_gate tests '.' "$GAUNTLET_TEST"
284
+ fi
285
+ return
286
+ fi
287
+
288
+ # Without node_modules every npm/npx gate fails on a missing binary, which
289
+ # would block every turn in a fresh clone. That is a missing tool, not a red.
290
+ if [ -f package.json ] && [ ! -d node_modules ]; then
291
+ SKIP_NOTE="node_modules is missing — run your install first"
292
+ elif [ -f package.json ]; then
293
+ # Prefer the repo's own typecheck script. In a workspace repo the root
294
+ # tsconfig is often solution-style (references only), where bare
295
+ # \`tsc --noEmit\` does not mean what \`tsc -b\` means.
296
+ if grep -q '"typecheck"[[:space:]]*:' package.json; then
297
+ add_gate typecheck "$JS_EXT" 'npm run --silent typecheck'
298
+ elif [ -f tsconfig.json ]; then
299
+ add_gate typecheck '\\.(ts|tsx)$' 'npx --no-install tsc --noEmit'
300
+ fi
234
301
  if grep -q '"vitest"' package.json; then
235
- GAUNTLET_TEST='npx --no-install vitest related --run $FILES'
302
+ add_gate tests "$JS_EXT" 'npx --no-install vitest related --run $FILES'
236
303
  elif grep -q '"jest"' package.json; then
237
- GAUNTLET_TEST='npx --no-install jest --findRelatedTests $FILES --passWithNoTests'
238
- fi
239
- if [ -f tsconfig.json ]; then
240
- GAUNTLET_TYPECHECK='npx --no-install tsc --noEmit'
304
+ add_gate tests "$JS_EXT" 'npx --no-install jest --findRelatedTests $FILES --passWithNoTests'
241
305
  fi
242
306
  fi
243
- if [ -z "$GAUNTLET_TEST" ] && { [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f setup.cfg ]; }; then
244
- GAUNTLET_TEST='python -m pytest -q'
307
+
308
+ if [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f setup.cfg ]; then
245
309
  if [ -f mypy.ini ] || grep -qs 'tool.mypy' pyproject.toml; then
246
- GAUNTLET_TYPECHECK='python -m mypy .'
310
+ add_gate typecheck '\\.py$' 'python -m mypy .'
247
311
  fi
312
+ add_gate tests '\\.py$' 'python -m pytest -q'
248
313
  fi
249
314
  }
250
315
  detect
251
- if [ -z "$GAUNTLET_TEST" ]; then exit 0; fi
316
+
317
+ # Typechecks before tests — cheapest first, so the slow gate is usually unpaid.
318
+ GATES="$TC_GATES$TEST_GATES"
319
+ if [ -z "$GATES" ]; then
320
+ if [ -n "$SKIP_NOTE" ]; then quit "skipped: $SKIP_NOTE"; fi
321
+ quit "skipped: no gates detected for this repo"
322
+ fi
252
323
 
253
324
  # ------------------------------------------------------------------ the gates
254
- # Cheapest first, stop at the first red. Most failures land in the first gate,
255
- # so the expensive ones are usually never paid for.
256
- export FILES=$(printf '%s' "$FILES" | tr '\\n' ' ')
257
325
  FAILED=""
258
326
  OUTPUT=""
259
-
260
- run_gate() {
261
- GATE_NAME="$1"
262
- GATE_CMD="$2"
263
- if [ -z "$GATE_CMD" ]; then return 0; fi
264
- GATE_OUT=$(eval "$GATE_CMD" 2>&1)
327
+ NO_TEST_MATCH=""
328
+ RAN=""
329
+ # Kept in variables on purpose: a bare \`}\` inside a \${var//pat/repl} replacement
330
+ # closes the expansion early and silently mangles the command.
331
+ FILES_REPL='"\${FILE_ARR[@]}"'
332
+ PAT_BRACE='\${FILES}'
333
+ PAT_PLAIN='$FILES'
334
+
335
+ while IFS=$'\\t' read -r GKIND GEXT GCMD; do
336
+ [ -z "$GKIND" ] && continue
337
+ GFILES=$(printf '%s\\n' "$FILES_NL" | grep -E "$GEXT" || true)
338
+ [ -z "$GFILES" ] && continue
339
+ # $FILES is substituted as a real argument list, not a space-joined string —
340
+ # word splitting would tear "src/my folder/a.ts" into two bogus paths.
341
+ mapfile -t FILE_ARR <<< "$GFILES"
342
+ export FILES=$(printf '%s' "$GFILES" | tr '\\n' ' ')
343
+ GCMD_RUN=\${GCMD//"$PAT_BRACE"/$FILES_REPL}
344
+ GCMD_RUN=\${GCMD_RUN//"$PAT_PLAIN"/$FILES_REPL}
345
+ RAN="$RAN$GKIND "
346
+ GATE_OUT=$(eval "$GCMD_RUN" 2>&1)
265
347
  if [ $? -ne 0 ]; then
266
- FAILED="$GATE_NAME"
348
+ # A gate whose tool is not installed is a missing gate, not a failing one.
349
+ # Blocking on it would wall off every turn until the user installs something.
350
+ 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|is not recognized'; then
351
+ SKIP_NOTE="the $GKIND tool is not installed"
352
+ RAN="\${RAN%"$GKIND "}"
353
+ continue
354
+ fi
355
+ FAILED="$GKIND"
267
356
  OUTPUT="$GATE_OUT"
268
- return 1
357
+ break
269
358
  fi
270
- return 0
271
- }
359
+ # A runner handed files it has no tests for exits 0 and looks exactly like a
360
+ # pass. It is the loudest false green: the changed code was never executed.
361
+ if [ "$GKIND" = "tests" ] \\
362
+ && printf '%s' "$GATE_OUT" | grep -qiE 'no test files found|no tests found|no tests ran'; then
363
+ NO_TEST_MATCH="$GCMD"
364
+ OUTPUT="$GATE_OUT"
365
+ fi
366
+ done <<< "$GATES"
367
+
368
+ if [ -z "$FAILED" ] && [ -n "$NO_TEST_MATCH" ] && [ -n "\${GAUNTLET_REQUIRE_TESTS:-}" ]; then
369
+ FAILED="tests"
370
+ OUTPUT="$OUTPUT
272
371
 
273
- run_gate "typecheck" "$GAUNTLET_TYPECHECK" && run_gate "tests" "$GAUNTLET_TEST"
372
+ The runner matched 0 test files for the changed code, so nothing was executed.
373
+ Write a test that covers the change, or unset GAUNTLET_REQUIRE_TESTS."
374
+ fi
274
375
 
275
376
  if [ -z "$FAILED" ]; then
276
377
  printf '%s' "$DIFF_HASH" > "$MARKER"
277
- exit 0
378
+ if [ -z "$RAN" ]; then
379
+ if [ -n "$SKIP_NOTE" ]; then quit "skipped: $SKIP_NOTE"; fi
380
+ quit "skipped: no gate applied to the changed files"
381
+ elif [ -n "$SKIP_NOTE" ]; then
382
+ quit "green: gates passed ($RAN) — but $SKIP_NOTE"
383
+ elif [ -n "$NO_TEST_MATCH" ]; then
384
+ quit "green: gates passed, but a runner matched 0 test files — that code was never executed"
385
+ fi
386
+ quit "green: gates passed ($RAN)"
278
387
  fi
279
388
 
280
389
  # ------------------------------------------------------------------ block red
@@ -301,7 +410,11 @@ json_escape() {
301
410
  printf '"%s"' "$s"
302
411
  }
303
412
 
304
- printf '{"decision":"block","reason":%s}\\n' "$(json_escape "$MSG")"
413
+ printf '%s\\n' "red: the $FAILED gate failed" > "$WHY" 2>/dev/null
414
+ if [ -n "\${GAUNTLET_DEBUG:-}" ]; then printf 'gauntlet: red: the %s gate failed\\n' "$FAILED" >&2; fi
415
+ # A Stop hook's decision is "continue" / "stop" / "escalate" — "block" is not a
416
+ # valid value and would be ignored, letting the turn end on a red gate.
417
+ printf '{"decision":"continue","reason":%s}\\n' "$(json_escape "$MSG")"
305
418
  exit 0
306
419
  `;
307
420
 
@@ -330,7 +443,8 @@ export async function detectStack(targetDir = process.cwd()) {
330
443
  if (pkg) {
331
444
  if (pkg.includes('"vitest"')) test = "vitest";
332
445
  else if (pkg.includes('"jest"')) test = "jest";
333
- if (await has("tsconfig.json")) typecheck = "tsc";
446
+ if (/"typecheck"\s*:/.test(pkg)) typecheck = "npm run typecheck";
447
+ else if (await has("tsconfig.json")) typecheck = "tsc";
334
448
  }
335
449
  if (!test && (await has("pyproject.toml") || await has("pytest.ini") || await has("setup.cfg"))) {
336
450
  test = "pytest";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "CLI to install Claude Code skills from the claude-skills collection",
5
5
  "type": "module",
6
6
  "bin": {