@spardutti/claude-skills 2.8.1 → 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 +128 -3
- package/bin/cli.mjs +28 -5
- package/lib/setup-hook.mjs +328 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ The CLI will:
|
|
|
47
47
|
2. Let you pick skills to install → `.claude/skills/`
|
|
48
48
|
3. Let you pick commands to install → `.claude/commands/`
|
|
49
49
|
4. Auto-install any subagents the selected commands declare → `.claude/agents/`
|
|
50
|
-
5. Optionally set up the **skill
|
|
50
|
+
5. Optionally set up the **hooks** — skill evaluation before edits, verification after (recommended — see [How It Works](#how-it-works))
|
|
51
51
|
|
|
52
52
|
## Skill Catalog
|
|
53
53
|
|
|
@@ -132,11 +132,17 @@ npx @spardutti/claude-skills --sync
|
|
|
132
132
|
|
|
133
133
|
After installing skills, the CLI offers to set up a hook that **guarantees** Claude evaluates your skills before writing code — instead of a soft reminder it can ignore.
|
|
134
134
|
|
|
135
|
-
It installs
|
|
135
|
+
It installs four hooks and appends a rule to your `CLAUDE.md`. Three guard the
|
|
136
|
+
**front** — nothing is written until your skills are considered:
|
|
136
137
|
|
|
137
138
|
- `skill-gate.sh` — a `PreToolUse` gate on `Write|Edit|MultiEdit`
|
|
139
|
+
- `skill-application-gate.sh` — a second `PreToolUse` gate requiring each loaded skill to be applied, not just read
|
|
138
140
|
- `skill-gate-automark.sh` — a `PostToolUse` hook on `Skill` that clears the gate
|
|
139
141
|
|
|
142
|
+
The fourth guards the **back** — nothing finishes until the code passes:
|
|
143
|
+
|
|
144
|
+
- `gauntlet.sh` — a `Stop` hook running the fast gates on your changed files (see [Automatic Verification](#automatic-verification-gauntlet))
|
|
145
|
+
|
|
140
146
|
<details>
|
|
141
147
|
<summary>How the gate works</summary>
|
|
142
148
|
|
|
@@ -151,7 +157,9 @@ It registers in `.claude/settings.json`:
|
|
|
151
157
|
"hooks": {
|
|
152
158
|
"PreToolUse": [
|
|
153
159
|
{ "matcher": "Write|Edit|MultiEdit", "hooks": [
|
|
154
|
-
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-gate.sh" } ] }
|
|
160
|
+
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-gate.sh" } ] },
|
|
161
|
+
{ "matcher": "Write|Edit|MultiEdit", "hooks": [
|
|
162
|
+
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-application-gate.sh" } ] }
|
|
155
163
|
],
|
|
156
164
|
"PostToolUse": [
|
|
157
165
|
{ "matcher": "Skill", "hooks": [
|
|
@@ -163,6 +171,109 @@ It registers in `.claude/settings.json`:
|
|
|
163
171
|
|
|
164
172
|
</details>
|
|
165
173
|
|
|
174
|
+
### Automatic Verification (Gauntlet)
|
|
175
|
+
|
|
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 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.
|
|
184
|
+
|
|
185
|
+
Only fast gates live here (target: under ~30s). Mutation testing and deep review stay
|
|
186
|
+
manual — they belong to `/test-review` and `/deep-review`, not to every turn.
|
|
187
|
+
|
|
188
|
+
At install time the CLI reports what it found, so a silent no-op is never a surprise:
|
|
189
|
+
|
|
190
|
+
```text
|
|
191
|
+
Gauntlet detected tsc + vitest — it will run those on changed files.
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
If no test runner is detected it says so, and offers to write a `.claude/gauntlet.conf`
|
|
195
|
+
pointing at your test command. Detection covers vitest, jest, pytest, `tsc`, and `mypy`;
|
|
196
|
+
everything else needs that one config line.
|
|
197
|
+
|
|
198
|
+
<details>
|
|
199
|
+
<summary>How the gauntlet hook works</summary>
|
|
200
|
+
|
|
201
|
+
It runs a skip ladder first, cheapest check first, and quits at the first "no":
|
|
202
|
+
|
|
203
|
+
1. no changed files (planning turns, questions)
|
|
204
|
+
2. no changed **code** files (docs/config only)
|
|
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
|
|
207
|
+
|
|
208
|
+
Rule 3 is what makes repeated `/ship` runs free: the gates run once per distinct diff.
|
|
209
|
+
|
|
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
|
|
243
|
+
a config file — two are read, in this order, and the project's wins key by key:
|
|
244
|
+
|
|
245
|
+
```text
|
|
246
|
+
~/.claude/gauntlet.conf your defaults for every project
|
|
247
|
+
<project>/.claude/gauntlet.conf overrides for this one
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
GAUNTLET_OFF=1 # disable entirely
|
|
252
|
+
GAUNTLET_TYPECHECK='npm run check' # "" to skip the gate
|
|
253
|
+
GAUNTLET_TEST='npm test -- $FILES' # $FILES = changed code files
|
|
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
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Set your house rules once in `~/.claude/gauntlet.conf`; only reach for the project file
|
|
260
|
+
where a repo does something unusual (or set `GAUNTLET_OFF=1` there to opt one out).
|
|
261
|
+
|
|
262
|
+
It registers in `.claude/settings.json`:
|
|
263
|
+
|
|
264
|
+
```json
|
|
265
|
+
{
|
|
266
|
+
"hooks": {
|
|
267
|
+
"Stop": [
|
|
268
|
+
{ "hooks": [
|
|
269
|
+
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/gauntlet.sh" } ] }
|
|
270
|
+
]
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
</details>
|
|
276
|
+
|
|
166
277
|
<details>
|
|
167
278
|
<summary>Manual install (without the CLI)</summary>
|
|
168
279
|
|
|
@@ -186,6 +297,7 @@ skills/ Skill playbooks — some are bundles (SKILL.md + on-demand refere
|
|
|
186
297
|
commands/ Slash commands installed to .claude/commands/
|
|
187
298
|
agents/ Subagent definitions — commands declare which they need via requires-agents
|
|
188
299
|
scripts/ validate-skills.mjs — checks skill length caps and reference integrity
|
|
300
|
+
gauntlet.sh — the Stop-hook verification gates, embedded by the CLI
|
|
189
301
|
cli/ The npm installer (npx @spardutti/claude-skills); version in cli/package.json
|
|
190
302
|
.husky/ pre-push hook running the skill validator
|
|
191
303
|
package.json Private dev-tooling package (claude-skills-dev) — not the published one
|
|
@@ -200,6 +312,19 @@ Skills live in `skills/<name>/SKILL.md`. Authoring conventions are in [CLAUDE.md
|
|
|
200
312
|
- References are one level deep — `SKILL.md` links them, they don't link each other.
|
|
201
313
|
- `npm run validate-skills` enforces this; it also runs on `pre-push`.
|
|
202
314
|
|
|
315
|
+
To get the validator on **every turn** instead of only on push, drop this into
|
|
316
|
+
this repo's `.claude/gauntlet.conf` — `.claude/` is gitignored, so each contributor adds it
|
|
317
|
+
locally. It has to be the project file, not `~/.claude/gauntlet.conf`: these settings are
|
|
318
|
+
only right for a skills repo:
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
GAUNTLET_CODE_EXT='md|mjs|js|json|sh' # markdown IS the product here
|
|
322
|
+
GAUNTLET_TYPECHECK=''
|
|
323
|
+
GAUNTLET_TEST='node scripts/validate-skills.mjs'
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Without the `CODE_EXT` line the hook treats a skill edit as a docs-only change and skips.
|
|
327
|
+
|
|
203
328
|
## License
|
|
204
329
|
|
|
205
330
|
MIT
|
package/bin/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { confirm } from "@inquirer/prompts";
|
|
3
|
+
import { confirm, input } from "@inquirer/prompts";
|
|
4
4
|
import chalk from "chalk";
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -8,7 +8,7 @@ import { dirname, join } from "node:path";
|
|
|
8
8
|
import { fetchSkills, fetchCommands, fetchAgents } from "../lib/github.mjs";
|
|
9
9
|
import { promptSkillSelection, promptCommandSelection, promptRemoval } from "../lib/prompt.mjs";
|
|
10
10
|
import { installSkills, installCommands, installRequiredAgents } from "../lib/install.mjs";
|
|
11
|
-
import { setupHook } from "../lib/setup-hook.mjs";
|
|
11
|
+
import { setupHook, detectStack, writeGauntletConf } from "../lib/setup-hook.mjs";
|
|
12
12
|
import { setupClaudeMd } from "../lib/setup-claude-md.mjs";
|
|
13
13
|
import {
|
|
14
14
|
readManifest, writeManifest, computeOrphans, computeRemovals, scanInstalled, removeArtifacts,
|
|
@@ -143,17 +143,40 @@ async function main() {
|
|
|
143
143
|
process.exit(0);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
// --- Hook setup
|
|
147
|
-
|
|
146
|
+
// --- Hook setup ---
|
|
147
|
+
// Offered whenever anything was installed: the skill gates need skills, but the
|
|
148
|
+
// gauntlet guards any repo with a test runner, commands-only installs included.
|
|
149
|
+
if (selectedSkills.length > 0 || selectedCommands.length > 0) {
|
|
148
150
|
console.log();
|
|
149
151
|
const shouldSetup = await confirm({
|
|
150
|
-
message: "Set up skill evaluation
|
|
152
|
+
message: "Set up hooks — skill evaluation before edits, verification after? (Recommended)",
|
|
151
153
|
default: true,
|
|
152
154
|
});
|
|
153
155
|
if (shouldSetup) {
|
|
154
156
|
console.log();
|
|
155
157
|
await setupHook();
|
|
156
158
|
await setupClaudeMd();
|
|
159
|
+
|
|
160
|
+
// Report what the gauntlet will actually run, so a silent no-op is visible.
|
|
161
|
+
const { test, typecheck } = await detectStack(CWD);
|
|
162
|
+
if (test) {
|
|
163
|
+
const found = [typecheck, test].filter(Boolean).join(" + ");
|
|
164
|
+
console.log(` ${chalk.dim(`Gauntlet detected ${found} — it will run those on changed files.`)}`);
|
|
165
|
+
} else {
|
|
166
|
+
console.log(` ${chalk.yellow("!")} ${chalk.dim("Gauntlet found no test runner here — it will stay asleep.")}`);
|
|
167
|
+
console.log();
|
|
168
|
+
const wantConf = await confirm({
|
|
169
|
+
message: "Point it at your test command now?",
|
|
170
|
+
default: false,
|
|
171
|
+
});
|
|
172
|
+
if (wantConf) {
|
|
173
|
+
const cmd = (await input({ message: "Test command:" })).trim();
|
|
174
|
+
if (cmd) {
|
|
175
|
+
const written = await writeGauntletConf(CWD, cmd);
|
|
176
|
+
console.log(` Config written: ${written.replace(CWD + "/", "")}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
157
180
|
}
|
|
158
181
|
}
|
|
159
182
|
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdir, writeFile, readFile, chmod, unlink } from "node:fs/promises";
|
|
2
|
-
import { join, resolve } from "node:path";
|
|
2
|
+
import { join, resolve, dirname } from "node:path";
|
|
3
3
|
|
|
4
4
|
// PreToolUse gate on Write|Edit|MultiEdit. Blocks the tool call unless
|
|
5
5
|
// a per-session marker file exists at /tmp/claude-skill-gate-<SESSION_ID>.
|
|
@@ -155,18 +155,329 @@ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision"
|
|
|
155
155
|
exit 0
|
|
156
156
|
`;
|
|
157
157
|
|
|
158
|
+
// Stop hook: fast deterministic gates on the changed diff. Silent when green,
|
|
159
|
+
// blocks the turn when a gate is red so the model fixes it before the user sees
|
|
160
|
+
// it. Canonical source: scripts/gauntlet.sh in the claude-skills repo.
|
|
161
|
+
const GAUNTLET_SCRIPT = `#!/usr/bin/env bash
|
|
162
|
+
# gauntlet.sh — Stop hook: fast deterministic gates on the changed diff.
|
|
163
|
+
#
|
|
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.
|
|
173
|
+
#
|
|
174
|
+
# Only cheap gates belong here (target: under ~30s). Mutation testing, deep
|
|
175
|
+
# review, and the spec checklist are the /gauntlet command's job, not this hook's.
|
|
176
|
+
#
|
|
177
|
+
# Skip ladder, cheapest first — quits at the first "no":
|
|
178
|
+
# 1. no changed files
|
|
179
|
+
# 2. no changed *code* files (docs/config only)
|
|
180
|
+
# 3. this exact diff already passed
|
|
181
|
+
# 4. no gates detected for this repo
|
|
182
|
+
#
|
|
183
|
+
# Config, both optional and both KEY=value files that get sourced in this order:
|
|
184
|
+
# ~/.claude/gauntlet.conf your defaults for every project
|
|
185
|
+
# <project>/.claude/gauntlet.conf overrides for this one; wins key by key
|
|
186
|
+
# GAUNTLET_OFF=1 disable entirely
|
|
187
|
+
# GAUNTLET_TYPECHECK="cmd" explicit typecheck command ("" to skip)
|
|
188
|
+
# GAUNTLET_TEST="cmd" explicit test command; $FILES = changed code files
|
|
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.
|
|
198
|
+
|
|
199
|
+
set -uo pipefail
|
|
200
|
+
|
|
201
|
+
INPUT=$(cat 2>/dev/null || true)
|
|
202
|
+
|
|
203
|
+
# Never re-block a turn we already blocked — this would loop forever.
|
|
204
|
+
case "$INPUT" in
|
|
205
|
+
*'"stop_hook_active":true'*) exit 0 ;;
|
|
206
|
+
esac
|
|
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
|
+
|
|
221
|
+
PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-}"
|
|
222
|
+
if [ -z "$PROJECT_DIR" ]; then PROJECT_DIR="$PWD"; fi
|
|
223
|
+
cd "$PROJECT_DIR" 2>/dev/null || quit "skipped: project dir unreadable"
|
|
224
|
+
|
|
225
|
+
git rev-parse --git-dir >/dev/null 2>&1 || quit "skipped: not a git repo"
|
|
226
|
+
|
|
227
|
+
GAUNTLET_OFF=""
|
|
228
|
+
GAUNTLET_TYPECHECK="__auto__"
|
|
229
|
+
GAUNTLET_TEST="__auto__"
|
|
230
|
+
GAUNTLET_CODE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|sql"
|
|
231
|
+
# Global defaults first, then the project's on top — project wins, key by key.
|
|
232
|
+
if [ -n "\${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ]; then . "$HOME/.claude/gauntlet.conf"; fi
|
|
233
|
+
if [ -f "$PROJECT_DIR/.claude/gauntlet.conf" ]; then . "$PROJECT_DIR/.claude/gauntlet.conf"; fi
|
|
234
|
+
if [ -n "$GAUNTLET_OFF" ]; then quit "skipped: GAUNTLET_OFF is set"; fi
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------- skip rule 1
|
|
237
|
+
CHANGED=$( { git diff --name-only HEAD 2>/dev/null; \\
|
|
238
|
+
git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )
|
|
239
|
+
if [ -z "$CHANGED" ]; then quit "skipped: no changed files"; fi
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------- skip rule 2
|
|
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
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------- skip rule 3
|
|
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"; } \\
|
|
249
|
+
| git hash-object --stdin 2>/dev/null )
|
|
250
|
+
if [ -f "$MARKER" ] && [ "$(cat "$MARKER")" = "$DIFF_HASH" ]; then
|
|
251
|
+
quit "skipped: this diff already passed"
|
|
252
|
+
fi
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------- skip rule 4
|
|
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
|
+
|
|
276
|
+
detect() {
|
|
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
|
|
301
|
+
if grep -q '"vitest"' package.json; then
|
|
302
|
+
add_gate tests "$JS_EXT" 'npx --no-install vitest related --run $FILES'
|
|
303
|
+
elif grep -q '"jest"' package.json; then
|
|
304
|
+
add_gate tests "$JS_EXT" 'npx --no-install jest --findRelatedTests $FILES --passWithNoTests'
|
|
305
|
+
fi
|
|
306
|
+
fi
|
|
307
|
+
|
|
308
|
+
if [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f setup.cfg ]; then
|
|
309
|
+
if [ -f mypy.ini ] || grep -qs 'tool.mypy' pyproject.toml; then
|
|
310
|
+
add_gate typecheck '\\.py$' 'python -m mypy .'
|
|
311
|
+
fi
|
|
312
|
+
add_gate tests '\\.py$' 'python -m pytest -q'
|
|
313
|
+
fi
|
|
314
|
+
}
|
|
315
|
+
detect
|
|
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
|
|
323
|
+
|
|
324
|
+
# ------------------------------------------------------------------ the gates
|
|
325
|
+
FAILED=""
|
|
326
|
+
OUTPUT=""
|
|
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)
|
|
347
|
+
if [ $? -ne 0 ]; then
|
|
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"
|
|
356
|
+
OUTPUT="$GATE_OUT"
|
|
357
|
+
break
|
|
358
|
+
fi
|
|
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
|
|
371
|
+
|
|
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
|
|
375
|
+
|
|
376
|
+
if [ -z "$FAILED" ]; then
|
|
377
|
+
printf '%s' "$DIFF_HASH" > "$MARKER"
|
|
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)"
|
|
387
|
+
fi
|
|
388
|
+
|
|
389
|
+
# ------------------------------------------------------------------ block red
|
|
390
|
+
# Last 60 lines only — the hook's reason is read by a model, not archived.
|
|
391
|
+
TAIL=$(printf '%s\\n' "$OUTPUT" | tail -60)
|
|
392
|
+
MSG="GAUNTLET FAILED: the $FAILED gate is red on the changed files.
|
|
393
|
+
|
|
394
|
+
Fix it before ending the turn. Do not weaken or delete a test to make this pass.
|
|
395
|
+
If the failure is unrelated to your change, say so explicitly and stop.
|
|
396
|
+
|
|
397
|
+
Changed code files:
|
|
398
|
+
$FILES
|
|
399
|
+
|
|
400
|
+
--- $FAILED output (last 60 lines) ---
|
|
401
|
+
$TAIL"
|
|
402
|
+
|
|
403
|
+
json_escape() {
|
|
404
|
+
local s="$1"
|
|
405
|
+
s="\${s//\\\\/\\\\\\\\}"
|
|
406
|
+
s="\${s//\\"/\\\\\\"}"
|
|
407
|
+
s="\${s//$'\\n'/\\\\n}"
|
|
408
|
+
s="\${s//$'\\r'/\\\\r}"
|
|
409
|
+
s="\${s//$'\\t'/\\\\t}"
|
|
410
|
+
printf '"%s"' "$s"
|
|
411
|
+
}
|
|
412
|
+
|
|
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")"
|
|
418
|
+
exit 0
|
|
419
|
+
`;
|
|
420
|
+
|
|
158
421
|
const GATE_FILENAME = "skill-gate.sh";
|
|
159
422
|
const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
|
|
160
423
|
const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
|
|
424
|
+
const GAUNTLET_FILENAME = "gauntlet.sh";
|
|
161
425
|
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
162
426
|
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
163
427
|
|
|
428
|
+
// Mirrors the detect() in scripts/gauntlet.sh. Kept in sync by hand: this one
|
|
429
|
+
// only reports what the hook will find, it never decides anything.
|
|
430
|
+
export async function detectStack(targetDir = process.cwd()) {
|
|
431
|
+
const dir = resolve(targetDir);
|
|
432
|
+
const has = async (f) => {
|
|
433
|
+
try { await readFile(join(dir, f), "utf-8"); return true; } catch { return false; }
|
|
434
|
+
};
|
|
435
|
+
const read = async (f) => {
|
|
436
|
+
try { return await readFile(join(dir, f), "utf-8"); } catch { return ""; }
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
let test = null;
|
|
440
|
+
let typecheck = null;
|
|
441
|
+
|
|
442
|
+
const pkg = await read("package.json");
|
|
443
|
+
if (pkg) {
|
|
444
|
+
if (pkg.includes('"vitest"')) test = "vitest";
|
|
445
|
+
else if (pkg.includes('"jest"')) test = "jest";
|
|
446
|
+
if (/"typecheck"\s*:/.test(pkg)) typecheck = "npm run typecheck";
|
|
447
|
+
else if (await has("tsconfig.json")) typecheck = "tsc";
|
|
448
|
+
}
|
|
449
|
+
if (!test && (await has("pyproject.toml") || await has("pytest.ini") || await has("setup.cfg"))) {
|
|
450
|
+
test = "pytest";
|
|
451
|
+
if (await has("mypy.ini") || (await read("pyproject.toml")).includes("tool.mypy")) {
|
|
452
|
+
typecheck = "mypy";
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return { test, typecheck };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Written only when detection finds nothing — otherwise the hook auto-detects and
|
|
459
|
+
// no config file is needed.
|
|
460
|
+
export async function writeGauntletConf(targetDir, testCommand) {
|
|
461
|
+
const confPath = join(resolve(targetDir), ".claude", "gauntlet.conf");
|
|
462
|
+
await mkdir(dirname(confPath), { recursive: true });
|
|
463
|
+
await writeFile(confPath, [
|
|
464
|
+
"# Gauntlet config. Read after ~/.claude/gauntlet.conf, and wins over it.",
|
|
465
|
+
"# GAUNTLET_OFF=1 disables the hook in this project.",
|
|
466
|
+
"",
|
|
467
|
+
"GAUNTLET_TYPECHECK=''",
|
|
468
|
+
`GAUNTLET_TEST='${testCommand.replace(/'/g, "'\\''")}'`,
|
|
469
|
+
"",
|
|
470
|
+
].join("\n"), { mode: 0o644 });
|
|
471
|
+
return confPath;
|
|
472
|
+
}
|
|
473
|
+
|
|
164
474
|
export async function setupHook(targetDir = process.cwd()) {
|
|
165
475
|
const resolved = resolve(targetDir);
|
|
166
476
|
const hooksDir = join(resolved, ".claude", "hooks");
|
|
167
477
|
const gatePath = join(hooksDir, GATE_FILENAME);
|
|
168
478
|
const autoMarkPath = join(hooksDir, AUTO_MARK_FILENAME);
|
|
169
479
|
const applicationGatePath = join(hooksDir, APPLICATION_GATE_FILENAME);
|
|
480
|
+
const gauntletPath = join(hooksDir, GAUNTLET_FILENAME);
|
|
170
481
|
const settingsPath = join(resolved, ".claude", "settings.json");
|
|
171
482
|
|
|
172
483
|
await mkdir(hooksDir, { recursive: true });
|
|
@@ -176,6 +487,8 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
176
487
|
await chmod(autoMarkPath, 0o755);
|
|
177
488
|
await writeFile(applicationGatePath, APPLICATION_GATE_SCRIPT, { mode: 0o755 });
|
|
178
489
|
await chmod(applicationGatePath, 0o755);
|
|
490
|
+
await writeFile(gauntletPath, GAUNTLET_SCRIPT, { mode: 0o755 });
|
|
491
|
+
await chmod(gauntletPath, 0o755);
|
|
179
492
|
|
|
180
493
|
// Remove the legacy audit-runner hook file from prior installs (2.3.x).
|
|
181
494
|
try {
|
|
@@ -252,10 +565,24 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
252
565
|
settings.hooks.PostToolUse = [autoMarkEntry];
|
|
253
566
|
}
|
|
254
567
|
|
|
568
|
+
// Register Stop gauntlet hook.
|
|
569
|
+
const gauntletCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${GAUNTLET_FILENAME}`;
|
|
570
|
+
const gauntletEntry = { hooks: [{ type: "command", command: gauntletCommand }] };
|
|
571
|
+
|
|
572
|
+
if (Array.isArray(settings.hooks.Stop)) {
|
|
573
|
+
const exists = settings.hooks.Stop.some((entry) =>
|
|
574
|
+
entry.hooks?.some((h) => h.command?.endsWith(GAUNTLET_FILENAME))
|
|
575
|
+
);
|
|
576
|
+
if (!exists) settings.hooks.Stop.push(gauntletEntry);
|
|
577
|
+
} else {
|
|
578
|
+
settings.hooks.Stop = [gauntletEntry];
|
|
579
|
+
}
|
|
580
|
+
|
|
255
581
|
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o644 });
|
|
256
582
|
|
|
257
583
|
console.log(` Hook installed: .claude/hooks/${GATE_FILENAME}`);
|
|
258
584
|
console.log(` Hook installed: .claude/hooks/${AUTO_MARK_FILENAME}`);
|
|
259
585
|
console.log(` Hook installed: .claude/hooks/${APPLICATION_GATE_FILENAME}`);
|
|
586
|
+
console.log(` Hook installed: .claude/hooks/${GAUNTLET_FILENAME}`);
|
|
260
587
|
console.log(` Settings updated: .claude/settings.json`);
|
|
261
588
|
}
|