@spardutti/claude-skills 2.53.1 → 2.54.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 CHANGED
@@ -36,7 +36,7 @@ npx @spardutti/claude-skills
36
36
  ```
37
37
 
38
38
  ```text
39
- Claude Skills Installer v2.53.1
39
+ Claude Skills Installer v2.54.1
40
40
 
41
41
  ── Frontend ──────────────────────────────
42
42
  ◉ react ◯ tanstack-query
@@ -8,6 +8,9 @@ GAUNTLET_IGNORE_FILES="*.gen.ts *.gen.tsx *.generated.* */migrations/*.py */alem
8
8
  # Components are length-checked but not mutated: their mutants are class names and JSX, not
9
9
  # behaviour. Override in .claude/gauntlet.conf to mutate them too.
10
10
  GAUNTLET_NO_MUTATE='\.(tsx|jsx)$'
11
+ # The /ship log is off unless a conf sets GAUNTLET_SHIP_LOG=1; every project writes to one folder.
12
+ GAUNTLET_SHIP_LOG=""
13
+ GAUNTLET_SHIP_LOG_DIR="${HOME:-}/.claude/ship-logs"
11
14
 
12
15
  load_gauntlet_conf() { # load_gauntlet_conf <project conf> <fallback conf>
13
16
  [ -n "${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ] && . "$HOME/.claude/gauntlet.conf"
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env bash
2
- # Sourced by ship-gate.sh: which changed files a Stryker run can skip, and what its output says.
3
2
 
4
3
  TEST_RE='(\.|_)(test|spec)\.[^.]+$|(^|/)tests?/'
5
4
 
@@ -90,20 +89,25 @@ files_record() { # files_record <context> <owner> <findings>
90
89
  # NoCoverage is printed exactly like Survived and means something worse: not
91
90
  # "a test missed this mutation" but "no test executes this line at all". The
92
91
  # gate matched only [Survived], so a diff whose files have no tests came back
93
- # PASS — 306 uncovered mutants reported as proven on one commit. It is judged
94
- # only for files that went in WITH a range: a glob-shaped path is mutated
95
- # whole, so its untouched lines would report uncovered forever, and a finding
96
- # nobody can close is what teaches people to --force.
92
+ # PASS — 306 uncovered mutants reported as proven on one commit. A glob-shaped
93
+ # path is mutated whole, so only its changed lines are judged: the rest would
94
+ # report forever, and a finding nobody can close is what teaches people to --force.
97
95
  #
98
96
  # The status line carries no file name; the line under it does, as
99
97
  # file:line:column. They are paired here rather than grepped separately.
100
- stryker_findings() { # stryker_findings <output> <globbed paths, comma-wrapped>
98
+ stryker_findings() { # stryker_findings <output> <",path:ranges," per globbed file>
101
99
  printf '%s\n' "$1" | awk -v globbed="$2" '
100
+ function changed(rs, l, parts, ab, i) {
101
+ for (i = split(rs, parts, " "); i > 0; i--) { split(parts[i], ab, "-"); if (l >= ab[1] + 0 && l <= ab[2] + 0) return 1 }
102
+ return 0
103
+ }
104
+ BEGIN { for (i = split(globbed, g, ","); i > 0; i--) if (g[i] != "") { k = g[i]; sub(/:[^:]*$/, "", k); r = g[i]; sub(/^.*:/, "", r); ranges[k] = r } }
102
105
  /^\[Survived\]/ { pend = $0; kind = "s"; next }
103
106
  /^\[NoCoverage\]/ { pend = $0; kind = "n"; next }
104
107
  pend != "" {
105
108
  file = $0; sub(/:[0-9]+:[0-9]+$/, "", file)
106
- if (kind == "s" || index(globbed, "," file ",") == 0) print pend " " $0
109
+ line = $0; sub(/:[0-9]+$/, "", line); sub(/^.*:/, "", line)
110
+ if (!(file in ranges) || changed(ranges[file], line + 0)) print pend " " $0
107
111
  pend = ""
108
112
  }
109
113
  # A survivor with no location under it is still a survivor, never dropped.
@@ -120,8 +124,23 @@ stryker_zero_tests() { # stryker_zero_tests <output> <label> <seconds>
120
124
  echo " On Vitest 5 this is stryker-js#6210: pin vitest@4 until a fixed runner ships."
121
125
  }
122
126
 
127
+ # Read by header name, so a reordered column cannot shift the numbers.
128
+ stryker_counts() { # stryker_counts <output> <label>
129
+ printf '%s\n' "$1" | awk -F'|' -v label="$2" '
130
+ /# killed/ { for (i = 1; i <= NF; i++) { h = $i; gsub(/^ +| +$/, "", h); col[h] = i } next }
131
+ /^ *All files/ && col["# killed"] {
132
+ for (k in col) { v = $col[k]; gsub(/ /, "", v); n[k] = v + 0 }
133
+ t = n["# killed"] + n["# survived"] + n["# timeout"] + n["# no cov"] + n["# errors"]
134
+ printf " %s stryker — %d mutants: %d killed, %d survived, %d uncovered, %d timed out, %d errors\n",
135
+ label, t, n["# killed"], n["# survived"], n["# no cov"], n["# timeout"], n["# errors"]
136
+ exit
137
+ }'
138
+ }
139
+
123
140
  # Sets ALL from a Stryker run. Fails when a broken run leaves nothing provable.
124
141
  stryker_judge() { # uses OUT GLOBBED FCTX RC owner label T from ship-gate.sh
142
+ echo " $label stryker — mutated: $(while IFS= read -r f; do printf '%s ' "${f#"$owner"/}"; done <<< "$OWNED")"
143
+ stryker_counts "$OUT" "$label"
125
144
  ALL=$(stryker_findings "$OUT" "$GLOBBED")
126
145
  if stryker_zero_tests "$OUT" "$label" "$T"; then
127
146
  ALL=$(grep -v '^\[Survived\]' <<< "$ALL"); [ -n "$ALL" ]; return
@@ -129,3 +148,38 @@ stryker_judge() { # uses OUT GLOBBED FCTX RC owner label T from ship-gate.sh
129
148
  [ -n "$FCTX" ] && [ "$RC" -eq 0 ] && files_record "$FCTX" "$owner" "$ALL"
130
149
  return 0
131
150
  }
151
+
152
+ # Files that decide whether Stryker can run the suite at all; none of them is code it mutates.
153
+ SETUP_RE='(^|/)(stryker\.conf[^/]*|stryker\.config\.[^/]*|vitest\.(config|workspace)\.[^/]*|vite\.config\.[^/]*|jest\.config\.[^/]*|tsconfig[^/]*\.json|package(-lock)?\.json|pnpm-(lock|workspace)\.yaml|yarn\.lock|bun\.lockb?|\.npmrc)$|(^|/)patches/'
154
+
155
+ # A version bump touches package.json on every release and proves nothing about Stryker.
156
+ setup_files() {
157
+ printf '%s\n' "$CHANGED" | grep -E "$SETUP_RE" | while IFS= read -r f; do
158
+ [ -n "$({ git diff "$BASE" -- "$f"; git ls-files --error-unmatch -- "$f" >/dev/null 2>&1 || sed 's/^/+/' "$f"; } 2>/dev/null \
159
+ | grep -E '^[-+]' | grep -vE '^(\+\+\+|---) ' | grep -v '"version"')" ] && printf '%s\n' "$f"
160
+ done
161
+ }
162
+
163
+ # A PR can break Stryker (a dropped patch, a test config) with no code to mutate, so a trial
164
+ # runs the suite under it. A project with code to mutate skips it: its real run starts the same way.
165
+ setup_check() {
166
+ [ -z "$GAUNTLET_MUTATE" ] || return 0
167
+ local o b bin label key out rc t0 mutated
168
+ mutated=$(printf '%s\n' "$FILES" | grep -vE "$GAUNTLET_NO_MUTATE" | while IFS= read -r f; do [ -n "$f" ] && owner_of "$f"; done)
169
+ for o in $(setup_files | while IFS= read -r f; do owner_of "$f"; done | sort -u); do
170
+ grep -qxF "$o" <<< "$mutated" && continue
171
+ b=""; [ "$o" != . ] && b="$o/"; [ "$o" = . ] && label="<repo root>" || label="$o/"
172
+ grep -qs '@stryker-mutator/core' "${b}package.json" || continue
173
+ key="/tmp/claude-shipgate-project-$(project_key "$o" dryrun)"
174
+ [ -f "$key" ] && { echo " $label stryker setup — ok, not re-run: its trial run passed"; continue; }
175
+ bin=$(stryker_bin "$b"); t0=$SECONDS; out=$(cd "${b:-.}" && $bin run --dryRunOnly --concurrency "$(gate_workers)" 2>&1); rc=$?
176
+ if [ $rc -eq 0 ]; then echo " $label stryker setup ($((SECONDS - t0))s) — ok, its trial run passed"; touch "$key"
177
+ elif grep -qE 'initial test run|DryRunExecutor' <<< "$out"; then
178
+ echo " $label stryker setup ($((SECONDS - t0))s) — this change breaks Stryker's own test run:"
179
+ printf '%s\n' "$out" | tail -12 | sed 's/^/ /'; STATUS=1
180
+ else
181
+ echo " $label stryker setup ($((SECONDS - t0))s) — UNPROVEN, the trial run could not start:"
182
+ printf '%s\n' "$out" | tail -6 | sed 's/^/ /'; stryker_elsewhere stryker "$b"; [ "$STATUS" = 0 ] && STATUS=2
183
+ fi
184
+ done
185
+ }
@@ -42,6 +42,21 @@ case "$CMD" in
42
42
  *) exit 0 ;;
43
43
  esac
44
44
 
45
+ json_escape() {
46
+ local s="$1"
47
+ s="${s//\\/\\\\}"
48
+ s="${s//\"/\\\"}"
49
+ s="${s//$'\n'/\\n}"
50
+ s="${s//$'\r'/\\r}"
51
+ s="${s//$'\t'/\\t}"
52
+ printf '"%s"' "$s"
53
+ }
54
+
55
+ deny() {
56
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$(json_escape "$1")"
57
+ exit 0
58
+ }
59
+
45
60
  GATE="$PROJECT_DIR/.claude/hooks/ship-gate.sh"
46
61
  [ -x "$GATE" ] || exit 0
47
62
  git rev-parse --git-dir >/dev/null 2>&1 || exit 0
@@ -84,6 +99,18 @@ BRANCH=$(pr_branch)
84
99
  KEY=$(CLAUDE_PROJECT_DIR="$TARGET" bash "$GATE" --key 2>/dev/null)
85
100
  [ -z "$KEY" ] && exit 0
86
101
 
102
+ # A gate run by hand skips /ship's audit and review, so the PR waits for /ship itself.
103
+ # A merge whose branch gh cannot name is left to the receipt alone.
104
+ MARKER="$PROJECT_DIR/.claude/hooks/ship-mark.sh"
105
+ case "$CMD" in *"gh pr create"*) MARK_BRANCH=${BRANCH:-$(git branch --show-current)} ;; *) MARK_BRANCH=$BRANCH ;; esac
106
+ if [ -f "$MARKER" ] && [ -n "$MARK_BRANCH" ] && ! CLAUDE_PROJECT_DIR="$TARGET" bash "$MARKER" check "$MARK_BRANCH"; then
107
+ deny "BLOCKED: /ship has not run on branch $MARK_BRANCH.
108
+
109
+ Load the /ship command with the Skill tool and follow it from the start. A gate run
110
+ by hand is not enough: /ship also runs the skills audit and the code review.
111
+ The gate's earlier pass is reused, so only those two steps cost time."
112
+ fi
113
+
87
114
  RECEIPT="/tmp/claude-shipgate-$KEY"
88
115
  if [ -f "$RECEIPT" ]; then
89
116
  exit 0
@@ -111,15 +138,4 @@ To publish without the gate, say so out loud and run:
111
138
 
112
139
  bash .claude/hooks/ship-gate.sh --force"
113
140
 
114
- json_escape() {
115
- local s="$1"
116
- s="${s//\\/\\\\}"
117
- s="${s//\"/\\\"}"
118
- s="${s//$'\n'/\\n}"
119
- s="${s//$'\r'/\\r}"
120
- s="${s//$'\t'/\\t}"
121
- printf '"%s"' "$s"
122
- }
123
-
124
- printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$(json_escape "$MSG")"
125
- exit 0
141
+ deny "$MSG"
@@ -92,7 +92,7 @@ changed_files() {
92
92
  # Ignored extensions are left out; see the comment where ship-gate.sh first calls this.
93
93
  receipt_key() {
94
94
  { printf '%s\n' "$PROJECT_DIR"
95
- changed_files | grep -viE "\.($GAUNTLET_IGNORE_EXT)$" | while IFS= read -r f; do
95
+ { changed_files | grep -viE "\.($GAUNTLET_IGNORE_EXT)$"; setup_files; } | LC_ALL=C sort -u | while IFS= read -r f; do
96
96
  [ -n "$f" ] || continue
97
97
  printf '%s\n' "$f"
98
98
  [ -f "$f" ] && cat "$f"
@@ -1,6 +1,4 @@
1
1
  #!/usr/bin/env bash
2
- # Sourced by ship-gate.sh: a new file must sit in the folder named for its kind.
3
- # Only ADDED files are judged, so a repo laid out the old way cannot get worse and need not move.
4
2
 
5
3
  STRUCTURE_JS_KINDS="components|hooks|api|queries|schemas|types|utils|stores|test"
6
4
  STRUCTURE_KIND_FILE='(^|[._])(router|routes|controller|service|schema|model)s?\.[cm]?[jt]s$|(Router|Routes|Controller|Service|Schema|Model)s?\.[cm]?[jt]s$'
@@ -69,6 +67,7 @@ py_misplaced() { # py_misplaced <path>
69
67
  fi
70
68
  }
71
69
 
70
+ # Only ADDED files are judged, so a repo laid out the old way cannot get worse and need not move.
72
71
  structure_check() {
73
72
  bad=""; count=0
74
73
  while IFS= read -r f; do
@@ -115,13 +114,44 @@ gate_report() {
115
114
  # A FAIL goes to the diary /add-rule reads, so a test gap the gate keeps finding can become a rule.
116
115
  gate_exit() {
117
116
  echo "ship-gate: full output in $REPORT"
118
- [ "$1" = 1 ] || return 0
117
+ [ "$1" = 1 ] || [ "$GAUNTLET_SHIP_LOG" = 1 ] || return 0
119
118
  exec 1>&3 2>&4
120
119
  # A child left running (a dev server) can hold tee open forever; give it 2s, not a hang.
121
120
  for _ in 1 2 3 4 5 6 7 8 9 10; do [ -n "$TEE_PID" ] && kill -0 "$TEE_PID" 2>/dev/null || break; sleep 0.2; done
121
+ [ "$1" = 1 ] && gate_diary
122
+ ship_log "$REPORT" "$1"
123
+ return 0
124
+ }
125
+
126
+ gate_diary() {
122
127
  local diary; diary=$(diary_dir "$HERE")
123
128
  mkdir -p "$diary"
124
129
  printf '{"ts":"%s","branch":"%s","status":"FAIL","report":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
125
130
  "$(git branch --show-current 2>/dev/null | json_string)" "$(json_string < "$REPORT")" \
126
131
  >> "$diary/gate-findings.jsonl"
127
132
  }
133
+
134
+ # Every run goes to the /ship log when it is on; a broken log never changes the verdict.
135
+ ship_log() { # ship_log <report> <exit code>
136
+ [ "$GAUNTLET_SHIP_LOG" = 1 ] && command -v node >/dev/null || return 0
137
+ SHIP_LOG_TEST_RE="$TEST_RE" SHIP_LOG_SOURCE_EXT="$GAUNTLET_SOURCE_EXT" node "$HERE/ship-log.mjs" gate "$1" "$2" "$SECONDS" "$BASE" >/dev/null 2>&1 || true
138
+ }
139
+
140
+ gate_force() {
141
+ printf 'FORCED %s\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
142
+ echo "ship-gate: FORCED — receipt written without running any check."
143
+ local r; r=$(mktemp); echo "ship-gate: FORCED" > "$r"
144
+ ship_log "$r" 0; rm -f "$r"
145
+ exit 0
146
+ }
147
+
148
+ # Nothing to mutate, but a Stryker trial run may still have been unable to start.
149
+ no_code_exit() {
150
+ echo "ship-gate: no changed code files — nothing to check"
151
+ if [ "$STATUS" = 2 ]; then
152
+ printf 'ship-gate: UNPROVEN in %ss — nothing is wrong, but nothing was proven either\n' "$SECONDS"
153
+ printf 'UNPROVEN %s setup-trial\n' "$(date -u +%FT%TZ)" > "$RECEIPT"; exit 2
154
+ fi
155
+ printf 'PASS %s no-code-changes\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
156
+ exit 0
157
+ }
@@ -100,11 +100,7 @@ CHANGED=$(changed_files)
100
100
  RECEIPT_KEY=$(receipt_key); RECEIPT="/tmp/claude-shipgate-$RECEIPT_KEY"
101
101
 
102
102
  if [ "$MODE" = key ]; then printf '%s\n' "$RECEIPT_KEY"; exit 0; fi
103
- if [ "$MODE" = force ]; then
104
- printf 'FORCED %s\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
105
- echo "ship-gate: FORCED — receipt written without running any check."
106
- exit 0
107
- fi
103
+ [ "$MODE" = force ] && gate_force
108
104
  gate_report "$RECEIPT_KEY"
109
105
  SOURCED=$(printf '%s\n' "$CHANGED" | grep -E "\.($GAUNTLET_SOURCE_EXT)$" \
110
106
  | grep -vE "$TEST_RE|(^|/)\.stryker-tmp/" | while read -r f; do
@@ -134,7 +130,7 @@ if [ -n "$IGNORED" ]; then
134
130
  fi
135
131
 
136
132
  [ -n "$FILES" ] && echo "ship-gate: $(printf '%s\n' "$FILES" | wc -l) changed code file(s), base $(git rev-parse --short "$BASE")"
137
- STATUS=0; structure_check || { STATUS=1; [ -z "$FILES" ] && structure_fail; }
133
+ STATUS=0; structure_check || STATUS=1; [ "$STATUS" = 1 ] || setup_check; [ "$STATUS" = 1 ] && [ -z "$FILES" ] && structure_fail
138
134
  if [ -z "$FILES" ]; then
139
135
  # "I recognised nothing" is not "there is nothing", and the gate used to report
140
136
  # both as a PASS. A Godot repo changed only .gd files, which no extension in
@@ -154,9 +150,7 @@ if [ -z "$FILES" ]; then
154
150
  printf 'UNPROVEN %s unknown-extensions\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
155
151
  exit 2
156
152
  fi
157
- echo "ship-gate: no changed code files — nothing to check"
158
- printf 'PASS %s no-code-changes\n' "$(date -u +%FT%TZ)" > "$RECEIPT"
159
- exit 0
153
+ no_code_exit
160
154
  fi
161
155
 
162
156
  # ------------------------------------------------- check 1: file length (hard)
@@ -224,7 +218,7 @@ for owner in $OWNERS; do
224
218
  # superset of the diff, slower than a range but never less than what changed.
225
219
  case "$rel" in
226
220
  *[][*?{}]*) FLAGS="$FLAGS,$(printf '%s' "$rel" | sed 's/[][*?{}]/?/g')"
227
- GLOBBED="$GLOBBED$rel," ;;
221
+ GLOBBED="$GLOBBED$rel:$(echo $hunks)," ;;
228
222
  *) for r in $hunks; do FLAGS="$FLAGS,$rel:$r"; done ;;
229
223
  esac
230
224
  done <<< "$OWNED"
@@ -0,0 +1,102 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ const PROJECT = /^ {2}(\S.*?) (stryker|mutmut|config)(?: \((\d+)s\))? — (.*)$/;
5
+ const SURVIVOR = /^ {6}\[(Survived|NoCoverage)\] (\S+)\s+(\S+)$/;
6
+ const MUTMUT = /^ {6}(\S+__mutmut_\d+)$/;
7
+ const COUNTS = /^(\d+) mutants: (\d+) killed, (\d+) survived, (\d+) uncovered, (\d+) timed out, (\d+) errors$/;
8
+ const VERDICT = /^ship-gate: (PASS|FAIL|UNPROVEN|FORCED)(?: in (\d+)s)?/;
9
+
10
+ function projectFor(run, label, tool) {
11
+ let p = run.projects.find((x) => x.label === label && x.tool === tool);
12
+ if (!p) {
13
+ p = { label, tool, seconds: null, result: null, reused: [], mutated: [], counts: null, survivors: [] };
14
+ run.projects.push(p);
15
+ }
16
+ return p;
17
+ }
18
+
19
+ function applyProject(p, seconds, text) {
20
+ if (seconds) p.seconds = Number(seconds);
21
+ const counts = text.match(COUNTS);
22
+ if (counts) {
23
+ const [, total, killed, survived, uncovered, timedOut, errors] = counts.map(Number);
24
+ p.counts = { total, killed, survived, uncovered, timedOut, errors };
25
+ } else if (text.startsWith("mutated:")) p.mutated = text.slice(8).trim().split(/\s+/);
26
+ else if (text.startsWith("ok, not re-run: passed and unchanged since:")) p.reused = text.split(":").pop().trim().split(/\s+/);
27
+ else if (text.startsWith("ok, not re-run")) p.result = "cached";
28
+ else if (text.startsWith("ok,")) p.result = "ok";
29
+ else if (text.startsWith("these lines can break")) p.result = "survivors";
30
+ else if (text.startsWith("the run failed")) p.result = "failed";
31
+ else if (text.startsWith("UNPROVEN")) p.result = "unproven";
32
+ }
33
+
34
+ function readSection(run, line, state) {
35
+ if (line.startsWith("STRUCTURE — ")) { run.structure = line.slice(12); state.list = line.includes("outside") ? run.misplaced : null; return; }
36
+ if (line.startsWith("FILE LENGTH — ")) { run.length = line.slice(14); state.list = line.includes("over the limit") ? run.overLimit : null; return; }
37
+ if (line.startsWith("ship-gate: not gated")) { state.list = run.ignored; return; }
38
+ const scope = line.match(/^ship-gate:\s+(\d+) changed code file\(s\)/);
39
+ if (scope) run.scope = Number(scope[1]);
40
+ const verdict = line.match(VERDICT);
41
+ if (verdict) run.verdict = verdict[1];
42
+ if (line.startsWith("ship-gate: no changed code files")) run.verdict = "PASS";
43
+ if (!/^ {2}\S/.test(line) || line.includes("Move them") || line.includes("Splitting a file")) state.list = null;
44
+ }
45
+
46
+ function readLine(run, line, state) {
47
+ const project = line.match(PROJECT);
48
+ if (project) {
49
+ state.project = projectFor(run, project[1], project[2]);
50
+ applyProject(state.project, project[3], project[4]);
51
+ state.list = null;
52
+ return;
53
+ }
54
+ const survivor = line.match(SURVIVOR) ?? line.match(MUTMUT);
55
+ if (survivor && state.project) {
56
+ state.project.survivors.push(survivor.length > 2 ? { kind: survivor[1], mutator: survivor[2], at: survivor[3] } : { kind: "Survived", at: survivor[1] });
57
+ return;
58
+ }
59
+ if (state.list && /^ {2}\S/.test(line)) { state.list.push(line.trim()); return; }
60
+ readSection(run, line, state);
61
+ }
62
+
63
+ export function parseReport(text) {
64
+ const run = { verdict: null, scope: 0, ignored: [], structure: null, misplaced: [], length: null, overLimit: [], projects: [] };
65
+ const state = { project: null, list: null };
66
+ for (const line of text.split("\n")) readLine(run, line, state);
67
+ // The verdict is the last line the gate prints; a run killed before it never earned one.
68
+ run.verdict ??= "INTERRUPTED";
69
+ return run;
70
+ }
71
+
72
+ export function git(args) {
73
+ const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 1 << 26 });
74
+ return r.status === 0 ? r.stdout.trim() : "";
75
+ }
76
+
77
+ function kindOf(path, testRe, sourceExt) {
78
+ if (testRe && new RegExp(testRe).test(path)) return "tests";
79
+ return sourceExt && new RegExp(`\\.(${sourceExt})$`).test(path) ? "code" : "other";
80
+ }
81
+
82
+ function untracked() {
83
+ return git(["ls-files", "--others", "--exclude-standard"]).split("\n").filter(Boolean).map((f) => {
84
+ let text = "";
85
+ try { text = readFileSync(f, "utf8"); } catch { /* unreadable: count the file, not its lines */ }
86
+ return [text ? text.split("\n").length - (text.endsWith("\n") ? 1 : 0) : 0, 0, f];
87
+ });
88
+ }
89
+
90
+ // What the PR will hold: the fork point against the working tree, plus files not yet added.
91
+ export function prSize(base, testRe, sourceExt) {
92
+ const fork = git(["merge-base", base, "HEAD"]).trim() || base;
93
+ const rows = git(["diff", "--numstat", "--no-renames", fork]).split("\n").filter(Boolean).map((l) => l.split("\t"));
94
+ const size = { files: 0, added: 0, removed: 0, code: { files: 0, added: 0, removed: 0 }, tests: { files: 0, added: 0, removed: 0 }, other: { files: 0, added: 0, removed: 0 } };
95
+ for (const [a, r, f] of [...rows, ...untracked()]) {
96
+ if (/(^|\/)\.claude\/|(^|\/)\.(line-limit|lint|mutmut)-baseline$/.test(f)) continue;
97
+ const add = Number(a) || 0, rem = Number(r) || 0, bucket = size[kindOf(f, testRe, sourceExt)];
98
+ size.files++; size.added += add; size.removed += rem;
99
+ bucket.files++; bucket.added += add; bucket.removed += rem;
100
+ }
101
+ return size;
102
+ }
@@ -0,0 +1,48 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ function records(dir) {
5
+ const read = (d) => (existsSync(d) ? readdirSync(d).filter((f) => f.endsWith(".json")).map((f) => join(d, f)) : []);
6
+ return [...read(dir), ...read(join(dir, "open"))].flatMap((f) => {
7
+ try {
8
+ const r = JSON.parse(readFileSync(f, "utf8"));
9
+ return r?.v === 1 && Array.isArray(r.gate) && Array.isArray(r.findings) ? [r] : [];
10
+ } catch { return []; }
11
+ });
12
+ }
13
+
14
+ export function tally(findings) {
15
+ const by = {};
16
+ for (const f of findings) {
17
+ const s = (by[f.by.split(":")[0]] ??= { found: 0, confirmed: 0, refuted: 0, unconfirmed: 0, fixed: 0 });
18
+ s.found++; s[f.verdict]++; if (f.fixed) s.fixed++;
19
+ }
20
+ return by;
21
+ }
22
+
23
+ function findingCell(by = {}) {
24
+ const parts = Object.entries(by).map(([src, s]) => `${src} ${s.found}/${s.confirmed}/${s.fixed}`);
25
+ return parts.join(" · ") || "-";
26
+ }
27
+
28
+ function row(r) {
29
+ const s = r.summary ?? { minutes: null, gateRuns: r.gate.length, gateSeconds: r.gate.reduce((n, g) => n + (g.seconds ?? 0), 0), findings: tally(r.findings) };
30
+ const size = r.size ? `${r.size.files} +${r.size.added} -${r.size.removed}` : "-";
31
+ return [r.app, (r.finished ?? r.started).slice(0, 10), r.pr ? `#${r.pr}` : r.branch, r.status, s.minutes ?? "-", s.gateRuns, `${s.gateSeconds}s`, size, findingCell(s.findings)];
32
+ }
33
+
34
+ function table(rows) {
35
+ const head = ["app", "date", "pr", "status", "min", "gate runs", "gate time", "files +/-", "findings found/confirmed/fixed"];
36
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
37
+ for (const r of [head, ...rows]) console.log(r.map((c, i) => String(c).padEnd(widths[i])).join(" ").trimEnd());
38
+ }
39
+
40
+ export function printReport(dir) {
41
+ const all = records(dir).sort((a, b) => (a.finished ?? a.started).localeCompare(b.finished ?? b.started));
42
+ if (all.length === 0) {
43
+ console.log(`ship-log: no logs in ${dir}. Turn logging on with GAUNTLET_SHIP_LOG=1 in ~/.claude/gauntlet.conf.`);
44
+ return 0;
45
+ }
46
+ table(all.map(row));
47
+ return 0;
48
+ }
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { git, parseReport, prSize } from "./ship-log-parse.mjs";
6
+ import { printReport, tally } from "./ship-log-report.mjs";
7
+
8
+ const HERE = dirname(resolve(process.argv[1]));
9
+ const VERDICTS = ["confirmed", "refuted", "unconfirmed"];
10
+ const now = () => new Date().toISOString().replace(/\.\d+Z$/, "Z");
11
+
12
+ // gate-config.sh owns the config files and their order, so the gate and this agree on "on".
13
+ function conf() {
14
+ const root = git(["rev-parse", "--show-toplevel"]) || process.cwd();
15
+ const script = 'cd "$2" && . "$1/gate-config.sh" || exit 1; load_gauntlet_conf .claude/gauntlet.conf "$1/../gauntlet.conf"; printf "\\nSHIP_LOG_ON=%s\\nSHIP_LOG_DIR=%s\\n" "$GAUNTLET_SHIP_LOG" "$GAUNTLET_SHIP_LOG_DIR"';
16
+ const out = spawnSync("bash", ["-c", script, "_", HERE, root], { encoding: "utf8" }).stdout;
17
+ const value = (key) => out.split("\n").findLast((l) => l.startsWith(`${key}=`))?.slice(key.length + 1) ?? "";
18
+ return { on: value("SHIP_LOG_ON") === "1", dir: value("SHIP_LOG_DIR") || join(process.env.HOME ?? "", ".claude", "ship-logs"), root };
19
+ }
20
+
21
+ function identity(root) {
22
+ const origin = git(["remote", "get-url", "origin"]);
23
+ const app = basename(origin || root).replace(/\.git$/, "");
24
+ return { app, branch: git(["branch", "--show-current"]) || "detached" };
25
+ }
26
+
27
+ function openPath(dir, { app, branch }) {
28
+ return join(dir, "open", `${app}__${branch.replace(/[^\w.-]+/g, "-")}.json`);
29
+ }
30
+
31
+ function load(path, id) {
32
+ if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
33
+ return { v: 1, ...id, started: now(), status: "open", pr: null, base: null, size: null, gate: [], steps: {}, findings: [] };
34
+ }
35
+
36
+ function save(path, record) {
37
+ mkdirSync(dirname(path), { recursive: true });
38
+ record.updated = now();
39
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`);
40
+ }
41
+
42
+ function summarize(record) {
43
+ const by = tally(record.findings);
44
+ const minutes = (Date.parse(record.finished) - Date.parse(record.started)) / 60000;
45
+ return { minutes: Math.round(minutes * 10) / 10, gateRuns: record.gate.length, gateSeconds: record.gate.reduce((n, r) => n + (r.seconds ?? 0), 0), findings: by };
46
+ }
47
+
48
+ function finalize(dir, path, record, tag, status, reason) {
49
+ Object.assign(record, { status, reason: reason ?? null, finished: now() });
50
+ record.summary = summarize(record);
51
+ const stem = `${record.app}_${record.finished.slice(0, 10)}_${tag}`;
52
+ let out = join(dir, `${stem}.json`);
53
+ for (let i = 2; existsSync(out); i++) out = join(dir, `${stem}-${i}.json`);
54
+ save(path, record);
55
+ renameSync(path, out);
56
+ console.log(`ship-log: saved ${out}`);
57
+ }
58
+
59
+ // A ship that never reached pr or end was abandoned; keep it, but never fold it into the next one.
60
+ function restart(dir, path, record, id) {
61
+ if (existsSync(path)) finalize(dir, path, record, id.branch.replace(/[^\w.-]+/g, "-"), "abandoned");
62
+ save(path, load(path, id));
63
+ }
64
+
65
+ function addGateRun(record, [report, exit, seconds, base]) {
66
+ const text = readFileSync(report, "utf8");
67
+ record.gate.push({ ts: now(), exit: Number(exit), seconds: Number(seconds), ...parseReport(text), report: text });
68
+ if (base) { record.base = base; record.size = prSize(base, process.env.SHIP_LOG_TEST_RE ?? "", process.env.SHIP_LOG_SOURCE_EXT ?? ""); }
69
+ }
70
+
71
+ function addFinding(record, json) {
72
+ const f = JSON.parse(json ?? "");
73
+ if (typeof f.by !== "string" || typeof f.what !== "string" || !VERDICTS.includes(f.verdict) || typeof f.fixed !== "boolean") {
74
+ throw new Error(`a finding needs by, what, verdict (${VERDICTS.join("|")}) and fixed (true|false)`);
75
+ }
76
+ record.findings.push({ ts: now(), ...f });
77
+ }
78
+
79
+ function lastEvent(record, skip) {
80
+ const ends = Object.entries(record.steps).filter(([k]) => k !== skip).map(([, s]) => s.end);
81
+ return [record.started, ...record.gate.map((g) => g.ts), ...ends].filter(Boolean).sort().at(-1);
82
+ }
83
+
84
+ function addStep(record, [name, edge]) {
85
+ if (!name || !["start", "end"].includes(edge)) throw new Error("usage: step <name> start|end");
86
+ const step = (record.steps[name] ??= {});
87
+ step[edge] = now();
88
+ if (edge === "end" && !step.start) step.start = lastEvent(record, name);
89
+ if (step.start && step.end) step.seconds = Math.round((Date.parse(step.end) - Date.parse(step.start)) / 1000);
90
+ }
91
+
92
+ function run(mode, args) {
93
+ const c = conf();
94
+ if (mode === "report") return printReport(args[0] ?? c.dir);
95
+ if (mode === "on") return c.on ? 0 : 1;
96
+ if (!c.on) return 0;
97
+ const id = identity(c.root), path = openPath(c.dir, id), record = load(path, id);
98
+ if (mode === "start") return restart(c.dir, path, record, id), 0;
99
+ // A gate run by hand or on a release is not a ship; only /ship's start opens one.
100
+ if (mode === "gate" && !existsSync(path)) return 0;
101
+ if (mode === "gate") addGateRun(record, args);
102
+ else if (mode === "finding") addFinding(record, args[0]);
103
+ else if (mode === "step") addStep(record, args);
104
+ else if (mode === "pr" && /^\d+$/.test(args[0] ?? "")) {
105
+ record.pr = Number(args[0]);
106
+ return finalize(c.dir, path, record, `pr${args[0]}`, "pr-opened"), 0;
107
+ }
108
+ else if (mode === "end") return finalize(c.dir, path, record, id.branch.replace(/[^\w.-]+/g, "-"), "stopped", args.join(" ") || null), 0;
109
+ else throw new Error("usage: ship-log.mjs on | start | gate <report> <exit> <seconds> <base> | step <name> start|end | finding '<json>' | pr <number> | end [reason] | report [dir]");
110
+ save(path, record);
111
+ return 0;
112
+ }
113
+
114
+ try {
115
+ process.exit(run(process.argv[2], process.argv.slice(3)) ?? 0);
116
+ } catch (err) {
117
+ console.error(`ship-log: ${err.message}`);
118
+ process.exit(1);
119
+ }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env bash
2
+ cd "${CLAUDE_PROJECT_DIR:-$PWD}" 2>/dev/null || exit 1
3
+ # The shared .git dir, so a worktree's mark is found from the main folder.
4
+ COMMON=$(cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd) || exit 1
5
+ BRANCH=${2:-$(git branch --show-current 2>/dev/null)}
6
+ [ -n "$BRANCH" ] || exit 1
7
+ MARK="/tmp/claude-ship-$(printf '%s %s' "$COMMON" "$BRANCH" | cksum | cut -d' ' -f1)"
8
+
9
+ # A mark lasts 12 hours, so a branch reused later (dev, for a release) needs /ship again.
10
+ case "${1:-}" in
11
+ begin) touch "$MARK" ;;
12
+ check) [ -n "$(find "$MARK" -mmin -720 2>/dev/null)" ] ;;
13
+ *) echo "usage: ship-mark.sh begin | check [branch]" >&2; exit 2 ;;
14
+ esac
@@ -26,7 +26,7 @@ The hooks and skills in \`.claude/\` come from \`@spardutti/claude-skills\`. Fac
26
26
  - **Your own skill:** write \`.claude/skills/<name>/SKILL.md\` with \`name\` and \`description\`, then add \`<name>\` to \`"custom"\` in \`.claude/.claude-skills.json\`. Never add it to \`"skills"\`: a sync deletes anything there that is not in the catalog.
27
27
  - **Make it mandatory on some files:** put \`gate-paths: "src/**/*.tsx"\` under \`metadata:\`, quoted. A bare top-level \`paths:\` makes Claude Code drop the skill. Add \`tracks: react@19\` to fire only when the project depends on that package.
28
28
  - **Hook settings** live in \`.claude/gauntlet.conf\` (shell syntax): \`GAUNTLET_TEST\`, \`GAUNTLET_TYPECHECK\`, \`GAUNTLET_BASE\` (the trunk branch, if not develop/dev/main), \`GAUNTLET_MAX_LINES\`, \`GAUNTLET_IGNORE_FILES\`, \`GAUNTLET_OFF=1\`.
29
- - **Ship gate:** \`bash .claude/hooks/ship-gate.sh\` before a PR. Never run it with \`--force\` or \`--baseline\` unless the user asks.
29
+ - **Shipping:** open, merge or release a PR only through the \`/ship\` command; load it every time, even when you remember the steps. Never run \`ship-gate.sh\` with \`--force\` or \`--baseline\` unless the user asks.
30
30
  - **Never edit \`.claude/hooks/\`** — a sync overwrites it. Update with \`npx @spardutti/claude-skills --sync\`.`;
31
31
 
32
32
  const BLOCKS = [
@@ -21,10 +21,13 @@ import { homedir } from "node:os";
21
21
  // gate-config.sh sourced by ship-gate.sh — shared helpers
22
22
  // ship-gate-projects.sh sourced by ship-gate.sh — project detection, reuse
23
23
  // ship-gate-hook.sh PreToolUse on Bash — no commit without a receipt
24
+ // ship-mark.sh written by /ship — no PR on a branch /ship never ran on
24
25
  // version-check.sh SessionStart — a line when a newer catalog exists
25
26
  // skill-flags-record.sh SubagentStop — logs each skills-audit verdict
26
27
  // skill-report.mjs read by /skill-report — tallies that log
27
28
  // diary-check.mjs run by /ship — says when /add-rule has repeats to look at
29
+ // ship-log.mjs the /ship log, when GAUNTLET_SHIP_LOG=1 — every gate run
30
+ // and finding, one file per ship, in one folder
28
31
  // edit-check.sh PostToolUse — line-limit.sh, lint-ratchet.mjs and
29
32
  // text-rules.mjs on each edited file
30
33
 
@@ -48,6 +51,10 @@ const TEXT_RULES_FILENAME = "text-rules.mjs";
48
51
  const RATCHET_BASELINE_FILENAME = "ratchet-baseline.mjs";
49
52
  const DIARY_CHECK_FILENAME = "diary-check.mjs";
50
53
  const DIARY_LIB_FILENAME = "diary-lib.mjs";
54
+ const SHIP_LOG_FILENAME = "ship-log.mjs";
55
+ const SHIP_LOG_PARSE_FILENAME = "ship-log-parse.mjs";
56
+ const SHIP_LOG_REPORT_FILENAME = "ship-log-report.mjs";
57
+ const SHIP_MARK_FILENAME = "ship-mark.sh";
51
58
  const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
52
59
  const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
53
60
 
@@ -152,6 +159,10 @@ export async function setupHook(targetDir = process.cwd()) {
152
159
  RATCHET_BASELINE_FILENAME,
153
160
  DIARY_CHECK_FILENAME,
154
161
  DIARY_LIB_FILENAME,
162
+ SHIP_LOG_FILENAME,
163
+ SHIP_LOG_PARSE_FILENAME,
164
+ SHIP_LOG_REPORT_FILENAME,
165
+ SHIP_MARK_FILENAME,
155
166
  ]) {
156
167
  const dest = join(hooksDir, name);
157
168
  await writeFile(dest, await readHookSource(name), { mode: 0o755 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.53.1",
3
+ "version": "2.54.1",
4
4
  "description": "Guardrails for Claude Code — best-practice skills, planning commands, and delivery gates.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -11,7 +11,7 @@
11
11
  "claude-skills": "bin/cli.mjs"
12
12
  },
13
13
  "scripts": {
14
- "prepack": "cp ../README.md README.md && mkdir -p hooks && cp ../scripts/skill-gate.sh ../scripts/skill-gate-automark.sh ../scripts/skill-application-gate.sh ../scripts/gauntlet.sh ../scripts/ship-gate.sh ../scripts/gate-config.sh ../scripts/ship-gate-projects.sh ../scripts/ship-gate-structure.sh ../scripts/ship-gate-files.sh ../scripts/ship-gate-hook.sh ../scripts/version-check.sh ../scripts/skill-flags-record.sh ../scripts/skill-report.mjs ../scripts/edit-check.sh ../scripts/line-limit.sh ../scripts/lint-ratchet.mjs ../scripts/text-rules.mjs ../scripts/ratchet-baseline.mjs ../scripts/diary-check.mjs ../scripts/diary-lib.mjs hooks/",
14
+ "prepack": "cp ../README.md README.md && mkdir -p hooks && cp ../scripts/skill-gate.sh ../scripts/skill-gate-automark.sh ../scripts/skill-application-gate.sh ../scripts/gauntlet.sh ../scripts/ship-gate.sh ../scripts/gate-config.sh ../scripts/ship-gate-projects.sh ../scripts/ship-gate-structure.sh ../scripts/ship-gate-files.sh ../scripts/ship-gate-hook.sh ../scripts/version-check.sh ../scripts/skill-flags-record.sh ../scripts/skill-report.mjs ../scripts/edit-check.sh ../scripts/line-limit.sh ../scripts/lint-ratchet.mjs ../scripts/text-rules.mjs ../scripts/ratchet-baseline.mjs ../scripts/diary-check.mjs ../scripts/diary-lib.mjs ../scripts/ship-log.mjs ../scripts/ship-log-parse.mjs ../scripts/ship-log-report.mjs ../scripts/ship-mark.sh hooks/",
15
15
  "postpack": "git checkout -- README.md && rm -rf hooks"
16
16
  },
17
17
  "engines": {