@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 +44 -5
- package/lib/setup-hook.mjs +163 -49
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -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.
|
|
165
|
-
#
|
|
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
|
|
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"
|
|
181
|
-
# GAUNTLET_TEST="cmd"
|
|
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 ||
|
|
223
|
+
cd "$PROJECT_DIR" 2>/dev/null || quit "skipped: project dir unreadable"
|
|
196
224
|
|
|
197
|
-
git rev-parse --git-dir >/dev/null 2>&1 ||
|
|
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
|
|
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
|
|
239
|
+
if [ -z "$CHANGED" ]; then quit "skipped: no changed files"; fi
|
|
212
240
|
|
|
213
241
|
# ---------------------------------------------------------------- skip rule 2
|
|
214
|
-
|
|
215
|
-
if [ -z "$
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
#
|
|
228
|
-
#
|
|
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
|
-
|
|
231
|
-
GAUNTLET_TEST
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
302
|
+
add_gate tests "$JS_EXT" 'npx --no-install vitest related --run $FILES'
|
|
236
303
|
elif grep -q '"jest"' package.json; then
|
|
237
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
+
break
|
|
269
358
|
fi
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
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 (
|
|
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";
|