@spardutti/claude-skills 2.11.0 → 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 +35 -7
- package/bin/cli.mjs +44 -6
- package/lib/local.mjs +171 -0
- package/lib/setup-hook.mjs +368 -38
- package/package.json +1 -1
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
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
|
@@ -158,10 +173,12 @@ async function main() {
|
|
|
158
173
|
await setupClaudeMd();
|
|
159
174
|
|
|
160
175
|
// Report what the gauntlet will actually run, so a silent no-op is visible.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
176
|
+
// This asks the installed hook itself — never a second copy of its logic.
|
|
177
|
+
const gates = await detectStack(CWD);
|
|
178
|
+
if (gates) {
|
|
179
|
+
for (const gate of gates.split(";").map((g) => g.trim()).filter(Boolean)) {
|
|
180
|
+
console.log(` ${chalk.dim(`Gauntlet gate: ${gate}`)}`);
|
|
181
|
+
}
|
|
165
182
|
} else {
|
|
166
183
|
console.log(` ${chalk.yellow("!")} ${chalk.dim("Gauntlet found no test runner here — it will stay asleep.")}`);
|
|
167
184
|
console.log();
|
|
@@ -180,6 +197,27 @@ async function main() {
|
|
|
180
197
|
}
|
|
181
198
|
}
|
|
182
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
|
+
|
|
183
221
|
// --- Record what is now installed ---
|
|
184
222
|
await writeManifest(CWD, {
|
|
185
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
|
+
}
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, writeFile, readFile, chmod, unlink } from "node:fs/promises";
|
|
2
2
|
import { join, resolve, dirname } from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
3
4
|
|
|
4
5
|
// PreToolUse gate on Write|Edit|MultiEdit. Blocks the tool call unless
|
|
5
6
|
// a per-session marker file exists at /tmp/claude-skill-gate-<SESSION_ID>.
|
|
@@ -161,9 +162,8 @@ exit 0
|
|
|
161
162
|
const GAUNTLET_SCRIPT = `#!/usr/bin/env bash
|
|
162
163
|
# gauntlet.sh — Stop hook: fast deterministic gates on the changed diff.
|
|
163
164
|
#
|
|
164
|
-
# Runs when Claude finishes a turn. Silent when green. On a red gate it
|
|
165
|
-
#
|
|
166
|
-
# 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.
|
|
167
167
|
#
|
|
168
168
|
# Two things the hook contract makes non-negotiable:
|
|
169
169
|
# - stop_hook_active must short-circuit, or a red gate loops forever.
|
|
@@ -239,7 +239,10 @@ CHANGED=$( { git diff --name-only HEAD 2>/dev/null; \\
|
|
|
239
239
|
if [ -z "$CHANGED" ] && [ -z "\${GAUNTLET_DRYRUN:-}" ]; then quit "skipped: no changed files"; fi
|
|
240
240
|
|
|
241
241
|
# ---------------------------------------------------------------- skip rule 2
|
|
242
|
-
|
|
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)
|
|
243
246
|
if [ -z "$FILES_NL" ] && [ -z "\${GAUNTLET_DRYRUN:-}" ]; then
|
|
244
247
|
quit "skipped: no changed code files (docs/config only)"
|
|
245
248
|
fi
|
|
@@ -444,6 +447,325 @@ $FILES
|
|
|
444
447
|
--- $FAILED output (last 60 lines) ---
|
|
445
448
|
$TAIL"
|
|
446
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
|
+
|
|
447
769
|
json_escape() {
|
|
448
770
|
local s="$1"
|
|
449
771
|
s="\${s//\\\\/\\\\\\\\}"
|
|
@@ -454,11 +776,7 @@ json_escape() {
|
|
|
454
776
|
printf '"%s"' "$s"
|
|
455
777
|
}
|
|
456
778
|
|
|
457
|
-
printf '
|
|
458
|
-
if [ -n "\${GAUNTLET_DEBUG:-}" ]; then printf 'gauntlet: red: the %s gate failed\\n' "$FAILED" >&2; fi
|
|
459
|
-
# A Stop hook's decision is "continue" / "stop" / "escalate" — "block" is not a
|
|
460
|
-
# valid value and would be ignored, letting the turn end on a red gate.
|
|
461
|
-
printf '{"decision":"continue","reason":%s}\\n' "$(json_escape "$MSG")"
|
|
779
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\\n' "$(json_escape "$MSG")"
|
|
462
780
|
exit 0
|
|
463
781
|
`;
|
|
464
782
|
|
|
@@ -466,40 +784,33 @@ const GATE_FILENAME = "skill-gate.sh";
|
|
|
466
784
|
const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
|
|
467
785
|
const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
|
|
468
786
|
const GAUNTLET_FILENAME = "gauntlet.sh";
|
|
787
|
+
const SHIP_GATE_FILENAME = "ship-gate.sh";
|
|
788
|
+
const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
|
|
469
789
|
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
470
790
|
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
471
791
|
|
|
472
|
-
//
|
|
473
|
-
//
|
|
792
|
+
// Asks the installed hook what it would run, rather than reimplementing its
|
|
793
|
+
// detection here. A second copy drifts: the JS one reported "no test runner"
|
|
794
|
+
// for repos the shell script had already learned to handle.
|
|
474
795
|
export async function detectStack(targetDir = process.cwd()) {
|
|
475
796
|
const dir = resolve(targetDir);
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
if (/"typecheck"\s*:/.test(pkg)) typecheck = "npm run typecheck";
|
|
494
|
-
else if (await has("tsconfig.json")) typecheck = "tsc";
|
|
495
|
-
}
|
|
496
|
-
if (!test && (await has("pyproject.toml") || await has("pytest.ini") || await has("setup.cfg"))) {
|
|
497
|
-
test = "pytest";
|
|
498
|
-
if (await has("mypy.ini") || (await read("pyproject.toml")).includes("tool.mypy")) {
|
|
499
|
-
typecheck = "mypy";
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
return { test, typecheck };
|
|
797
|
+
const hook = join(dir, ".claude", "hooks", GAUNTLET_FILENAME);
|
|
798
|
+
return new Promise((done) => {
|
|
799
|
+
const child = spawn("bash", [hook], {
|
|
800
|
+
cwd: dir,
|
|
801
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: dir, GAUNTLET_DEBUG: "1", GAUNTLET_DRYRUN: "1" },
|
|
802
|
+
});
|
|
803
|
+
let err = "";
|
|
804
|
+
child.stderr.on("data", (c) => { err += c; });
|
|
805
|
+
child.on("error", () => done(null));
|
|
806
|
+
child.on("close", () => {
|
|
807
|
+
const line = err.split("\n").find((l) => l.startsWith("gauntlet: would run:"));
|
|
808
|
+
if (!line) return done(null);
|
|
809
|
+
const gates = line.slice("gauntlet: would run:".length).trim();
|
|
810
|
+
done(gates && !gates.startsWith("nothing") ? gates : null);
|
|
811
|
+
});
|
|
812
|
+
child.stdin.end('{"session_id":"install"}');
|
|
813
|
+
});
|
|
503
814
|
}
|
|
504
815
|
|
|
505
816
|
// Written only when detection finds nothing — otherwise the hook auto-detects and
|
|
@@ -525,6 +836,8 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
525
836
|
const autoMarkPath = join(hooksDir, AUTO_MARK_FILENAME);
|
|
526
837
|
const applicationGatePath = join(hooksDir, APPLICATION_GATE_FILENAME);
|
|
527
838
|
const gauntletPath = join(hooksDir, GAUNTLET_FILENAME);
|
|
839
|
+
const shipGatePath = join(hooksDir, SHIP_GATE_FILENAME);
|
|
840
|
+
const shipGateHookPath = join(hooksDir, SHIP_GATE_HOOK_FILENAME);
|
|
528
841
|
const settingsPath = join(resolved, ".claude", "settings.json");
|
|
529
842
|
|
|
530
843
|
await mkdir(hooksDir, { recursive: true });
|
|
@@ -536,6 +849,10 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
536
849
|
await chmod(applicationGatePath, 0o755);
|
|
537
850
|
await writeFile(gauntletPath, GAUNTLET_SCRIPT, { mode: 0o755 });
|
|
538
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);
|
|
539
856
|
|
|
540
857
|
// Remove the legacy audit-runner hook file from prior installs (2.3.x).
|
|
541
858
|
try {
|
|
@@ -596,6 +913,17 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
596
913
|
);
|
|
597
914
|
if (!applicationAlreadyRegistered) settings.hooks.PreToolUse.push(applicationEntry);
|
|
598
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
|
+
|
|
599
927
|
// Register PostToolUse auto-mark on Skill.
|
|
600
928
|
const autoMarkCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${AUTO_MARK_FILENAME}`;
|
|
601
929
|
const autoMarkEntry = {
|
|
@@ -631,5 +959,7 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
631
959
|
console.log(` Hook installed: .claude/hooks/${AUTO_MARK_FILENAME}`);
|
|
632
960
|
console.log(` Hook installed: .claude/hooks/${APPLICATION_GATE_FILENAME}`);
|
|
633
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}`);
|
|
634
964
|
console.log(` Settings updated: .claude/settings.json`);
|
|
635
965
|
}
|