@spardutti/claude-skills 2.45.0 → 2.47.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 +2 -0
- package/hooks/ship-gate-files.sh +112 -0
- package/hooks/ship-gate-projects.sh +13 -1
- package/hooks/ship-gate.sh +7 -29
- package/hooks/skill-flags-record.sh +25 -0
- package/hooks/skill-report.mjs +61 -0
- package/lib/hook-settings.mjs +11 -0
- package/lib/setup-hook.mjs +9 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -137,6 +137,7 @@ Portable slash commands installed to `.claude/commands/`. Some orchestrate paral
|
|
|
137
137
|
| `/optimize` | Make one measured job faster without changing what it proves — fixes a benchmark and a result that must not move, sends 4 read-only subagents to find suspects, then changes one thing per round with a prediction written first, keeping only what beats the noise. Profiles after every wrong prediction, reruns before judging a result that flipped, and ends by writing the cause as a skill rule for you |
|
|
138
138
|
| `/handoff` | Write a handoff doc so a fresh session picks the work up — captures what auto-compaction loses: decisions made and rejected, dead ends already ruled out, why the tree is dirty, and the one next step |
|
|
139
139
|
| `/lockdown` | Per-repo supply-chain hardening — detects the package managers, Dockerfiles, and CI in use, then guides you through and applies install-time, deploy, and pipeline hardening for npm/pnpm, pip/uv, Docker, and GitHub Actions |
|
|
140
|
+
| `/skill-report` | Which installed skills earn their place in this repo — every `/ship` skills audit is logged by a hook, and this tallies it: how often each skill applied, how often it caught a violation, and the rule it catches most. Names rules that break often enough to become a script check, and skills that never flag and may be dead weight |
|
|
140
141
|
|
|
141
142
|
## How It Works
|
|
142
143
|
|
|
@@ -193,6 +194,7 @@ scripts/ validate-skills.mjs — checks skill length caps and reference in
|
|
|
193
194
|
ship-gate.sh — /ship's file-length, folder-structure and mutation checks, behind an exit code
|
|
194
195
|
ship-gate-hook.sh — refuses gh pr create/merge without a ship-gate receipt
|
|
195
196
|
version-check.sh — SessionStart nudge when a newer catalog is published
|
|
197
|
+
skill-flags-record.sh — logs each /ship skills-audit verdict; skill-report.mjs tallies it
|
|
196
198
|
gauntlet-selftest.sh — behavioural tests for the hooks (runs on pre-push)
|
|
197
199
|
gauntlet-survey.sh — what the hook would do in every repo under a directory
|
|
198
200
|
preflight.sh — everything that must pass before a release
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
|
|
4
|
+
TEST_RE='(\.|_)(test|spec)\.[^.]+$|(^|/)tests?/'
|
|
5
|
+
|
|
6
|
+
stryker_owner() { # stryker_owner <owner> <owned files>
|
|
7
|
+
b=""; [ "$1" != "." ] && b="$1/"
|
|
8
|
+
[ -z "$GAUNTLET_MUTATE" ] && ! grep -q '\.py$' <<< "$2" \
|
|
9
|
+
&& grep -qs '@stryker-mutator/core' "${b}package.json"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
changed_tests() { # changed_tests <family>
|
|
13
|
+
printf '%s\n' "$CHANGED" | grep -E "$TEST_RE" | while IFS= read -r t; do
|
|
14
|
+
[ -n "$t" ] && [ "$(family_of "$t")" = "$1" ] && printf '%s\n' "$t"
|
|
15
|
+
done
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# Everything a file's verdict rests on besides the file itself and the tests.
|
|
19
|
+
# Tests are left out: adding to them can only kill more, so only a lost line counts.
|
|
20
|
+
files_context() { # files_context <owner> <owned files>
|
|
21
|
+
fam=$(family_of "$1/.")
|
|
22
|
+
{ printf '%s\n' "$PROJECT_DIR" "$1" "$(git rev-parse "$BASE" 2>/dev/null)"
|
|
23
|
+
cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh" "$HERE/ship-gate-files.sh"
|
|
24
|
+
printf '%s\n' "$CHANGED" | grep -vE '\.(md|mdx|txt|rst|adoc)$' | grep -vE "$TEST_RE" \
|
|
25
|
+
| grep -vxF -f <(printf '%s\n' "$2") | while IFS= read -r f; do
|
|
26
|
+
[ -n "$f" ] || continue
|
|
27
|
+
case "$(family_of "$f")" in "$fam"|any) printf '%s\n' "$f"; [ -f "$f" ] && cat "$f" ;; esac
|
|
28
|
+
done; } | git hash-object --stdin
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
file_receipt() { # file_receipt <context> <file>
|
|
32
|
+
printf '/tmp/claude-shipgate-file-%s\n' "$({ printf '%s\n' "$1" "$2"; cat "$2"; } | git hash-object --stdin)"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test_then() { # test_then <snapshot> <test>: its content when the snapshot was taken
|
|
36
|
+
if grep -qxF "$2" "$1/manifest"; then cat "$1/files/$2" 2>/dev/null
|
|
37
|
+
else git show "$BASE:$2" 2>/dev/null; fi
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Never `| grep -q` under pipefail: diff exits 1 on any difference, and an early
|
|
41
|
+
# grep exit fails the writer, so a match reads as none.
|
|
42
|
+
tests_lost_lines() { # tests_lost_lines <snapshot> <family>
|
|
43
|
+
[ -f "$1/manifest" ] || return 0
|
|
44
|
+
while IFS= read -r t; do
|
|
45
|
+
[ -n "$t" ] || continue
|
|
46
|
+
case "$(diff <(test_then "$1" "$t") <(cat "$t" 2>/dev/null))" in *$'\n<'*) return 0 ;; esac
|
|
47
|
+
done <<< "$({ cat "$1/manifest"; changed_tests "$2"; } | sort -u)"
|
|
48
|
+
return 1
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Sets OWNED to the files that still need mutating.
|
|
52
|
+
files_split() { # files_split <context> <owner> <label>
|
|
53
|
+
fam=$(family_of "$2/."); kept=""; rest=""
|
|
54
|
+
while IFS= read -r f; do
|
|
55
|
+
[ -n "$f" ] || continue
|
|
56
|
+
r=$(file_receipt "$1" "$f")
|
|
57
|
+
if [ -f "$r" ] && ! tests_lost_lines "$(cat "$r")" "$fam"; then kept="$kept ${f#"$2"/}"
|
|
58
|
+
else rest="$rest$f"$'\n'; fi
|
|
59
|
+
done <<< "$OWNED"
|
|
60
|
+
OWNED=${rest%$'\n'}
|
|
61
|
+
[ -n "$kept" ] || return 0
|
|
62
|
+
echo " $3 stryker — ok, not re-run: passed and unchanged since:$kept"
|
|
63
|
+
echo " A change in a file they call is not re-checked."
|
|
64
|
+
b=""; [ "$2" != "." ] && b="$2/"
|
|
65
|
+
[ -z "$OWNED" ] && stryker_page_tests "$b" "$3"
|
|
66
|
+
return 0
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# A finding with no location cannot be pinned on one file, so it keeps none.
|
|
70
|
+
files_record() { # files_record <context> <owner> <findings>
|
|
71
|
+
[ -n "$(printf '%s\n' "$3" | grep . | grep -vE ':[0-9]+:[0-9]+$')" ] && return 0
|
|
72
|
+
fam=$(family_of "$2/.")
|
|
73
|
+
snap="/tmp/claude-shipgate-tests-$({ printf '%s\n' "$1"; changed_tests "$fam" \
|
|
74
|
+
| while IFS= read -r t; do printf '%s\n' "$t"; cat "$t" 2>/dev/null; done; } | git hash-object --stdin)"
|
|
75
|
+
mkdir -p "$snap/files"; changed_tests "$fam" > "$snap/manifest"
|
|
76
|
+
while IFS= read -r t; do
|
|
77
|
+
[ -f "$t" ] && mkdir -p "$snap/files/$(dirname "$t")" && cp "$t" "$snap/files/$t"
|
|
78
|
+
done < "$snap/manifest"
|
|
79
|
+
while IFS= read -r f; do
|
|
80
|
+
[ -n "$f" ] || continue
|
|
81
|
+
grep -qF " ${f#"$2"/}:" <<< "$3" && continue
|
|
82
|
+
printf '%s\n' "$snap" > "$(file_receipt "$1" "$f")"
|
|
83
|
+
done <<< "$OWNED"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Match a finding, never a summary row. Stryker prints a `# survived` COLUMN
|
|
87
|
+
# HEADER every run and repeats the word in its table, so a bare grep reports
|
|
88
|
+
# survivors on a clean run — worse than not running at all. Stryker marks each
|
|
89
|
+
# real finding `[Survived]`; table and border lines are excluded outright.
|
|
90
|
+
# NoCoverage is printed exactly like Survived and means something worse: not
|
|
91
|
+
# "a test missed this mutation" but "no test executes this line at all". The
|
|
92
|
+
# 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.
|
|
97
|
+
#
|
|
98
|
+
# The status line carries no file name; the line under it does, as
|
|
99
|
+
# file:line:column. They are paired here rather than grepped separately.
|
|
100
|
+
stryker_findings() { # stryker_findings <output> <globbed paths, comma-wrapped>
|
|
101
|
+
printf '%s\n' "$1" | awk -v globbed="$2" '
|
|
102
|
+
/^\[Survived\]/ { pend = $0; kind = "s"; next }
|
|
103
|
+
/^\[NoCoverage\]/ { pend = $0; kind = "n"; next }
|
|
104
|
+
pend != "" {
|
|
105
|
+
file = $0; sub(/:[0-9]+:[0-9]+$/, "", file)
|
|
106
|
+
if (kind == "s" || index(globbed, "," file ",") == 0) print pend " " $0
|
|
107
|
+
pend = ""
|
|
108
|
+
}
|
|
109
|
+
# A survivor with no location under it is still a survivor, never dropped.
|
|
110
|
+
END { if (pend != "" && kind == "s") print pend }
|
|
111
|
+
'
|
|
112
|
+
}
|
|
@@ -25,6 +25,18 @@ py_mutmut() {
|
|
|
25
25
|
command -v mutmut >/dev/null 2>&1 && printf 'mutmut\n'
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
# Node that lives only in Docker leaves no Stryker on the host. An executable
|
|
29
|
+
# .stryker-run runs it there instead, taking the arguments stryker would.
|
|
30
|
+
stryker_bin() { if [ -x "$1.stryker-run" ]; then echo ./.stryker-run; else echo npx --no-install stryker; fi; }
|
|
31
|
+
stryker_elsewhere() { # stryker_elsewhere <tool> <base>: after a failed run, say where Stryker could run
|
|
32
|
+
[ "$1" = stryker ] && [ ! -x "$2.stryker-run" ] || return 0
|
|
33
|
+
d=$(cd "${2:-.}" && pwd)
|
|
34
|
+
while [ "$d" != / ]; do [ -d "$d/node_modules/@stryker-mutator/core" ] && return 0; d=$(dirname "$d"); done
|
|
35
|
+
echo " Stryker is not installed on this machine. If Node runs only in Docker, add an"
|
|
36
|
+
echo " executable $2.stryker-run that runs it there and passes on its arguments:"
|
|
37
|
+
echo " docker compose run --rm -T <service> nice -n 19 npx stryker \"\$@\""
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
owner_of() {
|
|
29
41
|
d=$(dirname "$1")
|
|
30
42
|
while :; do
|
|
@@ -127,7 +139,7 @@ project_key() { # project_key <owner> <tool>
|
|
|
127
139
|
fam=$(family_of "$1/.")
|
|
128
140
|
{ printf '%s\n' "$PROJECT_DIR" "$1" "$2" "$(git rev-parse "$BASE" 2>/dev/null)" \
|
|
129
141
|
"$GAUNTLET_NO_MUTATE" "$GAUNTLET_IGNORE_FILES"
|
|
130
|
-
cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh"
|
|
142
|
+
cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh" "$HERE/ship-gate-files.sh"
|
|
131
143
|
printf '%s\n' "$CHANGED" | grep -vE '\.(md|mdx|txt|rst|adoc)$' | while IFS= read -r f; do
|
|
132
144
|
[ -n "$f" ] || continue
|
|
133
145
|
case "$(family_of "$f")" in "$fam"|any) printf '%s\n' "$f"; [ -f "$f" ] && cat "$f" ;; esac
|
package/hooks/ship-gate.sh
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
set -uo pipefail
|
|
47
47
|
|
|
48
48
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
49
|
-
. "$HERE/ship-gate-projects.sh" && . "$HERE/ship-gate-structure.sh" || { echo "ship-gate: cannot read its helpers in $HERE"; exit 1; }
|
|
49
|
+
. "$HERE/ship-gate-projects.sh" && . "$HERE/ship-gate-structure.sh" && . "$HERE/ship-gate-files.sh" || { echo "ship-gate: cannot read its helpers in $HERE"; exit 1; }
|
|
50
50
|
|
|
51
51
|
MODE=""
|
|
52
52
|
case "${1:-}" in
|
|
@@ -122,7 +122,7 @@ if [ "$MODE" = force ]; then
|
|
|
122
122
|
fi
|
|
123
123
|
gate_report "$RECEIPT_KEY"
|
|
124
124
|
SOURCED=$(printf '%s\n' "$CHANGED" | grep -E "\.($GAUNTLET_SOURCE_EXT)$" \
|
|
125
|
-
| grep -vE
|
|
125
|
+
| grep -vE "$TEST_RE|(^|/)\.stryker-tmp/" | while read -r f; do
|
|
126
126
|
[ -f "$f" ] && printf '%s\n' "$f"
|
|
127
127
|
done)
|
|
128
128
|
|
|
@@ -223,6 +223,7 @@ for owner in $OWNERS; do
|
|
|
223
223
|
OWNED=$(while IFS= read -r f; do
|
|
224
224
|
[ "$(owner_of "$f")" = "$owner" ] && printf '%s\n' "$f"
|
|
225
225
|
done <<< "$FILES" | grep -vE "$GAUNTLET_NO_MUTATE" || true)
|
|
226
|
+
FCTX=""; if stryker_owner "$owner" "$OWNED"; then FCTX=$(files_context "$owner" "$OWNED"); files_split "$FCTX" "$owner" "$label"; fi
|
|
226
227
|
[ -z "$OWNED" ] && continue
|
|
227
228
|
|
|
228
229
|
# ONE comma-joined --mutate value, never a flag per range. Stryker's CLI
|
|
@@ -271,7 +272,7 @@ for owner in $OWNERS; do
|
|
|
271
272
|
# 8 of them on one commit, all long since killed. --mutate already scopes
|
|
272
273
|
# the run to the changed hunks, so incremental buys nothing here and costs
|
|
273
274
|
# a cache that goes stale exactly the way mutmut's did.
|
|
274
|
-
CMD="${RUN}
|
|
275
|
+
CMD="${RUN}$(stryker_bin "$base") run --concurrency $(gate_workers) --mutate '${FLAGS#,}'"; stryker_page_tests "$base" "$label"
|
|
275
276
|
elif [ -z "$PY" ] && [ -f "$base"package.json ]; then
|
|
276
277
|
MISSING="$MISSING $label needs Stryker:
|
|
277
278
|
npm --prefix ${owner} i -D @stryker-mutator/core @stryker-mutator/vitest-runner
|
|
@@ -324,32 +325,9 @@ for owner in $OWNERS; do
|
|
|
324
325
|
T0=$SECONDS; OUT=$(export FILES="$(printf '%s' "$OWNED" | tr '\n' ' ')" MUTATE_FLAGS="--mutate ${FLAGS#,}"; eval "$CMD" 2>&1); RC=$?; T=$((SECONDS - T0))
|
|
325
326
|
[ "$TOOL" = mutmut ] && [ $RC -eq 0 ] && { printf '%s\n' "$OUT" > "$PKEY.out"; cp "$MLOG" "$PKEY.log" 2>/dev/null; }
|
|
326
327
|
[ -n "$SCOPE_RE" ] && [ $RC -eq 0 ] && OUT=$(printf '%s\n' "$OUT" | grep -E "^[[:space:]]*($SCOPE_RE)\.x")
|
|
327
|
-
# Match a finding, never a summary row. Stryker prints a `# survived` COLUMN
|
|
328
|
-
# HEADER every run and repeats the word in its table, so a bare grep reports
|
|
329
|
-
# survivors on a clean run — worse than not running at all. Stryker marks each
|
|
330
|
-
# real finding `[Survived]`; table and border lines are excluded outright.
|
|
331
|
-
# NoCoverage is printed exactly like Survived and means something worse: not
|
|
332
|
-
# "a test missed this mutation" but "no test executes this line at all". The
|
|
333
|
-
# gate matched only [Survived], so a diff whose files have no tests came back
|
|
334
|
-
# PASS — 306 uncovered mutants reported as proven on one commit. It is judged
|
|
335
|
-
# only for files that went in WITH a range: a glob-shaped path is mutated
|
|
336
|
-
# whole, so its untouched lines would report uncovered forever, and a finding
|
|
337
|
-
# nobody can close is what teaches people to --force.
|
|
338
|
-
#
|
|
339
|
-
# The status line carries no file name; the line under it does, as
|
|
340
|
-
# file:line:column. They are paired here rather than grepped separately.
|
|
341
328
|
if [ "$TOOL" = stryker ]; then
|
|
342
|
-
ALL=$(
|
|
343
|
-
|
|
344
|
-
/^\[NoCoverage\]/ { pend = $0; kind = "n"; next }
|
|
345
|
-
pend != "" {
|
|
346
|
-
file = $0; sub(/:[0-9]+:[0-9]+$/, "", file)
|
|
347
|
-
if (kind == "s" || index(globbed, "," file ",") == 0) print pend " " $0
|
|
348
|
-
pend = ""
|
|
349
|
-
}
|
|
350
|
-
# A survivor with no location under it is still a survivor, never dropped.
|
|
351
|
-
END { if (pend != "" && kind == "s") print pend }
|
|
352
|
-
')
|
|
329
|
+
ALL=$(stryker_findings "$OUT" "$GLOBBED")
|
|
330
|
+
[ -n "$FCTX" ] && [ $RC -eq 0 ] && files_record "$FCTX" "$owner" "$ALL"
|
|
353
331
|
else
|
|
354
332
|
ALL=$(printf '%s\n' "$OUT" | grep -E '[Ss]urvived' \
|
|
355
333
|
| grep -vE '^[[:space:]]*[#│|+-]|# *surviv')
|
|
@@ -433,7 +411,7 @@ for owner in $OWNERS; do
|
|
|
433
411
|
STATUS=1
|
|
434
412
|
elif [ $RC -ne 0 ]; then
|
|
435
413
|
echo " $label $TOOL (${T}s) — the run failed:"
|
|
436
|
-
printf '%s\n' "$OUT" | tail -12 | sed 's/^/ /'
|
|
414
|
+
printf '%s\n' "$OUT" | tail -12 | sed 's/^/ /'; stryker_elsewhere "$TOOL" "$base"
|
|
437
415
|
[ "$STATUS" = 0 ] && STATUS=2
|
|
438
416
|
elif [ -n "$NOTE" ]; then
|
|
439
417
|
printf '%s\n' "$NOTE"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# SubagentStop: appends each gauntlet-skills verdict to .claude/skill-flags.jsonl.
|
|
3
|
+
|
|
4
|
+
INPUT=$(cat | tr -d '\n')
|
|
5
|
+
|
|
6
|
+
case "$INPUT" in
|
|
7
|
+
*'"agent_type":"gauntlet-skills"'*) ;;
|
|
8
|
+
*) exit 0 ;;
|
|
9
|
+
esac
|
|
10
|
+
|
|
11
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
12
|
+
PROJECT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
|
13
|
+
|
|
14
|
+
# Kept JSON-escaped as it arrived, so the line stays valid JSON without a parser.
|
|
15
|
+
REPLY=$(printf '%s' "$INPUT" | sed -nE 's/.*"last_assistant_message":"(([^"\\]|\\.)*)".*/\1/p')
|
|
16
|
+
[ -z "$REPLY" ] && exit 0
|
|
17
|
+
|
|
18
|
+
TRANSCRIPT=$(printf '%s' "$INPUT" | grep -o '"agent_transcript_path":"[^"]*"' | head -1 | sed 's/.*:"//; s/"$//')
|
|
19
|
+
MODEL=""
|
|
20
|
+
[ -f "$TRANSCRIPT" ] && MODEL=$(grep -o '"model":"[^"]*"' "$TRANSCRIPT" | tail -1 | sed 's/.*:"//; s/"$//')
|
|
21
|
+
|
|
22
|
+
printf '{"ts":"%s","model":"%s","reply":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$MODEL" "$REPLY" \
|
|
23
|
+
>> "$PROJECT_DIR/.claude/skill-flags.jsonl"
|
|
24
|
+
|
|
25
|
+
exit 0
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Prints, per skill, how often the /ship skills audit applied it and flagged a violation.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_LOG = fileURLToPath(new URL("../skill-flags.jsonl", import.meta.url));
|
|
7
|
+
|
|
8
|
+
function parseVerdict(line) {
|
|
9
|
+
try {
|
|
10
|
+
const { model, reply } = JSON.parse(line);
|
|
11
|
+
return { model, verdict: JSON.parse(reply.slice(reply.indexOf("{"), reply.lastIndexOf("}") + 1)) };
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function tally(lines) {
|
|
18
|
+
const skills = new Map();
|
|
19
|
+
const models = new Map();
|
|
20
|
+
let unreadable = 0;
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
const parsed = parseVerdict(line);
|
|
23
|
+
if (!parsed?.verdict?.skill) { unreadable++; continue; }
|
|
24
|
+
const { model, verdict } = parsed;
|
|
25
|
+
if (!(verdict.checked > 0)) continue;
|
|
26
|
+
const s = skills.get(verdict.skill) ?? { audits: 0, flagged: 0, rules: new Map() };
|
|
27
|
+
s.audits++;
|
|
28
|
+
const violations = Array.isArray(verdict.violations) ? verdict.violations : [];
|
|
29
|
+
if (violations.length) s.flagged++;
|
|
30
|
+
for (const v of violations) s.rules.set(v.rule, (s.rules.get(v.rule) ?? 0) + 1);
|
|
31
|
+
skills.set(verdict.skill, s);
|
|
32
|
+
if (model) models.set(model, (models.get(model) ?? 0) + 1);
|
|
33
|
+
}
|
|
34
|
+
return { skills, models, unreadable };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function topRule(rules) {
|
|
38
|
+
const [rule] = [...rules].sort((a, b) => b[1] - a[1])[0] ?? ["-"];
|
|
39
|
+
return rule.length > 60 ? rule.slice(0, 57) + "..." : rule;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function render({ skills, models, unreadable }) {
|
|
43
|
+
if (!skills.size) return "No skill audits recorded yet. Run /ship to record some.";
|
|
44
|
+
const rows = [...skills].sort((a, b) => b[1].flagged - a[1].flagged || a[0].localeCompare(b[0]));
|
|
45
|
+
const width = Math.max(5, ...rows.map(([name]) => name.length));
|
|
46
|
+
const out = [`${"skill".padEnd(width)} audits flagged top rule broken`];
|
|
47
|
+
for (const [name, s] of rows) {
|
|
48
|
+
out.push(`${name.padEnd(width)} ${String(s.audits).padStart(6)} ${String(s.flagged).padStart(7)} ${topRule(s.rules)}`);
|
|
49
|
+
}
|
|
50
|
+
if (models.size) out.push("", "models: " + [...models].map(([m, n]) => `${m} (${n})`).join(", "));
|
|
51
|
+
if (unreadable) out.push(`unreadable lines: ${unreadable}`);
|
|
52
|
+
return out.join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let text = "";
|
|
56
|
+
try {
|
|
57
|
+
text = readFileSync(process.argv[2] ?? DEFAULT_LOG, "utf-8");
|
|
58
|
+
} catch {
|
|
59
|
+
// no log yet
|
|
60
|
+
}
|
|
61
|
+
console.log(render(tally(text.split("\n").filter(Boolean))));
|
package/lib/hook-settings.mjs
CHANGED
|
@@ -11,6 +11,7 @@ const GAUNTLET_FILENAME = "gauntlet.sh";
|
|
|
11
11
|
const SHIP_GATE_FILENAME = "ship-gate.sh";
|
|
12
12
|
const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
|
|
13
13
|
const VERSION_CHECK_FILENAME = "version-check.sh";
|
|
14
|
+
const SKILL_FLAGS_FILENAME = "skill-flags-record.sh";
|
|
14
15
|
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
15
16
|
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
16
17
|
|
|
@@ -116,6 +117,16 @@ export async function registerHooks(settingsPath) {
|
|
|
116
117
|
settings.hooks.Stop = [gauntletEntry];
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
// No matcher: the script filters on agent_type itself.
|
|
121
|
+
const skillFlagsCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${SKILL_FLAGS_FILENAME}`;
|
|
122
|
+
const skillFlagsEntry = { hooks: [{ type: "command", command: skillFlagsCommand }] };
|
|
123
|
+
|
|
124
|
+
if (Array.isArray(settings.hooks.SubagentStop)) {
|
|
125
|
+
upsert(settings.hooks.SubagentStop, SKILL_FLAGS_FILENAME, skillFlagsEntry);
|
|
126
|
+
} else {
|
|
127
|
+
settings.hooks.SubagentStop = [skillFlagsEntry];
|
|
128
|
+
}
|
|
129
|
+
|
|
119
130
|
// Both gates clear by touching a marker under /tmp. The hook scripts let those
|
|
120
131
|
// commands through themselves, but auto mode's permission classifier does not:
|
|
121
132
|
// a touch whose only purpose is to unlock a gate is exactly what it refuses.
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -20,6 +20,8 @@ import { homedir } from "node:os";
|
|
|
20
20
|
// ship-gate-projects.sh sourced by ship-gate.sh — project detection, reuse
|
|
21
21
|
// ship-gate-hook.sh PreToolUse on Bash — no commit without a receipt
|
|
22
22
|
// version-check.sh SessionStart — a line when a newer catalog exists
|
|
23
|
+
// skill-flags-record.sh SubagentStop — logs each skills-audit verdict
|
|
24
|
+
// skill-report.mjs read by /skill-report — tallies that log
|
|
23
25
|
|
|
24
26
|
const GATE_FILENAME = "skill-gate.sh";
|
|
25
27
|
const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
|
|
@@ -28,8 +30,11 @@ const GAUNTLET_FILENAME = "gauntlet.sh";
|
|
|
28
30
|
const SHIP_GATE_FILENAME = "ship-gate.sh";
|
|
29
31
|
const SHIP_GATE_PROJECTS_FILENAME = "ship-gate-projects.sh";
|
|
30
32
|
const SHIP_GATE_STRUCTURE_FILENAME = "ship-gate-structure.sh";
|
|
33
|
+
const SHIP_GATE_FILES_FILENAME = "ship-gate-files.sh";
|
|
31
34
|
const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
|
|
32
35
|
const VERSION_CHECK_FILENAME = "version-check.sh";
|
|
36
|
+
const SKILL_FLAGS_FILENAME = "skill-flags-record.sh";
|
|
37
|
+
const SKILL_REPORT_FILENAME = "skill-report.mjs";
|
|
33
38
|
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
34
39
|
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
35
40
|
|
|
@@ -108,8 +113,11 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
108
113
|
SHIP_GATE_FILENAME,
|
|
109
114
|
SHIP_GATE_PROJECTS_FILENAME,
|
|
110
115
|
SHIP_GATE_STRUCTURE_FILENAME,
|
|
116
|
+
SHIP_GATE_FILES_FILENAME,
|
|
111
117
|
SHIP_GATE_HOOK_FILENAME,
|
|
112
118
|
VERSION_CHECK_FILENAME,
|
|
119
|
+
SKILL_FLAGS_FILENAME,
|
|
120
|
+
SKILL_REPORT_FILENAME,
|
|
113
121
|
]) {
|
|
114
122
|
const dest = join(hooksDir, name);
|
|
115
123
|
await writeFile(dest, await readHookSource(name), { mode: 0o755 });
|
|
@@ -131,5 +139,6 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
131
139
|
console.log(` Hook installed: .claude/hooks/${GAUNTLET_FILENAME}`);
|
|
132
140
|
console.log(` Installed: .claude/hooks/${SHIP_GATE_FILENAME} (used by /ship)`);
|
|
133
141
|
console.log(` Hook installed: .claude/hooks/${SHIP_GATE_HOOK_FILENAME}`);
|
|
142
|
+
console.log(` Hook installed: .claude/hooks/${SKILL_FLAGS_FILENAME}`);
|
|
134
143
|
console.log(` Settings updated: .claude/settings.json`);
|
|
135
144
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spardutti/claude-skills",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.47.0",
|
|
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/ship-gate-projects.sh ../scripts/ship-gate-structure.sh ../scripts/ship-gate-hook.sh ../scripts/version-check.sh 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/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 hooks/",
|
|
15
15
|
"postpack": "git checkout -- README.md && rm -rf hooks"
|
|
16
16
|
},
|
|
17
17
|
"engines": {
|