@spardutti/claude-skills 2.11.1 → 2.12.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
@@ -88,7 +88,7 @@ Cross-cutting craft — applies to any stack, any language.
88
88
  | `code-structure` 📦 | Single Responsibility (when to split) + Avoid Hasty Abstractions (when *not* to extract) — hard size limits, separation of concerns, the Rule of Three |
89
89
  | `debugging` | Root cause before patching — reproduce, trace the failure backward to its origin, one hypothesis at a time, the 3-attempts-then-question-the-design rule |
90
90
  | `typescript-best-practices` | TypeScript 7.x — type design, generics, type guards, `satisfies`, `using`, error handling, `tsconfig` |
91
- | `testing-best-practices` | Arrange-Act-Assert, factory-based test data, isolation, mocking boundaries, a pyramid-balanced suite |
91
+ | `testing-best-practices` 📦 | Arrange-Act-Assert, factory-based test data, isolation, mocking boundaries, a pyramid-balanced suite; bundle covers mutation testing — Stryker and mutmut setup, diff-scoped runs, the traps, and working score thresholds |
92
92
  | `security-practices` | OWASP Top 10 prevention, input validation, auth, SQL injection, XSS, CSRF, secure defaults |
93
93
 
94
94
  ## Commands
@@ -97,7 +97,7 @@ Portable slash commands installed to `.claude/commands/`. Some orchestrate paral
97
97
 
98
98
  | Command | What it does |
99
99
  |---------|--------------|
100
- | `/ship` | Unified delivery pipeline — commit → PR → merge → release. No argument steps through interactively; `/ship pr` runs through PR creation; `/ship release` runs the full pipeline |
100
+ | `/ship` | Unified delivery pipeline — gate → commit → PR → merge → release. The gate is the one enforcement moment: it checks the diff's file lengths, audits it against the skills installed in the project, and mutation-tests the changed lines to prove the tests would catch a break — **fixing what it finds** rather than handing you a list. `--force` skips it. No argument steps through interactively; `/ship pr` runs through PR creation; `/ship release` runs the full pipeline |
101
101
  | `/discover` | Find the right problem before deciding what to build — diverges first: names competing interpretations of what you said, stress-tests the framing with a blind critic subagent, then converges on a mental model. Run before `/preplan` |
102
102
  | `/preplan` | Resolve a fuzzy feature idea into concrete decisions — 6 fixed phases, one question at a time, ends with a decision log. Run before `/plan-feature` |
103
103
  | `/plan-feature` | Integration-first feature planning — 3 parallel subagents scan for reusable code, patterns, and touch points before producing a short plan |
@@ -126,6 +126,13 @@ Every install writes a manifest at `.claude/.claude-skills.json` recording what
126
126
  npx @spardutti/claude-skills --sync
127
127
  ```
128
128
 
129
+ `--local[=path]` reads the catalog from a working copy instead of GitHub, so an unreleased
130
+ change can be installed and tried without publishing it first:
131
+
132
+ ```bash
133
+ node ~/projects/claude-skills/cli/bin/cli.mjs --local
134
+ ```
135
+
129
136
  `--sync` refreshes every tracked item to the latest catalog and prunes stale ones in one shot — no menu. For a project that predates the manifest, the first normal run offers a one-time cleanup of `.claude/` content no longer in the catalog.
130
137
 
131
138
  ### Automatic Skill Evaluation
@@ -139,9 +146,10 @@ It installs four hooks and appends a rule to your `CLAUDE.md`. Three guard the
139
146
  - `skill-application-gate.sh` — a second `PreToolUse` gate requiring each loaded skill to be applied, not just read
140
147
  - `skill-gate-automark.sh` — a `PostToolUse` hook on `Skill` that clears the gate
141
148
 
142
- The fourth guards the **back** — nothing finishes until the code passes:
149
+ The last two guard the **back** — nothing finishes or ships until the code passes:
143
150
 
144
151
  - `gauntlet.sh` — a `Stop` hook running the fast gates on your changed files (see [Automatic Verification](#automatic-verification-gauntlet))
152
+ - `ship-gate-hook.sh` — a `PreToolUse` gate on `Bash` that refuses `git commit` and `git push` without a receipt from `ship-gate.sh`
145
153
 
146
154
  <details>
147
155
  <summary>How the gate works</summary>
@@ -174,9 +182,12 @@ It registers in `.claude/settings.json`:
174
182
  ### Automatic Verification (Gauntlet)
175
183
 
176
184
  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.
185
+ gates on your changed files every time Claude finishes a turn. Green is silent; red exits 2,
186
+ which keeps the turn from ending, shows you the failure, and hands it to Claude to fix.
187
+
188
+ Exit 2 matters: `{"decision":"continue"}` also keeps the turn going, but its reason reaches
189
+ Claude alone. A failing gate was then invisible from the outside — indistinguishable from a
190
+ gauntlet that does nothing.
180
191
 
181
192
  Keep the gates fast for a reason beyond patience: a `command` hook that reaches its timeout
182
193
  (600s by default) is cancelled and its **output discarded**, so the turn ends as if the hook
@@ -207,6 +218,12 @@ It runs a skip ladder first, cheapest check first, and quits at the first "no":
207
218
 
208
219
  Rule 3 is what makes repeated `/ship` runs free: the gates run once per distinct diff.
209
220
 
221
+ To confirm it is alive when everything is green — green prints nothing, by design:
222
+
223
+ ```bash
224
+ ls -t /tmp/claude-gauntlet-*.why | head -1 | xargs cat
225
+ ```
226
+
210
227
  Every run records why it ended in `/tmp/claude-gauntlet-<session>.why`, so a green run
211
228
  and a skipped one are told apart without re-running the hook. `GAUNTLET_DEBUG=1` prints
212
229
  the same line to stderr:
@@ -257,11 +274,20 @@ a config file — two are read, in this order, and the project's wins key by key
257
274
  GAUNTLET_OFF=1 # disable entirely
258
275
  GAUNTLET_TYPECHECK='npm run check' # "" to skip the gate
259
276
  GAUNTLET_TEST='npm test -- $FILES' # $FILES = changed code files
260
- GAUNTLET_CODE_EXT='ts|tsx|py' # what counts as code
277
+ GAUNTLET_CODE_EXT='ts|tsx|py' # what counts as a change worth checking
261
278
  GAUNTLET_DEBUG=1 # print the outcome to stderr every run
262
279
  GAUNTLET_REQUIRE_TESTS=1 # block when the runner matched 0 test files
280
+
281
+ # /ship's gate reads its own keys from the same file
282
+ GAUNTLET_MAX_LINES=200 # per-file limit; 0 turns the check off
283
+ GAUNTLET_SOURCE_EXT='ts|tsx|py' # what counts as SOURCE — not the same question
284
+ GAUNTLET_MUTATE='bash mutate.sh' # replace per-project mutation detection
263
285
  ```
264
286
 
287
+ `GAUNTLET_SOURCE_EXT` is separate from `GAUNTLET_CODE_EXT` on purpose. The Stop hook asks
288
+ "did anything worth checking change", so a docs repo sets `CODE_EXT` to include `.md`. The
289
+ line limit and mutation ask about source code, where that answer is wrong.
290
+
265
291
  Set your house rules once in `~/.claude/gauntlet.conf`; only reach for the project file
266
292
  where a repo does something unusual (or set `GAUNTLET_OFF=1` there to opt one out).
267
293
 
@@ -304,6 +330,8 @@ commands/ Slash commands installed to .claude/commands/
304
330
  agents/ Subagent definitions — commands declare which they need via requires-agents
305
331
  scripts/ validate-skills.mjs — checks skill length caps and reference integrity
306
332
  gauntlet.sh — the Stop-hook verification gates, embedded by the CLI
333
+ ship-gate.sh — /ship's file-length and mutation checks, behind an exit code
334
+ ship-gate-hook.sh — refuses git commit/push without a ship-gate receipt
307
335
  gauntlet-selftest.sh — 15 behavioural tests for the hook (runs on pre-push)
308
336
  gauntlet-survey.sh — what the hook would do in every repo under a directory
309
337
  cli/ The npm installer (npx @spardutti/claude-skills); version in cli/package.json
package/bin/cli.mjs CHANGED
@@ -9,6 +9,7 @@ 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
11
  import { setupHook, detectStack, writeGauntletConf } from "../lib/setup-hook.mjs";
12
+ import { makeLocalSource, reportToolNeeds } from "../lib/local.mjs";
12
13
  import { setupClaudeMd } from "../lib/setup-claude-md.mjs";
13
14
  import {
14
15
  readManifest, writeManifest, computeOrphans, computeRemovals, scanInstalled, removeArtifacts,
@@ -50,10 +51,24 @@ async function runSync(manifest, catalog) {
50
51
 
51
52
  async function main() {
52
53
  const isSync = process.argv.includes("--sync");
54
+
55
+ // --local[=path] reads the catalog from a working copy instead of GitHub, so an
56
+ // unreleased change can be installed and tried without publishing it first.
57
+ const localArg = process.argv.find((a) => a === "--local" || a.startsWith("--local="));
58
+ const localRoot = localArg ? (localArg.split("=")[1] || join(__dirname, "..", "..")) : null;
59
+
53
60
  console.log(`\n ${chalk.bold.cyan("Claude Skills Installer")} ${chalk.dim(`v${pkg.version}`)}\n`);
54
61
 
55
- console.log(chalk.dim(" Fetching available skills, commands, and agents...\n"));
56
- const [skills, commands, agents] = await Promise.all([fetchSkills(), fetchCommands(), fetchAgents()]);
62
+ let fetchers = { fetchSkills, fetchCommands, fetchAgents };
63
+ if (localRoot) {
64
+ fetchers = makeLocalSource(localRoot);
65
+ console.log(` ${chalk.yellow("LOCAL")} ${chalk.dim(`reading the catalog from ${localRoot}`)}\n`);
66
+ } else {
67
+ console.log(chalk.dim(" Fetching available skills, commands, and agents...\n"));
68
+ }
69
+ const [skills, commands, agents] = await Promise.all([
70
+ fetchers.fetchSkills(), fetchers.fetchCommands(), fetchers.fetchAgents(),
71
+ ]);
57
72
  const catalog = { skills, commands, agents };
58
73
  const manifest = await readManifest(CWD);
59
74
 
@@ -182,6 +197,27 @@ async function main() {
182
197
  }
183
198
  }
184
199
 
200
+ // --- What the installed commands need that this project does not have ---
201
+ // /ship's mutation check is per project, so a monorepo needs a tool per project.
202
+ if (selectedCommands.some((c) => c.fileName === "ship.md")) {
203
+ const needs = await reportToolNeeds(CWD);
204
+ console.log();
205
+ if (needs.length === 0) {
206
+ console.log(` ${chalk.dim("/ship's mutation check has a tool in every project here.")}`);
207
+ } else {
208
+ console.log(` ${chalk.yellow("!")} ${chalk.bold("/ship needs a mutation tool per project")} ${chalk.dim("— without one it reports UNPROVEN, and never blocks.")}`);
209
+ if (!selectedSkills.some((s) => s.dirName === "testing-best-practices")) {
210
+ console.log(` ${chalk.dim("Setting these up has traps — install the testing-best-practices skill for MUTATION-TESTING.md.")}`);
211
+ }
212
+ for (const n of needs) {
213
+ console.log(` ${chalk.bold(n.label)} needs ${n.tool}`);
214
+ console.log(` ${chalk.cyan(n.install)}`);
215
+ console.log(` ${chalk.dim(n.config)}`);
216
+ for (const line of n.also ?? []) console.log(` ${chalk.dim(line)}`);
217
+ }
218
+ }
219
+ }
220
+
185
221
  // --- Record what is now installed ---
186
222
  await writeManifest(CWD, {
187
223
  catalogVersion: pkg.version,
package/lib/local.mjs ADDED
@@ -0,0 +1,171 @@
1
+ // Local catalog source — the same shape github.mjs returns, read from a working
2
+ // copy instead of GitHub. Testing an unreleased change should not require
3
+ // publishing it first.
4
+ import { readdir, readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+
7
+ function parseFrontmatter(content, fallbackName) {
8
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
9
+ if (!match) return { name: fallbackName, description: "", category: "General", requiresAgents: [] };
10
+ const fm = match[1];
11
+ const pick = (key) => {
12
+ const m = fm.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
13
+ return m ? m[1].trim().replace(/^["']|["']$/g, "") : "";
14
+ };
15
+ const raw = pick("requires-agents");
16
+ return {
17
+ name: pick("name") || fallbackName,
18
+ description: pick("description"),
19
+ category: pick("category") || "General",
20
+ requiresAgents: raw ? raw.replace(/[[\]]/g, "").split(",").map((s) => s.trim()).filter(Boolean) : [],
21
+ };
22
+ }
23
+
24
+ async function listDir(dir) {
25
+ try { return await readdir(dir, { withFileTypes: true }); } catch { return []; }
26
+ }
27
+
28
+ export function makeLocalSource(root) {
29
+ return {
30
+ async fetchSkills() {
31
+ const out = [];
32
+ for (const d of await listDir(join(root, "skills"))) {
33
+ if (!d.isDirectory()) continue;
34
+ const dirPath = join(root, "skills", d.name);
35
+ const files = (await listDir(dirPath)).filter((f) => f.isFile());
36
+ const skillFile = files.find((f) => f.name === "SKILL.md");
37
+ if (!skillFile) continue;
38
+ const content = await readFile(join(dirPath, "SKILL.md"), "utf-8");
39
+ const peerFiles = [];
40
+ for (const f of files) {
41
+ if (f.name === "SKILL.md") continue;
42
+ peerFiles.push({
43
+ name: f.name,
44
+ content: await readFile(join(dirPath, f.name), "utf-8"),
45
+ executable: f.name.endsWith(".sh"),
46
+ });
47
+ }
48
+ const { name, description, category } = parseFrontmatter(content, d.name);
49
+ out.push({ dirName: d.name, name, description, category, content, peerFiles });
50
+ }
51
+ return out;
52
+ },
53
+
54
+ async fetchCommands() {
55
+ const out = [];
56
+ for (const f of await listDir(join(root, "commands"))) {
57
+ if (!f.isFile() || !f.name.endsWith(".md")) continue;
58
+ const content = await readFile(join(root, "commands", f.name), "utf-8");
59
+ const { name, description, category, requiresAgents } = parseFrontmatter(content, f.name.replace(/\.md$/, ""));
60
+ out.push({ fileName: f.name, name, description, category, requiresAgents, content });
61
+ }
62
+ return out;
63
+ },
64
+
65
+ async fetchAgents() {
66
+ const out = [];
67
+ for (const f of await listDir(join(root, "agents"))) {
68
+ if (!f.isFile() || !f.name.endsWith(".md")) continue;
69
+ const content = await readFile(join(root, "agents", f.name), "utf-8");
70
+ const { name } = parseFrontmatter(content, f.name.replace(/\.md$/, ""));
71
+ out.push({ fileName: f.name, name, content });
72
+ }
73
+ return out;
74
+ },
75
+ };
76
+ }
77
+
78
+ // mutmut's config has to name the real source and test directories, and the
79
+ // install command has to match how the project actually manages dependencies —
80
+ // `pip install` into a uv project puts it somewhere the container rebuild loses.
81
+ async function pythonAdvice(dir, rel, entries) {
82
+ const has = (n) => entries.some((e) => e.name === n);
83
+ const pyproject = has("pyproject.toml") ? await readFile(join(dir, "pyproject.toml"), "utf-8") : "";
84
+
85
+ let install = "pip install mutmut";
86
+ if (has("uv.lock") || /^\[tool\.uv\]/m.test(pyproject)) install = "uv add --dev mutmut";
87
+ else if (has("poetry.lock")) install = "poetry add --group dev mutmut";
88
+
89
+ // Source: the package directory, not a guess. Skip the ones that are never it.
90
+ const ignore = ["tests", "test", "alembic", "migrations", "db", "e2e", "__pycache__", ".venv", "venv", "scripts"];
91
+ const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !ignore.includes(e.name));
92
+ const source = (dirs.find((d) => d.name === "src") || dirs.find((d) => d.name === "app") || dirs[0])?.name || "src";
93
+
94
+ // Tests: pytest already knows where they are.
95
+ const testpaths = pyproject.match(/testpaths\s*=\s*\[([^\]]*)\]/);
96
+ const tests = testpaths ? testpaths[1].replace(/["'\s]/g, "").split(",")[0] : "tests";
97
+
98
+ return {
99
+ install,
100
+ config: `${rel ? rel + "/" : ""}pyproject.toml [tool.mutmut] source_paths=["${source}/"] pytest_add_cli_args_test_selection=["${tests}/"]`,
101
+ };
102
+ }
103
+
104
+ // What the installed commands need that this project does not have yet.
105
+ // ship-gate.sh's own detection drives a single-package repo; a monorepo whose
106
+ // tools need a workspace prefix or a container sets GAUNTLET_MUTATE instead. Only
107
+ // /ship needs anything: a mutation tool, per project, since a monorepo can hold
108
+ // several and they do not share one.
109
+ export async function reportToolNeeds(projectDir) {
110
+ const needs = [];
111
+ const seen = new Set();
112
+ // Whether a nested project of each kind exists at all — not whether it still
113
+ // needs a tool. A root that delegates is not the place to install, even once
114
+ // the workspace it delegates to is already set up.
115
+ const nested = { Stryker: false, mutmut: false };
116
+
117
+ async function scan(rel, depth) {
118
+ if (depth > 2) return;
119
+ const dir = rel ? join(projectDir, rel) : projectDir;
120
+ const entries = await listDir(dir);
121
+ const label = rel ? `${rel}/` : "<repo root>";
122
+
123
+ const pkg = entries.find((e) => e.isFile() && e.name === "package.json");
124
+ const pyp = entries.find((e) => e.isFile() && (e.name === "pyproject.toml" || e.name === "pytest.ini"));
125
+
126
+ if (pkg && rel) nested.Stryker = true;
127
+ if (pyp && rel) nested.mutmut = true;
128
+
129
+ if (pkg && !seen.has(label)) {
130
+ seen.add(label);
131
+ const text = await readFile(join(dir, "package.json"), "utf-8");
132
+ if (!text.includes("@stryker-mutator/core")) {
133
+ const at = rel ? rel + "/" : "";
134
+ needs.push({
135
+ label,
136
+ tool: "Stryker",
137
+ install: `npm --prefix ${rel || "."} i -D @stryker-mutator/core @stryker-mutator/vitest-runner`,
138
+ config: `${at}stryker.config.json {"testRunner":"vitest","plugins":["@stryker-mutator/vitest-runner"],"coverageAnalysis":"perTest"}`,
139
+ // Stryker copies the whole project into .stryker-tmp/sandbox-*/, so
140
+ // every test file briefly exists twice and the runner collects both.
141
+ // The duplicates pass, so a doubled test count is the only symptom.
142
+ // Spread configDefaults.exclude — assigning it drops node_modules.
143
+ also: [
144
+ `${at}vitest.config.ts exclude: [...configDefaults.exclude, "**/.stryker-tmp/**"]`,
145
+ `${at}.gitignore .stryker-tmp/`,
146
+ ],
147
+ });
148
+ }
149
+ }
150
+ if (pyp && !seen.has(label + ":py")) {
151
+ seen.add(label + ":py");
152
+ const text = pyp.name === "pyproject.toml" ? await readFile(join(dir, "pyproject.toml"), "utf-8") : "";
153
+ if (!text.includes("[tool.mutmut]")) {
154
+ const advice = await pythonAdvice(dir, rel, entries);
155
+ needs.push({ label, tool: "mutmut", ...advice });
156
+ }
157
+ }
158
+
159
+ for (const e of entries) {
160
+ if (!e.isDirectory()) continue;
161
+ if (["node_modules", ".git", "dist", "build", ".venv", "venv", ".claude"].includes(e.name)) continue;
162
+ await scan(rel ? `${rel}/${e.name}` : e.name, depth + 1);
163
+ }
164
+ }
165
+
166
+ await scan("", 0);
167
+
168
+ // A root package.json that delegates to a workspace (npm --prefix web run test)
169
+ // has no tests of its own, so asking for a mutation tool there is noise.
170
+ return needs.filter((n) => n.label !== "<repo root>" || !nested[n.tool]);
171
+ }
@@ -162,9 +162,8 @@ exit 0
162
162
  const GAUNTLET_SCRIPT = `#!/usr/bin/env bash
163
163
  # gauntlet.sh — Stop hook: fast deterministic gates on the changed diff.
164
164
  #
165
- # Runs when Claude finishes a turn. Silent when green. On a red gate it returns
166
- # {"decision":"continue"} so the turn does not end, handing the failure back to
167
- # Claude to fix before the user sees it.
165
+ # Runs when Claude finishes a turn. Silent when green. On a red gate it exits 2:
166
+ # the turn keeps going, the user is shown why, and Claude is handed the failure.
168
167
  #
169
168
  # Two things the hook contract makes non-negotiable:
170
169
  # - stop_hook_active must short-circuit, or a red gate loops forever.
@@ -240,7 +239,10 @@ CHANGED=$( { git diff --name-only HEAD 2>/dev/null; \\
240
239
  if [ -z "$CHANGED" ] && [ -z "\${GAUNTLET_DRYRUN:-}" ]; then quit "skipped: no changed files"; fi
241
240
 
242
241
  # ---------------------------------------------------------------- skip rule 2
243
- FILES_NL=$(printf '%s\\n' "$CHANGED" | grep -E "\\.($GAUNTLET_CODE_EXT)$" || true)
242
+ # .stryker-tmp holds Stryker's sandbox — a full copy of the project. A crashed
243
+ # run leaves it behind, and then every file in it looks like a changed file.
244
+ FILES_NL=$(printf '%s\\n' "$CHANGED" | grep -E "\\.($GAUNTLET_CODE_EXT)$" \\
245
+ | grep -vE '(^|/)\\.stryker-tmp/' || true)
244
246
  if [ -z "$FILES_NL" ] && [ -z "\${GAUNTLET_DRYRUN:-}" ]; then
245
247
  quit "skipped: no changed code files (docs/config only)"
246
248
  fi
@@ -445,6 +447,325 @@ $FILES
445
447
  --- $FAILED output (last 60 lines) ---
446
448
  $TAIL"
447
449
 
450
+ printf '%s\\n' "red: the $FAILED gate failed" > "$WHY" 2>/dev/null
451
+
452
+ # Exit 2 is the only path where a red is visible to the user: its stderr is shown
453
+ # to them as the reason the turn is continuing. Exit 0 with {"decision":"continue"}
454
+ # also keeps the turn going, but the reason reaches Claude alone — so a failing
455
+ # gate looked, from the outside, exactly like a gauntlet that does nothing.
456
+ printf '%s\\n' "$MSG" >&2
457
+ exit 2
458
+ `;
459
+
460
+ // The deterministic half of /ship's quality gate: file length and mutation,
461
+ // behind an exit code rather than in prose a model can talk past.
462
+ // Canonical source: scripts/ship-gate.sh.
463
+ const SHIP_GATE_SCRIPT = `#!/usr/bin/env bash
464
+ # ship-gate.sh — the deterministic half of /ship's quality gate.
465
+ #
466
+ # File length and mutation are not judgement calls, so they do not live in prose
467
+ # where a model can decide they are not worth it. They live here, behind an exit
468
+ # code. /ship runs this and obeys the result; the skills audit stays in the
469
+ # command, because that one genuinely needs a model.
470
+ #
471
+ # bash .claude/hooks/ship-gate.sh [base-ref]
472
+ # bash .claude/hooks/ship-gate.sh --key print the receipt key and stop
473
+ # bash .claude/hooks/ship-gate.sh --force write a FORCED receipt, run nothing
474
+ #
475
+ # On completion it writes a RECEIPT at /tmp/claude-shipgate-<key>, and the
476
+ # PreToolUse hook refuses \`git commit\` / \`git push\` without a matching one. The
477
+ # point is that a gate has to be evidence rather than an instruction: a sentence
478
+ # saying "run this first" is a sentence an agent can decide is not worth it, and
479
+ # then report a pass it never earned. A receipt cannot be reported, only produced.
480
+ #
481
+ # The key is the content of everything about to ship, so it is the same before
482
+ # and after \`git commit\` — and it changes the moment anything is edited, which is
483
+ # what makes a fix re-gate itself instead of riding on the previous verdict.
484
+ #
485
+ # Exit codes:
486
+ # 0 clean — nothing over the line limit, no surviving mutants
487
+ # 1 findings that must be dealt with before shipping
488
+ # 2 ran, but could not prove the tests (a mutation tool is missing) — report,
489
+ # don't block; the output names exactly what to install and where
490
+ #
491
+ # Config: the same .claude/gauntlet.conf as the Stop hook.
492
+ # GAUNTLET_MAX_LINES=200 the per-file limit; 0 turns the check off
493
+ # GAUNTLET_SOURCE_EXT=... which extensions count as source. Deliberately NOT
494
+ # GAUNTLET_CODE_EXT: the Stop hook's key answers a
495
+ # different question, and a docs repo sets it to .md
496
+ # GAUNTLET_MUTATE="cmd" one explicit mutation command for the whole repo,
497
+ # replacing per-project detection. $MUTATE_FLAGS holds
498
+ # the --mutate flags, $FILES the changed files.
499
+
500
+ set -uo pipefail
501
+
502
+ MODE=""
503
+ case "\${1:-}" in --key) MODE=key; shift ;; --force) MODE=force; shift ;; esac
504
+
505
+ PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-$PWD}"
506
+ cd "$PROJECT_DIR" 2>/dev/null || { echo "ship-gate: cannot read $PROJECT_DIR"; exit 1; }
507
+ git rev-parse --git-dir >/dev/null 2>&1 || { echo "ship-gate: not a git repo"; exit 1; }
508
+
509
+ GAUNTLET_MAX_LINES=200
510
+ GAUNTLET_MUTATE=""
511
+ GAUNTLET_SOURCE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift"
512
+ [ -n "\${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ] && . "$HOME/.claude/gauntlet.conf"
513
+ [ -f ".claude/gauntlet.conf" ] && . ".claude/gauntlet.conf"
514
+
515
+ # ------------------------------------------------------------------- the scope
516
+ BASE="\${1:-}"
517
+ if [ -z "$BASE" ]; then
518
+ BASE=$(git merge-base HEAD develop 2>/dev/null \\
519
+ || git merge-base HEAD main 2>/dev/null \\
520
+ || git rev-list --max-parents=0 HEAD | head -1)
521
+ fi
522
+
523
+ CHANGED=$( { git diff "$BASE"...HEAD --name-only 2>/dev/null
524
+ git diff HEAD --name-only 2>/dev/null
525
+ git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )
526
+
527
+ # The repo path plus the names and contents of the changed files, in a fixed
528
+ # order. The path is in there because the key is otherwise pure content: two
529
+ # repos holding the same file would share a receipt, and one would be waved
530
+ # through on the other's verdict. Deliberately not a diff: a file moves from "untracked" to "in the diff" at commit time, so a
531
+ # diff-based key changes when the content did not, and the receipt would go stale
532
+ # the moment it was committed. This is identical either side of a commit, and
533
+ # changes the instant any of those files is edited.
534
+ RECEIPT_KEY=$( { printf '%s\\n' "$PROJECT_DIR"
535
+ printf '%s\\n' "$CHANGED" | while IFS= read -r f; do
536
+ [ -n "$f" ] || continue
537
+ printf '%s\\n' "$f"
538
+ [ -f "$f" ] && cat "$f"
539
+ done; } | git hash-object --stdin )
540
+ RECEIPT="/tmp/claude-shipgate-$RECEIPT_KEY"
541
+
542
+ if [ "$MODE" = key ]; then printf '%s\\n' "$RECEIPT_KEY"; exit 0; fi
543
+ if [ "$MODE" = force ]; then
544
+ printf 'FORCED %s\\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
545
+ echo "ship-gate: FORCED — receipt written without running any check."
546
+ exit 0
547
+ fi
548
+ FILES=$(printf '%s\\n' "$CHANGED" | grep -E "\\.($GAUNTLET_SOURCE_EXT)$" \\
549
+ | grep -vE '(\\.|_)(test|spec)\\.[^.]+$|(^|/)tests?/|(^|/)\\.stryker-tmp/' | while read -r f; do
550
+ [ -f "$f" ] && printf '%s\\n' "$f"
551
+ done)
552
+
553
+ if [ -z "$FILES" ]; then
554
+ echo "ship-gate: no changed code files — nothing to check"
555
+ printf 'PASS %s no-code-changes\\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
556
+ exit 0
557
+ fi
558
+
559
+ echo "ship-gate: $(printf '%s\\n' "$FILES" | wc -l) changed code file(s), base $(git rev-parse --short "$BASE")"
560
+ echo
561
+ STATUS=0
562
+
563
+ # ------------------------------------------------- check 1: file length (hard)
564
+ OVER=""
565
+ LARGEST=0
566
+ if [ "$GAUNTLET_MAX_LINES" -gt 0 ]; then
567
+ while IFS= read -r f; do
568
+ n=$(wc -l < "$f")
569
+ [ "$n" -gt "$LARGEST" ] && LARGEST=$n
570
+ [ "$n" -gt "$GAUNTLET_MAX_LINES" ] && OVER="$OVER $f — $n lines (limit $GAUNTLET_MAX_LINES)
571
+ "
572
+ done <<< "$FILES"
573
+ fi
574
+
575
+ if [ "$GAUNTLET_MAX_LINES" -le 0 ]; then
576
+ echo "FILE LENGTH — off (GAUNTLET_MAX_LINES=0)"
577
+ elif [ -n "$OVER" ]; then
578
+ echo "FILE LENGTH — over the limit:"
579
+ printf '%s' "$OVER"
580
+ echo " Splitting a file is a design decision. Do it deliberately, or ship with --force."
581
+ STATUS=1
582
+ else
583
+ echo "FILE LENGTH — ok, largest is $LARGEST lines"
584
+ fi
585
+
586
+ # ---------------------------------------------------------- which project owns
587
+ # A monorepo holds several projects, each with its own runner and its own
588
+ # mutation tool. Walk up from each changed file to the nearest manifest.
589
+ owner_of() {
590
+ d=$(dirname "$1")
591
+ while :; do
592
+ if [ -f "$d/package.json" ] || [ -f "$d/pyproject.toml" ] \\
593
+ || [ -f "$d/pytest.ini" ] || [ -f "$d/setup.cfg" ]; then
594
+ printf '%s\\n' "\${d#./}"; return
595
+ fi
596
+ [ "$d" = "." ] || [ "$d" = "/" ] && { printf '.\\n'; return; }
597
+ d=$(dirname "$d")
598
+ done
599
+ }
600
+
601
+ OWNERS=$(while IFS= read -r f; do owner_of "$f"; done <<< "$FILES" | sort -u)
602
+
603
+ # --------------------------------------------- check 2: mutation, per project
604
+ echo
605
+ echo "MUTATION — $(printf '%s\\n' "$OWNERS" | wc -l) project(s) in this diff"
606
+ MISSING=""
607
+
608
+ for owner in $OWNERS; do
609
+ [ "$owner" = "." ] && label="<repo root>" || label="$owner/"
610
+ OWNED=$(while IFS= read -r f; do
611
+ [ "$(owner_of "$f")" = "$owner" ] && printf '%s\\n' "$f"
612
+ done <<< "$FILES")
613
+ [ -z "$OWNED" ] && continue
614
+
615
+ # --mutate flags, one per changed hunk. Ranges inside a file are already
616
+ # comma-separated, so they cannot share a comma-joined list across files.
617
+ FLAGS=""
618
+ while IFS= read -r f; do
619
+ hunks=$(git diff -U0 "$BASE" -- "$f" 2>/dev/null \\
620
+ | grep -oE '^@@ -[0-9,]+ \\+[0-9]+(,[0-9]+)?' | sed 's/.*+//' \\
621
+ | while IFS=, read -r s l; do l=\${l:-1}; [ "$l" -gt 0 ] && echo "$s-$((s+l-1))"; done)
622
+ [ -z "$hunks" ] && hunks="1-$(wc -l < "$f")"
623
+ rel=\${f#$owner/}
624
+ for r in $hunks; do FLAGS="$FLAGS --mutate '$rel:$r'"; done
625
+ done <<< "$OWNED"
626
+
627
+ base=""; RUN=""
628
+ if [ "$owner" != "." ]; then base="$owner/"; RUN="cd '$owner' && "; fi
629
+ if [ -n "$GAUNTLET_MUTATE" ]; then
630
+ CMD="$GAUNTLET_MUTATE"; TOOL="config"
631
+ elif [ -f "$base"package.json ] && grep -qs '@stryker-mutator/core' "$base"package.json; then
632
+ TOOL="stryker"
633
+ # From inside the owning package: npx resolves against ITS node_modules, and
634
+ # stryker.config.json lives there too. A monorepo root usually has neither.
635
+ CMD="\${RUN}npx --no-install stryker run --incremental --force $FLAGS"
636
+ elif [ -f "$base"package.json ]; then
637
+ MISSING="$MISSING $label needs Stryker:
638
+ npm --prefix \${owner} i -D @stryker-mutator/core @stryker-mutator/vitest-runner
639
+ then \${base}stryker.config.json:
640
+ {\\"testRunner\\":\\"vitest\\",\\"plugins\\":[\\"@stryker-mutator/vitest-runner\\"],\\"coverageAnalysis\\":\\"perTest\\"}
641
+ "
642
+ continue
643
+ elif command -v mutmut >/dev/null 2>&1; then
644
+ TOOL="mutmut"
645
+ # mutmut takes its scope from [tool.mutmut] in pyproject, not from a flag:
646
+ # --paths-to-mutate was 2.x, and 3.x filters by fnmatch globs over mutant
647
+ # NAMES (app.balance.reserve.*). There is no per-line scoping at all.
648
+ CMD="\${RUN}mutmut run"
649
+ else
650
+ MISSING="$MISSING $label needs mutmut:
651
+ pip install mutmut
652
+ then \${base}pyproject.toml:
653
+ [tool.mutmut]
654
+ source_paths = [\\"src/\\"]
655
+ pytest_add_cli_args_test_selection = [\\"tests/\\"]
656
+ (mutmut 3 renamed these — paths_to_mutate/tests_dir are 2.x and are ignored)
657
+ "
658
+ continue
659
+ fi
660
+
661
+ OUT=$(eval "$CMD" 2>&1); RC=$?
662
+ # Match a finding, never a summary row. Stryker prints a \`# survived\` COLUMN
663
+ # HEADER every run and repeats the word in its table, so a bare grep reports
664
+ # survivors on a clean run — worse than not running at all. Stryker marks each
665
+ # real finding \`[Survived]\`; table and border lines are excluded outright.
666
+ case "$TOOL" in
667
+ stryker) PATTERN='\\[Survived\\]' ;;
668
+ *) PATTERN='[Ss]urvived' ;;
669
+ esac
670
+ SURVIVED=$(printf '%s\\n' "$OUT" | grep -E "$PATTERN" \\
671
+ | grep -vE '^[[:space:]]*[#│|+-]|# *surviv' | head -20)
672
+ if [ -n "$SURVIVED" ]; then
673
+ echo " $label $TOOL — surviving mutants; these lines can break and no test notices:"
674
+ printf '%s\\n' "$SURVIVED" | sed 's/^/ /'
675
+ STATUS=1
676
+ elif [ $RC -ne 0 ]; then
677
+ echo " $label $TOOL — the run failed:"
678
+ printf '%s\\n' "$OUT" | tail -12 | sed 's/^/ /'
679
+ [ "$STATUS" = 0 ] && STATUS=2
680
+ else
681
+ echo " $label $TOOL — ok, no surviving mutants in the changed lines"
682
+ fi
683
+ done
684
+
685
+ if [ -n "$MISSING" ]; then
686
+ echo " UNPROVEN — no mutation tool for these, so their tests were never shown"
687
+ echo " to catch a break. Install per project:"
688
+ printf '%s' "$MISSING"
689
+ [ "$STATUS" = 0 ] && STATUS=2
690
+ fi
691
+
692
+ # The receipt is written by this script and nothing else. A hand-rolled check
693
+ # produces no receipt, which is the whole point: substituting a weaker check is
694
+ # a choice that can be argued for, but it cannot be passed off as this one.
695
+ echo
696
+ case $STATUS in
697
+ 0) echo "ship-gate: PASS"
698
+ printf 'PASS %s\\n' "$(date -u +%FT%TZ)" > "$RECEIPT" ;;
699
+ 1) echo "ship-gate: FAIL — deal with the findings above, then run this again."
700
+ echo " To ship anyway: bash .claude/hooks/ship-gate.sh --force"
701
+ rm -f "$RECEIPT" ;;
702
+ 2) echo "ship-gate: UNPROVEN — nothing is wrong, but nothing was proven either"
703
+ printf 'UNPROVEN %s\\n' "$(date -u +%FT%TZ)" > "$RECEIPT" ;;
704
+ esac
705
+ exit $STATUS
706
+ `;
707
+
708
+ // PreToolUse on Bash: refuses git commit/push without a ship-gate receipt.
709
+ // Canonical source: scripts/ship-gate-hook.sh.
710
+ const SHIP_GATE_HOOK_SCRIPT = `#!/usr/bin/env bash
711
+ # ship-gate-hook.sh — PreToolUse on Bash. Refuses \`git commit\` and \`git push\`
712
+ # unless ship-gate.sh has left a receipt for exactly these changes.
713
+ #
714
+ # A gate written as an instruction is a gate an agent can decide is not worth the
715
+ # time — and then report a pass it never earned, which is the part you cannot see
716
+ # from the outside. This removes the claim entirely: either the receipt exists for
717
+ # this exact content, or git does not run.
718
+ #
719
+ # The receipt key covers everything about to ship, so it survives \`git commit\`
720
+ # and still matches at push time, and it changes the moment any file is edited —
721
+ # a fix has to be re-gated instead of riding on the previous verdict.
722
+
723
+ INPUT=$(cat)
724
+
725
+ CMD=$(printf '%s' "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' \\
726
+ | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
727
+
728
+ # Only the two commands that publish work. Everything else passes untouched.
729
+ case "$CMD" in
730
+ *"git commit"*|*"git push"*) ;;
731
+ *) exit 0 ;;
732
+ esac
733
+
734
+ PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-$PWD}"
735
+ cd "$PROJECT_DIR" 2>/dev/null || exit 0
736
+
737
+ GATE="$PROJECT_DIR/.claude/hooks/ship-gate.sh"
738
+ [ -x "$GATE" ] || exit 0
739
+ git rev-parse --git-dir >/dev/null 2>&1 || exit 0
740
+
741
+ # Ask the gate for the key rather than recomputing it here. Two implementations
742
+ # of the same rule drift, and the drift is silent.
743
+ KEY=$(bash "$GATE" --key 2>/dev/null)
744
+ [ -z "$KEY" ] && exit 0
745
+
746
+ RECEIPT="/tmp/claude-shipgate-$KEY"
747
+ if [ -f "$RECEIPT" ]; then
748
+ exit 0
749
+ fi
750
+
751
+ MSG="BLOCKED: no ship-gate receipt for these changes.
752
+
753
+ The quality gate has not run against the code you are about to publish, or the
754
+ code changed since it last did. Run it:
755
+
756
+ bash .claude/hooks/ship-gate.sh
757
+
758
+ It checks file length and mutation-tests the changed lines, and writes a receipt
759
+ this hook can see. Fix what it reports and run it again — a fix invalidates the
760
+ previous receipt on purpose.
761
+
762
+ Do NOT hand-roll a substitute check: only ship-gate.sh writes a receipt, so a
763
+ weaker check you designed yourself cannot be passed off as this one.
764
+
765
+ To publish without the gate, say so out loud and run:
766
+
767
+ bash .claude/hooks/ship-gate.sh --force"
768
+
448
769
  json_escape() {
449
770
  local s="$1"
450
771
  s="\${s//\\\\/\\\\\\\\}"
@@ -455,11 +776,7 @@ json_escape() {
455
776
  printf '"%s"' "$s"
456
777
  }
457
778
 
458
- printf '%s\\n' "red: the $FAILED gate failed" > "$WHY" 2>/dev/null
459
- if [ -n "\${GAUNTLET_DEBUG:-}" ]; then printf 'gauntlet: red: the %s gate failed\\n' "$FAILED" >&2; fi
460
- # A Stop hook's decision is "continue" / "stop" / "escalate" — "block" is not a
461
- # valid value and would be ignored, letting the turn end on a red gate.
462
- printf '{"decision":"continue","reason":%s}\\n' "$(json_escape "$MSG")"
779
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\\n' "$(json_escape "$MSG")"
463
780
  exit 0
464
781
  `;
465
782
 
@@ -467,6 +784,8 @@ const GATE_FILENAME = "skill-gate.sh";
467
784
  const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
468
785
  const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
469
786
  const GAUNTLET_FILENAME = "gauntlet.sh";
787
+ const SHIP_GATE_FILENAME = "ship-gate.sh";
788
+ const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
470
789
  const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
471
790
  const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
472
791
 
@@ -517,6 +836,8 @@ export async function setupHook(targetDir = process.cwd()) {
517
836
  const autoMarkPath = join(hooksDir, AUTO_MARK_FILENAME);
518
837
  const applicationGatePath = join(hooksDir, APPLICATION_GATE_FILENAME);
519
838
  const gauntletPath = join(hooksDir, GAUNTLET_FILENAME);
839
+ const shipGatePath = join(hooksDir, SHIP_GATE_FILENAME);
840
+ const shipGateHookPath = join(hooksDir, SHIP_GATE_HOOK_FILENAME);
520
841
  const settingsPath = join(resolved, ".claude", "settings.json");
521
842
 
522
843
  await mkdir(hooksDir, { recursive: true });
@@ -528,6 +849,10 @@ export async function setupHook(targetDir = process.cwd()) {
528
849
  await chmod(applicationGatePath, 0o755);
529
850
  await writeFile(gauntletPath, GAUNTLET_SCRIPT, { mode: 0o755 });
530
851
  await chmod(gauntletPath, 0o755);
852
+ await writeFile(shipGatePath, SHIP_GATE_SCRIPT, { mode: 0o755 });
853
+ await chmod(shipGatePath, 0o755);
854
+ await writeFile(shipGateHookPath, SHIP_GATE_HOOK_SCRIPT, { mode: 0o755 });
855
+ await chmod(shipGateHookPath, 0o755);
531
856
 
532
857
  // Remove the legacy audit-runner hook file from prior installs (2.3.x).
533
858
  try {
@@ -588,6 +913,17 @@ export async function setupHook(targetDir = process.cwd()) {
588
913
  );
589
914
  if (!applicationAlreadyRegistered) settings.hooks.PreToolUse.push(applicationEntry);
590
915
 
916
+ // Register PreToolUse ship-gate receipt check on Bash.
917
+ const shipGateCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${SHIP_GATE_HOOK_FILENAME}`;
918
+ const shipGateEntry = {
919
+ matcher: "Bash",
920
+ hooks: [{ type: "command", command: shipGateCommand }],
921
+ };
922
+ const shipGateRegistered = settings.hooks.PreToolUse.some((entry) =>
923
+ entry.hooks?.some((h) => h.command?.endsWith(SHIP_GATE_HOOK_FILENAME))
924
+ );
925
+ if (!shipGateRegistered) settings.hooks.PreToolUse.push(shipGateEntry);
926
+
591
927
  // Register PostToolUse auto-mark on Skill.
592
928
  const autoMarkCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${AUTO_MARK_FILENAME}`;
593
929
  const autoMarkEntry = {
@@ -623,5 +959,7 @@ export async function setupHook(targetDir = process.cwd()) {
623
959
  console.log(` Hook installed: .claude/hooks/${AUTO_MARK_FILENAME}`);
624
960
  console.log(` Hook installed: .claude/hooks/${APPLICATION_GATE_FILENAME}`);
625
961
  console.log(` Hook installed: .claude/hooks/${GAUNTLET_FILENAME}`);
962
+ console.log(` Installed: .claude/hooks/${SHIP_GATE_FILENAME} (used by /ship)`);
963
+ console.log(` Hook installed: .claude/hooks/${SHIP_GATE_HOOK_FILENAME}`);
626
964
  console.log(` Settings updated: .claude/settings.json`);
627
965
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.11.1",
3
+ "version": "2.12.0",
4
4
  "description": "CLI to install Claude Code skills from the claude-skills collection",
5
5
  "type": "module",
6
6
  "bin": {