@spardutti/claude-skills 2.11.1 → 2.12.1
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 +38 -2
- package/lib/local.mjs +171 -0
- package/lib/setup-hook.mjs +365 -9
- 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
|
|
|
@@ -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
|
+
}
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -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
|
|
166
|
-
#
|
|
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
|
-
|
|
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,343 @@ $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
|
+
# mutmut is installed into the project's environment, not onto PATH. Ask the
|
|
590
|
+
# project how to run its own tools before falling back to a bare binary.
|
|
591
|
+
py_mutmut() {
|
|
592
|
+
d="$1"
|
|
593
|
+
# Returned relative to the PROJECT, because the command runs from inside it.
|
|
594
|
+
if [ -x "$d.venv/bin/mutmut" ]; then printf './.venv/bin/mutmut\\n'; return; fi
|
|
595
|
+
if [ -f "$d""uv.lock" ] && command -v uv >/dev/null 2>&1; then printf 'uv run mutmut\\n'; return; fi
|
|
596
|
+
if [ -f "$d""poetry.lock" ] && command -v poetry >/dev/null 2>&1; then printf 'poetry run mutmut\\n'; return; fi
|
|
597
|
+
command -v mutmut >/dev/null 2>&1 && printf 'mutmut\\n'
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
owner_of() {
|
|
601
|
+
d=$(dirname "$1")
|
|
602
|
+
while :; do
|
|
603
|
+
if [ -f "$d/package.json" ] || [ -f "$d/pyproject.toml" ] \\
|
|
604
|
+
|| [ -f "$d/pytest.ini" ] || [ -f "$d/setup.cfg" ]; then
|
|
605
|
+
printf '%s\\n' "\${d#./}"; return
|
|
606
|
+
fi
|
|
607
|
+
[ "$d" = "." ] || [ "$d" = "/" ] && { printf '.\\n'; return; }
|
|
608
|
+
d=$(dirname "$d")
|
|
609
|
+
done
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
OWNERS=$(while IFS= read -r f; do owner_of "$f"; done <<< "$FILES" | sort -u)
|
|
613
|
+
|
|
614
|
+
# --------------------------------------------- check 2: mutation, per project
|
|
615
|
+
echo
|
|
616
|
+
echo "MUTATION — $(printf '%s\\n' "$OWNERS" | wc -l) project(s) in this diff"
|
|
617
|
+
MISSING=""
|
|
618
|
+
|
|
619
|
+
for owner in $OWNERS; do
|
|
620
|
+
[ "$owner" = "." ] && label="<repo root>" || label="$owner/"
|
|
621
|
+
OWNED=$(while IFS= read -r f; do
|
|
622
|
+
[ "$(owner_of "$f")" = "$owner" ] && printf '%s\\n' "$f"
|
|
623
|
+
done <<< "$FILES")
|
|
624
|
+
[ -z "$OWNED" ] && continue
|
|
625
|
+
|
|
626
|
+
# --mutate flags, one per changed hunk. Ranges inside a file are already
|
|
627
|
+
# comma-separated, so they cannot share a comma-joined list across files.
|
|
628
|
+
FLAGS=""
|
|
629
|
+
while IFS= read -r f; do
|
|
630
|
+
hunks=$(git diff -U0 "$BASE" -- "$f" 2>/dev/null \\
|
|
631
|
+
| grep -oE '^@@ -[0-9,]+ \\+[0-9]+(,[0-9]+)?' | sed 's/.*+//' \\
|
|
632
|
+
| while IFS=, read -r s l; do l=\${l:-1}; [ "$l" -gt 0 ] && echo "$s-$((s+l-1))"; done)
|
|
633
|
+
[ -z "$hunks" ] && hunks="1-$(wc -l < "$f")"
|
|
634
|
+
rel=\${f#$owner/}
|
|
635
|
+
for r in $hunks; do FLAGS="$FLAGS --mutate '$rel:$r'"; done
|
|
636
|
+
done <<< "$OWNED"
|
|
637
|
+
|
|
638
|
+
base=""; RUN=""
|
|
639
|
+
if [ "$owner" != "." ]; then base="$owner/"; RUN="cd '$owner' && "; fi
|
|
640
|
+
if [ -n "$GAUNTLET_MUTATE" ]; then
|
|
641
|
+
CMD="$GAUNTLET_MUTATE"; TOOL="config"
|
|
642
|
+
elif [ -f "$base"package.json ] && grep -qs '@stryker-mutator/core' "$base"package.json; then
|
|
643
|
+
TOOL="stryker"
|
|
644
|
+
# From inside the owning package: npx resolves against ITS node_modules, and
|
|
645
|
+
# stryker.config.json lives there too. A monorepo root usually has neither.
|
|
646
|
+
CMD="\${RUN}npx --no-install stryker run --incremental --force $FLAGS"
|
|
647
|
+
elif [ -f "$base"package.json ]; then
|
|
648
|
+
MISSING="$MISSING $label needs Stryker:
|
|
649
|
+
npm --prefix \${owner} i -D @stryker-mutator/core @stryker-mutator/vitest-runner
|
|
650
|
+
then \${base}stryker.config.json:
|
|
651
|
+
{\\"testRunner\\":\\"vitest\\",\\"plugins\\":[\\"@stryker-mutator/vitest-runner\\"],\\"coverageAnalysis\\":\\"perTest\\"}
|
|
652
|
+
"
|
|
653
|
+
continue
|
|
654
|
+
elif [ -n "$(py_mutmut "$base")" ]; then
|
|
655
|
+
TOOL="mutmut"
|
|
656
|
+
# mutmut takes its scope from [tool.mutmut] in pyproject, not from a flag:
|
|
657
|
+
# --paths-to-mutate was 2.x, and 3.x filters by fnmatch globs over mutant
|
|
658
|
+
# NAMES (app.balance.reserve.*). There is no per-line scoping at all.
|
|
659
|
+
#
|
|
660
|
+
# \`mutmut run\` prints 🙁 for a survivor and exits 0 either way — parsing it
|
|
661
|
+
# reports clean with survivors sitting there, which is a false green and
|
|
662
|
+
# worse than reporting nothing. \`mutmut results\` is the readable source:
|
|
663
|
+
# it prints "<mutant name>: survived" per survivor.
|
|
664
|
+
M="$(py_mutmut "$base")"
|
|
665
|
+
# One cd for both: the second command runs in the same shell, already there.
|
|
666
|
+
CMD="\${RUN}$M run >/dev/null 2>&1; $M results"
|
|
667
|
+
else
|
|
668
|
+
MISSING="$MISSING $label needs mutmut:
|
|
669
|
+
uv add --dev mutmut # or: poetry add --group dev mutmut, pip install mutmut
|
|
670
|
+
then \${base}pyproject.toml:
|
|
671
|
+
[tool.mutmut]
|
|
672
|
+
source_paths = [\\"src/\\"]
|
|
673
|
+
pytest_add_cli_args_test_selection = [\\"tests/\\"]
|
|
674
|
+
(mutmut 3 renamed these — paths_to_mutate/tests_dir are 2.x and are ignored)
|
|
675
|
+
"
|
|
676
|
+
continue
|
|
677
|
+
fi
|
|
678
|
+
|
|
679
|
+
OUT=$(eval "$CMD" 2>&1); RC=$?
|
|
680
|
+
# Match a finding, never a summary row. Stryker prints a \`# survived\` COLUMN
|
|
681
|
+
# HEADER every run and repeats the word in its table, so a bare grep reports
|
|
682
|
+
# survivors on a clean run — worse than not running at all. Stryker marks each
|
|
683
|
+
# real finding \`[Survived]\`; table and border lines are excluded outright.
|
|
684
|
+
case "$TOOL" in
|
|
685
|
+
stryker) PATTERN='\\[Survived\\]' ;;
|
|
686
|
+
*) PATTERN='[Ss]urvived' ;;
|
|
687
|
+
esac
|
|
688
|
+
SURVIVED=$(printf '%s\\n' "$OUT" | grep -E "$PATTERN" \\
|
|
689
|
+
| grep -vE '^[[:space:]]*[#│|+-]|# *surviv' | head -20)
|
|
690
|
+
if [ -n "$SURVIVED" ]; then
|
|
691
|
+
echo " $label $TOOL — surviving mutants; these lines can break and no test notices:"
|
|
692
|
+
printf '%s\\n' "$SURVIVED" | sed 's/^/ /'
|
|
693
|
+
STATUS=1
|
|
694
|
+
elif [ $RC -ne 0 ]; then
|
|
695
|
+
echo " $label $TOOL — the run failed:"
|
|
696
|
+
printf '%s\\n' "$OUT" | tail -12 | sed 's/^/ /'
|
|
697
|
+
[ "$STATUS" = 0 ] && STATUS=2
|
|
698
|
+
else
|
|
699
|
+
echo " $label $TOOL — ok, no surviving mutants in the changed lines"
|
|
700
|
+
fi
|
|
701
|
+
done
|
|
702
|
+
|
|
703
|
+
if [ -n "$MISSING" ]; then
|
|
704
|
+
echo " UNPROVEN — no mutation tool for these, so their tests were never shown"
|
|
705
|
+
echo " to catch a break. Install per project:"
|
|
706
|
+
printf '%s' "$MISSING"
|
|
707
|
+
[ "$STATUS" = 0 ] && STATUS=2
|
|
708
|
+
fi
|
|
709
|
+
|
|
710
|
+
# The receipt is written by this script and nothing else. A hand-rolled check
|
|
711
|
+
# produces no receipt, which is the whole point: substituting a weaker check is
|
|
712
|
+
# a choice that can be argued for, but it cannot be passed off as this one.
|
|
713
|
+
echo
|
|
714
|
+
case $STATUS in
|
|
715
|
+
0) echo "ship-gate: PASS"
|
|
716
|
+
printf 'PASS %s\\n' "$(date -u +%FT%TZ)" > "$RECEIPT" ;;
|
|
717
|
+
1) echo "ship-gate: FAIL — deal with the findings above, then run this again."
|
|
718
|
+
echo " To ship anyway: bash .claude/hooks/ship-gate.sh --force"
|
|
719
|
+
rm -f "$RECEIPT" ;;
|
|
720
|
+
2) echo "ship-gate: UNPROVEN — nothing is wrong, but nothing was proven either"
|
|
721
|
+
printf 'UNPROVEN %s\\n' "$(date -u +%FT%TZ)" > "$RECEIPT" ;;
|
|
722
|
+
esac
|
|
723
|
+
exit $STATUS
|
|
724
|
+
`;
|
|
725
|
+
|
|
726
|
+
// PreToolUse on Bash: refuses git commit/push without a ship-gate receipt.
|
|
727
|
+
// Canonical source: scripts/ship-gate-hook.sh.
|
|
728
|
+
const SHIP_GATE_HOOK_SCRIPT = `#!/usr/bin/env bash
|
|
729
|
+
# ship-gate-hook.sh — PreToolUse on Bash. Refuses \`git commit\` and \`git push\`
|
|
730
|
+
# unless ship-gate.sh has left a receipt for exactly these changes.
|
|
731
|
+
#
|
|
732
|
+
# A gate written as an instruction is a gate an agent can decide is not worth the
|
|
733
|
+
# time — and then report a pass it never earned, which is the part you cannot see
|
|
734
|
+
# from the outside. This removes the claim entirely: either the receipt exists for
|
|
735
|
+
# this exact content, or git does not run.
|
|
736
|
+
#
|
|
737
|
+
# The receipt key covers everything about to ship, so it survives \`git commit\`
|
|
738
|
+
# and still matches at push time, and it changes the moment any file is edited —
|
|
739
|
+
# a fix has to be re-gated instead of riding on the previous verdict.
|
|
740
|
+
|
|
741
|
+
INPUT=$(cat)
|
|
742
|
+
|
|
743
|
+
CMD=$(printf '%s' "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' \\
|
|
744
|
+
| head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
745
|
+
|
|
746
|
+
# Only the two commands that publish work. Everything else passes untouched.
|
|
747
|
+
case "$CMD" in
|
|
748
|
+
*"git commit"*|*"git push"*) ;;
|
|
749
|
+
*) exit 0 ;;
|
|
750
|
+
esac
|
|
751
|
+
|
|
752
|
+
PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
753
|
+
cd "$PROJECT_DIR" 2>/dev/null || exit 0
|
|
754
|
+
|
|
755
|
+
GATE="$PROJECT_DIR/.claude/hooks/ship-gate.sh"
|
|
756
|
+
[ -x "$GATE" ] || exit 0
|
|
757
|
+
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
|
758
|
+
|
|
759
|
+
# Ask the gate for the key rather than recomputing it here. Two implementations
|
|
760
|
+
# of the same rule drift, and the drift is silent.
|
|
761
|
+
KEY=$(bash "$GATE" --key 2>/dev/null)
|
|
762
|
+
[ -z "$KEY" ] && exit 0
|
|
763
|
+
|
|
764
|
+
RECEIPT="/tmp/claude-shipgate-$KEY"
|
|
765
|
+
if [ -f "$RECEIPT" ]; then
|
|
766
|
+
exit 0
|
|
767
|
+
fi
|
|
768
|
+
|
|
769
|
+
MSG="BLOCKED: no ship-gate receipt for these changes.
|
|
770
|
+
|
|
771
|
+
The quality gate has not run against the code you are about to publish, or the
|
|
772
|
+
code changed since it last did. Run it:
|
|
773
|
+
|
|
774
|
+
bash .claude/hooks/ship-gate.sh
|
|
775
|
+
|
|
776
|
+
It checks file length and mutation-tests the changed lines, and writes a receipt
|
|
777
|
+
this hook can see. Fix what it reports and run it again — a fix invalidates the
|
|
778
|
+
previous receipt on purpose.
|
|
779
|
+
|
|
780
|
+
Do NOT hand-roll a substitute check: only ship-gate.sh writes a receipt, so a
|
|
781
|
+
weaker check you designed yourself cannot be passed off as this one.
|
|
782
|
+
|
|
783
|
+
To publish without the gate, say so out loud and run:
|
|
784
|
+
|
|
785
|
+
bash .claude/hooks/ship-gate.sh --force"
|
|
786
|
+
|
|
448
787
|
json_escape() {
|
|
449
788
|
local s="$1"
|
|
450
789
|
s="\${s//\\\\/\\\\\\\\}"
|
|
@@ -455,11 +794,7 @@ json_escape() {
|
|
|
455
794
|
printf '"%s"' "$s"
|
|
456
795
|
}
|
|
457
796
|
|
|
458
|
-
printf '
|
|
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")"
|
|
797
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\\n' "$(json_escape "$MSG")"
|
|
463
798
|
exit 0
|
|
464
799
|
`;
|
|
465
800
|
|
|
@@ -467,6 +802,8 @@ const GATE_FILENAME = "skill-gate.sh";
|
|
|
467
802
|
const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
|
|
468
803
|
const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
|
|
469
804
|
const GAUNTLET_FILENAME = "gauntlet.sh";
|
|
805
|
+
const SHIP_GATE_FILENAME = "ship-gate.sh";
|
|
806
|
+
const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
|
|
470
807
|
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
471
808
|
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
472
809
|
|
|
@@ -517,6 +854,8 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
517
854
|
const autoMarkPath = join(hooksDir, AUTO_MARK_FILENAME);
|
|
518
855
|
const applicationGatePath = join(hooksDir, APPLICATION_GATE_FILENAME);
|
|
519
856
|
const gauntletPath = join(hooksDir, GAUNTLET_FILENAME);
|
|
857
|
+
const shipGatePath = join(hooksDir, SHIP_GATE_FILENAME);
|
|
858
|
+
const shipGateHookPath = join(hooksDir, SHIP_GATE_HOOK_FILENAME);
|
|
520
859
|
const settingsPath = join(resolved, ".claude", "settings.json");
|
|
521
860
|
|
|
522
861
|
await mkdir(hooksDir, { recursive: true });
|
|
@@ -528,6 +867,10 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
528
867
|
await chmod(applicationGatePath, 0o755);
|
|
529
868
|
await writeFile(gauntletPath, GAUNTLET_SCRIPT, { mode: 0o755 });
|
|
530
869
|
await chmod(gauntletPath, 0o755);
|
|
870
|
+
await writeFile(shipGatePath, SHIP_GATE_SCRIPT, { mode: 0o755 });
|
|
871
|
+
await chmod(shipGatePath, 0o755);
|
|
872
|
+
await writeFile(shipGateHookPath, SHIP_GATE_HOOK_SCRIPT, { mode: 0o755 });
|
|
873
|
+
await chmod(shipGateHookPath, 0o755);
|
|
531
874
|
|
|
532
875
|
// Remove the legacy audit-runner hook file from prior installs (2.3.x).
|
|
533
876
|
try {
|
|
@@ -588,6 +931,17 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
588
931
|
);
|
|
589
932
|
if (!applicationAlreadyRegistered) settings.hooks.PreToolUse.push(applicationEntry);
|
|
590
933
|
|
|
934
|
+
// Register PreToolUse ship-gate receipt check on Bash.
|
|
935
|
+
const shipGateCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${SHIP_GATE_HOOK_FILENAME}`;
|
|
936
|
+
const shipGateEntry = {
|
|
937
|
+
matcher: "Bash",
|
|
938
|
+
hooks: [{ type: "command", command: shipGateCommand }],
|
|
939
|
+
};
|
|
940
|
+
const shipGateRegistered = settings.hooks.PreToolUse.some((entry) =>
|
|
941
|
+
entry.hooks?.some((h) => h.command?.endsWith(SHIP_GATE_HOOK_FILENAME))
|
|
942
|
+
);
|
|
943
|
+
if (!shipGateRegistered) settings.hooks.PreToolUse.push(shipGateEntry);
|
|
944
|
+
|
|
591
945
|
// Register PostToolUse auto-mark on Skill.
|
|
592
946
|
const autoMarkCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${AUTO_MARK_FILENAME}`;
|
|
593
947
|
const autoMarkEntry = {
|
|
@@ -623,5 +977,7 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
623
977
|
console.log(` Hook installed: .claude/hooks/${AUTO_MARK_FILENAME}`);
|
|
624
978
|
console.log(` Hook installed: .claude/hooks/${APPLICATION_GATE_FILENAME}`);
|
|
625
979
|
console.log(` Hook installed: .claude/hooks/${GAUNTLET_FILENAME}`);
|
|
980
|
+
console.log(` Installed: .claude/hooks/${SHIP_GATE_FILENAME} (used by /ship)`);
|
|
981
|
+
console.log(` Hook installed: .claude/hooks/${SHIP_GATE_HOOK_FILENAME}`);
|
|
626
982
|
console.log(` Settings updated: .claude/settings.json`);
|
|
627
983
|
}
|