@spardutti/claude-skills 2.9.0 → 2.11.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,50 @@ 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 work, including the ones with nothing at the root.** Detection collects every
231
+ gate it finds rather than stopping at the first, and each gate carries the file pattern it
232
+ applies to — so a JS + Python repo gets four gates, and a turn that touched only `.py` files
233
+ runs the Python two and skips the JS two entirely. Typechecks always run before tests, and
234
+ the first red stops the rest.
235
+
236
+ When the root has no manifest, detection looks two levels down for `package.json` /
237
+ `pyproject.toml` / `pytest.ini` — the `web/` + `api/`, `apps/*`, `packages/*` layouts — and
238
+ scopes each gate to that folder's files, running the command from inside it. Nested gates
239
+ are only added for the kind the root did not already provide, so a repo that works today
240
+ keeps working.
241
+
242
+ Setting `GAUNTLET_TEST` or `GAUNTLET_TYPECHECK` switches the repo to explicit mode:
243
+ auto-detection is off and only what you set runs.
244
+
245
+ **A missing tool is a skip, not a red.** A fresh clone with no `node_modules`, or a venv
246
+ without pytest, would otherwise fail every gate and block every turn — so the hook reports
247
+ `skipped: node_modules is missing — run your install first` and gets out of the way. Only
248
+ a gate that actually ran and actually failed blocks. Override anything in
206
249
  a config file — two are read, in this order, and the project's wins key by key:
207
250
 
208
251
  ```text
@@ -215,6 +258,8 @@ GAUNTLET_OFF=1 # disable entirely
215
258
  GAUNTLET_TYPECHECK='npm run check' # "" to skip the gate
216
259
  GAUNTLET_TEST='npm test -- $FILES' # $FILES = changed code files
217
260
  GAUNTLET_CODE_EXT='ts|tsx|py' # what counts as code
261
+ GAUNTLET_DEBUG=1 # print the outcome to stderr every run
262
+ GAUNTLET_REQUIRE_TESTS=1 # block when the runner matched 0 test files
218
263
  ```
219
264
 
220
265
  Set your house rules once in `~/.claude/gauntlet.conf`; only reach for the project file
@@ -259,6 +304,8 @@ commands/ Slash commands installed to .claude/commands/
259
304
  agents/ Subagent definitions — commands declare which they need via requires-agents
260
305
  scripts/ validate-skills.mjs — checks skill length caps and reference integrity
261
306
  gauntlet.sh — the Stop-hook verification gates, embedded by the CLI
307
+ gauntlet-selftest.sh — 15 behavioural tests for the hook (runs on pre-push)
308
+ gauntlet-survey.sh — what the hook would do in every repo under a directory
262
309
  cli/ The npm installer (npx @spardutti/claude-skills); version in cli/package.json
263
310
  .husky/ pre-push hook running the skill validator
264
311
  package.json Private dev-tooling package (claude-skills-dev) — not the published one
@@ -272,6 +319,11 @@ Skills live in `skills/<name>/SKILL.md`. Authoring conventions are in [CLAUDE.md
272
319
  - `SKILL.md` ≤ 350 lines; reference files ≤ 500 and need a `## Contents` TOC past 100 lines.
273
320
  - References are one level deep — `SKILL.md` links them, they don't link each other.
274
321
  - `npm run validate-skills` enforces this; it also runs on `pre-push`.
322
+ - Changing `scripts/gauntlet.sh`? Run `bash scripts/gauntlet-selftest.sh` (also on `pre-push`),
323
+ and run `bash scripts/gauntlet-survey.sh ~/projects` before releasing. Detection is where
324
+ this hook keeps breaking, because real repos are shaped in ways invented test repos aren't —
325
+ the survey is a dry run that reports what each of your repos would get without executing
326
+ anything. Read the rows that say `nothing`.
275
327
 
276
328
  To get the validator on **every turn** instead of only on push, drop this into
277
329
  this repo's `.claude/gauntlet.conf` — `.claude/` is gitignored, so each contributor adds it
@@ -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,203 @@ 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" ] && [ -z "\${GAUNTLET_DRYRUN:-}" ]; 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" ] && [ -z "\${GAUNTLET_DRYRUN:-}" ]; then
244
+ quit "skipped: no changed code files (docs/config only)"
245
+ fi
216
246
 
217
247
  # ---------------------------------------------------------------- skip rule 3
218
- DIFF_HASH=$( { git diff HEAD 2>/dev/null; printf '%s\\n' "$CHANGED"; } \\
248
+ # The repo path is part of the hash: one session can touch several repos, and two
249
+ # of them can legitimately hold an identical diff.
250
+ DIFF_HASH=$( { printf '%s\\n' "$PROJECT_DIR"; git diff HEAD 2>/dev/null; printf '%s\\n' "$CHANGED"; } \\
219
251
  | 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
252
+ if [ -f "$MARKER" ] && [ "$(cat "$MARKER")" = "$DIFF_HASH" ] \\
253
+ && [ -z "\${GAUNTLET_DRYRUN:-}" ]; then
254
+ quit "skipped: this diff already passed"
255
+ fi
225
256
 
226
257
  # ---------------------------------------------------------------- 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.
229
- detect() {
230
- if [ "$GAUNTLET_TEST" != "__auto__" ]; then return; fi
231
- GAUNTLET_TEST=""
232
- GAUNTLET_TYPECHECK=""
233
- if [ -f package.json ]; then
234
- if grep -q '"vitest"' package.json; then
235
- GAUNTLET_TEST='npx --no-install vitest related --run $FILES'
236
- elif grep -q '"jest"' package.json; then
237
- GAUNTLET_TEST='npx --no-install jest --findRelatedTests $FILES --passWithNoTests'
258
+ # A monorepo can hold more than one stack, so detection collects every gate it
259
+ # finds rather than stopping at the first. Each gate carries the file pattern it
260
+ # applies to, and is skipped entirely when the diff touched none of those files —
261
+ # so a Python-only change never runs the JS suite, and vice versa.
262
+ TC_GATES=""
263
+ TEST_GATES=""
264
+ SKIP_NOTE=""
265
+
266
+ add_gate() { # kind (typecheck|tests), file regex, command
267
+ LINE=$(printf '%s\\t%s\\t%s' "$1" "$2" "$3")
268
+ if [ "$1" = "typecheck" ]; then
269
+ TC_GATES="$TC_GATES$LINE
270
+ "
271
+ else
272
+ TEST_GATES="$TEST_GATES$LINE
273
+ "
274
+ fi
275
+ }
276
+
277
+ JS_EXT='(ts|tsx|js|jsx|mjs|cjs)'
278
+
279
+ # Builds the gates for one directory. "" is the repo root; anything else is a
280
+ # nested project, and its gates match only files under that path. Commands run
281
+ # from inside the directory, which is why the file list is passed as absolute
282
+ # paths — a relative path would not survive the cd.
283
+ add_dir_gates() {
284
+ d="$1"
285
+ if [ -n "$d" ]; then pre="^$d/.*\\\\." ; run="cd $d && " ; else pre='\\.' ; run="" ; fi
286
+ have_tc=""; have_test=""
287
+ [ -n "$d" ] && [ -n "\${ROOT_TC:-}" ] && have_tc="skip"
288
+ [ -n "$d" ] && [ -n "\${ROOT_TEST:-}" ] && have_test="skip"
289
+
290
+ if [ -f "$d\${d:+/}package.json" ]; then
291
+ pkg="$d\${d:+/}package.json"
292
+ if [ -z "$have_tc" ]; then
293
+ if grep -q '"typecheck"[[:space:]]*:' "$pkg"; then
294
+ add_gate typecheck "$pre($JS_EXT)\\$" "\${run}npm run --silent typecheck"
295
+ elif [ -f "$d\${d:+/}tsconfig.json" ]; then
296
+ add_gate typecheck "$pre(ts|tsx)\\$" "\${run}npx --no-install tsc --noEmit"
297
+ fi
238
298
  fi
239
- if [ -f tsconfig.json ]; then
240
- GAUNTLET_TYPECHECK='npx --no-install tsc --noEmit'
299
+ if [ -z "$have_test" ]; then
300
+ if grep -q '"vitest"' "$pkg"; then
301
+ add_gate tests "$pre($JS_EXT)\\$" "\${run}npx --no-install vitest related --run \\$FILES"
302
+ elif grep -q '"jest"' "$pkg"; then
303
+ add_gate tests "$pre($JS_EXT)\\$" "\${run}npx --no-install jest --findRelatedTests \\$FILES --passWithNoTests"
304
+ elif grep -q '"test"[[:space:]]*:' "$pkg"; then
305
+ add_gate tests "$pre($JS_EXT)\\$" "\${run}CI=1 npm run --silent test"
306
+ fi
241
307
  fi
242
308
  fi
243
- if [ -z "$GAUNTLET_TEST" ] && { [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f setup.cfg ]; }; then
244
- GAUNTLET_TEST='python -m pytest -q'
245
- if [ -f mypy.ini ] || grep -qs 'tool.mypy' pyproject.toml; then
246
- GAUNTLET_TYPECHECK='python -m mypy .'
309
+
310
+ if [ -f "$d\${d:+/}pyproject.toml" ] || [ -f "$d\${d:+/}pytest.ini" ] || [ -f "$d\${d:+/}setup.cfg" ]; then
311
+ if [ -z "$have_tc" ]; then
312
+ if [ -f "$d\${d:+/}mypy.ini" ] || grep -qs 'tool.mypy' "$d\${d:+/}pyproject.toml"; then
313
+ add_gate typecheck "\${pre}py\\$" "\${run}python -m mypy ."
314
+ fi
315
+ fi
316
+ if [ -z "$have_test" ]; then
317
+ add_gate tests "\${pre}py\\$" "\${run}python -m pytest -q"
247
318
  fi
248
319
  fi
249
320
  }
321
+
322
+ detect() {
323
+ # Explicit config wins outright: auto-detection is off for the whole repo.
324
+ if [ "$GAUNTLET_TEST" != "__auto__" ] || [ "$GAUNTLET_TYPECHECK" != "__auto__" ]; then
325
+ if [ "$GAUNTLET_TYPECHECK" != "__auto__" ] && [ -n "$GAUNTLET_TYPECHECK" ]; then
326
+ add_gate typecheck '.' "$GAUNTLET_TYPECHECK"
327
+ fi
328
+ if [ "$GAUNTLET_TEST" != "__auto__" ] && [ -n "$GAUNTLET_TEST" ]; then
329
+ add_gate tests '.' "$GAUNTLET_TEST"
330
+ fi
331
+ return
332
+ fi
333
+
334
+ add_dir_gates ""
335
+
336
+ # Many repos keep no manifest at the root — the code lives in web/, api/,
337
+ # backend/, packages/*. Look two levels down, but only for the kind of gate the
338
+ # root did not already provide, so a repo that works today keeps working.
339
+ ROOT_TC="$TC_GATES"
340
+ ROOT_TEST="$TEST_GATES"
341
+ while IFS= read -r manifest; do
342
+ [ -z "$manifest" ] && continue
343
+ d=$(dirname "$manifest")
344
+ d=\${d#./}
345
+ [ "$d" = "." ] && continue
346
+ add_dir_gates "$d"
347
+ done <<< "$(find . -mindepth 2 -maxdepth 3 \\
348
+ \\( -name package.json -o -name pyproject.toml -o -name pytest.ini \\) \\
349
+ -not -path '*/node_modules/*' -not -path '*/.git/*' \\
350
+ -not -path '*/dist/*' -not -path '*/build/*' \\
351
+ -not -path '*/.venv/*' -not -path '*/venv/*' 2>/dev/null | sort)"
352
+ }
250
353
  detect
251
- if [ -z "$GAUNTLET_TEST" ]; then exit 0; fi
354
+
355
+ # Typechecks before tests — cheapest first, so the slow gate is usually unpaid.
356
+ GATES="$TC_GATES$TEST_GATES"
357
+ if [ -n "\${GAUNTLET_DRYRUN:-}" ]; then
358
+ if [ -z "$GATES" ]; then quit "would run: nothing — no gates detected"; fi
359
+ quit "would run: $(printf '%s' "$GATES" | cut -f1,3 | tr '\\t' ' ' | tr '\\n' '; ')"
360
+ fi
361
+ if [ -z "$GATES" ]; then
362
+ if [ -n "$SKIP_NOTE" ]; then quit "skipped: $SKIP_NOTE"; fi
363
+ quit "skipped: no gates detected for this repo"
364
+ fi
252
365
 
253
366
  # ------------------------------------------------------------------ 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
367
  FAILED=""
258
368
  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)
369
+ NO_TEST_MATCH=""
370
+ RAN=""
371
+ # Kept in variables on purpose: a bare \`}\` inside a \${var//pat/repl} replacement
372
+ # closes the expansion early and silently mangles the command.
373
+ FILES_REPL='"\${FILE_ARR[@]}"'
374
+ PAT_BRACE='\${FILES}'
375
+ PAT_PLAIN='$FILES'
376
+
377
+ while IFS=$'\\t' read -r GKIND GEXT GCMD; do
378
+ [ -z "$GKIND" ] && continue
379
+ GFILES=$(printf '%s\\n' "$FILES_NL" | grep -E "$GEXT" || true)
380
+ [ -z "$GFILES" ] && continue
381
+ # $FILES is substituted as a real argument list, not a space-joined string —
382
+ # word splitting would tear "src/my folder/a.ts" into two bogus paths.
383
+ mapfile -t REL_ARR <<< "$GFILES"
384
+ FILE_ARR=()
385
+ for f in "\${REL_ARR[@]}"; do FILE_ARR+=("$PROJECT_DIR/$f"); done
386
+ export FILES=$(printf '%s' "$GFILES" | tr '\\n' ' ')
387
+ GCMD_RUN=\${GCMD//"$PAT_BRACE"/$FILES_REPL}
388
+ GCMD_RUN=\${GCMD_RUN//"$PAT_PLAIN"/$FILES_REPL}
389
+ RAN="$RAN$GKIND "
390
+ GATE_OUT=$(eval "$GCMD_RUN" 2>&1)
265
391
  if [ $? -ne 0 ]; then
266
- FAILED="$GATE_NAME"
392
+ # A gate whose tool is not installed is a missing gate, not a failing one.
393
+ # Blocking on it would wall off every turn until the user installs something.
394
+ 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
395
+ SKIP_NOTE="the $GKIND tool is not installed"
396
+ RAN="\${RAN%"$GKIND "}"
397
+ continue
398
+ fi
399
+ FAILED="$GKIND"
267
400
  OUTPUT="$GATE_OUT"
268
- return 1
401
+ break
269
402
  fi
270
- return 0
271
- }
403
+ # A runner handed files it has no tests for exits 0 and looks exactly like a
404
+ # pass. It is the loudest false green: the changed code was never executed.
405
+ if [ "$GKIND" = "tests" ] \\
406
+ && printf '%s' "$GATE_OUT" | grep -qiE 'no test files found|no tests found|no tests ran'; then
407
+ NO_TEST_MATCH="$GCMD"
408
+ OUTPUT="$GATE_OUT"
409
+ fi
410
+ done <<< "$GATES"
411
+
412
+ if [ -z "$FAILED" ] && [ -n "$NO_TEST_MATCH" ] && [ -n "\${GAUNTLET_REQUIRE_TESTS:-}" ]; then
413
+ FAILED="tests"
414
+ OUTPUT="$OUTPUT
272
415
 
273
- run_gate "typecheck" "$GAUNTLET_TYPECHECK" && run_gate "tests" "$GAUNTLET_TEST"
416
+ The runner matched 0 test files for the changed code, so nothing was executed.
417
+ Write a test that covers the change, or unset GAUNTLET_REQUIRE_TESTS."
418
+ fi
274
419
 
275
420
  if [ -z "$FAILED" ]; then
276
421
  printf '%s' "$DIFF_HASH" > "$MARKER"
277
- exit 0
422
+ if [ -z "$RAN" ]; then
423
+ if [ -n "$SKIP_NOTE" ]; then quit "skipped: $SKIP_NOTE"; fi
424
+ quit "skipped: no gate applied to the changed files"
425
+ elif [ -n "$SKIP_NOTE" ]; then
426
+ quit "green: gates passed ($RAN) — but $SKIP_NOTE"
427
+ elif [ -n "$NO_TEST_MATCH" ]; then
428
+ quit "green: gates passed, but a runner matched 0 test files — that code was never executed"
429
+ fi
430
+ quit "green: gates passed ($RAN)"
278
431
  fi
279
432
 
280
433
  # ------------------------------------------------------------------ block red
@@ -301,7 +454,11 @@ json_escape() {
301
454
  printf '"%s"' "$s"
302
455
  }
303
456
 
304
- printf '{"decision":"block","reason":%s}\\n' "$(json_escape "$MSG")"
457
+ printf '%s\\n' "red: the $FAILED gate failed" > "$WHY" 2>/dev/null
458
+ if [ -n "\${GAUNTLET_DEBUG:-}" ]; then printf 'gauntlet: red: the %s gate failed\\n' "$FAILED" >&2; fi
459
+ # A Stop hook's decision is "continue" / "stop" / "escalate" — "block" is not a
460
+ # valid value and would be ignored, letting the turn end on a red gate.
461
+ printf '{"decision":"continue","reason":%s}\\n' "$(json_escape "$MSG")"
305
462
  exit 0
306
463
  `;
307
464
 
@@ -330,7 +487,11 @@ export async function detectStack(targetDir = process.cwd()) {
330
487
  if (pkg) {
331
488
  if (pkg.includes('"vitest"')) test = "vitest";
332
489
  else if (pkg.includes('"jest"')) test = "jest";
333
- if (await has("tsconfig.json")) typecheck = "tsc";
490
+ // A delegating monorepo root has no runner as a direct dependency, but its
491
+ // own `test` script still runs the right thing.
492
+ else if (/"test"\s*:/.test(pkg)) test = "npm test";
493
+ if (/"typecheck"\s*:/.test(pkg)) typecheck = "npm run typecheck";
494
+ else if (await has("tsconfig.json")) typecheck = "tsc";
334
495
  }
335
496
  if (!test && (await has("pyproject.toml") || await has("pytest.ini") || await has("setup.cfg"))) {
336
497
  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.11.0",
4
4
  "description": "CLI to install Claude Code skills from the claude-skills collection",
5
5
  "type": "module",
6
6
  "bin": {