@spardutti/claude-skills 2.25.2 → 2.26.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.
Files changed (2) hide show
  1. package/lib/setup-hook.mjs +95 -4
  2. package/package.json +1 -1
@@ -30,10 +30,10 @@ if [ -z "$SESSION_ID" ]; then
30
30
  exit 0
31
31
  fi
32
32
 
33
+ # The session marker is checked at the very bottom now, not here. A skill the
34
+ # edited file's own stack demands is not something an already-cleared gate
35
+ # excuses, so the required-skill scan below runs first.
33
36
  MARKER="/tmp/claude-skill-gate-$SESSION_ID"
34
- if [ -f "$MARKER" ]; then
35
- exit 0
36
- fi
37
37
 
38
38
  # Write|Edit|MultiEdit is not the only way to change a file. A session that edits
39
39
  # through \`python3 - <<'PY'\` in Bash walked past this gate entirely — twelve
@@ -49,6 +49,9 @@ CODE_EXT='ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|sql
49
49
 
50
50
  TOOL=$(printf '%s' "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | sed 's/.*:"//; s/"$//')
51
51
 
52
+ # Every file this call would write, collected for the required-skill scan below.
53
+ TARGETS=""
54
+
52
55
  if [ "$TOOL" != "Bash" ]; then
53
56
  # The structured tools name their target outright.
54
57
  TARGET=$(printf '%s' "$INPUT" | grep -o '"file_path":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
@@ -58,6 +61,7 @@ if [ "$TOOL" != "Bash" ]; then
58
61
  */.claude/settings.json|*/.claude/settings.local.json) exit 0 ;;
59
62
  *.*) printf '%s' "$TARGET" | grep -qiE "\.($PROSE_EXT)$" && exit 0 ;;
60
63
  esac
64
+ TARGETS="$TARGET"
61
65
  fi
62
66
  if [ "$TOOL" = "Bash" ]; then
63
67
  CMD=$(printf '%s' "$INPUT" | grep -o '"command":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
@@ -86,6 +90,84 @@ if [ "$TOOL" = "Bash" ]; then
86
90
  *"<<"*|*python*" -c"*|*node*" -e"*|*perl*" -e"*|*ruby*" -e"*) WRITES=1 ;;
87
91
  esac
88
92
  [ -z "$WRITES" ] && exit 0
93
+ # A Bash write names its files in the command itself.
94
+ TARGETS=$(printf '%s' "$CMD" | grep -oiE "[A-Za-z0-9_./-]+\.($CODE_EXT)" 2>/dev/null)
95
+ fi
96
+
97
+ # --- mandatory skills -------------------------------------------------------
98
+ # The generic gate below asks the model to rate every skill ACTIVATE or SKIP,
99
+ # and hands it an all-SKIP escape. A session took that escape, touched the
100
+ # marker, and edited React files with none of the React rules loaded. It had
101
+ # followed the hook exactly: the hook let it decide.
102
+ #
103
+ # So a skill that declares BOTH paths: and tracks: stops getting a vote. If the
104
+ # edited file matches its paths AND this project depends on a package it
105
+ # tracks, that skill is mandatory and only Skill(<name>) clears it. Skills
106
+ # without both fields keep the old behaviour, so this tightens nothing that was
107
+ # not deliberately declared.
108
+ MISSING=""
109
+ if [ -n "$TARGETS" ]; then
110
+ MANIFESTS=$(find "$PROJECT_DIR" -maxdepth 4 \\( -name node_modules -o -name .git -o -name .venv -o -name dist \\) -prune -o \\( -name package.json -o -name pyproject.toml -o -name requirements.txt \\) -print 2>/dev/null)
111
+ fi
112
+ if [ -n "$TARGETS" ] && [ -n "$MANIFESTS" ]; then
113
+ for SKILL_FILE in $(find "$PROJECT_DIR" -path '*/.claude/skills/*/SKILL.md' 2>/dev/null); do
114
+ # paths: must be quoted in YAML — a scalar opening with * is an alias.
115
+ UNQUOTE='s/^["'"'"']//; s/["'"'"']$//'
116
+ SPATHS=$(sed -n 's/^paths:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
117
+ STRACKS=$(sed -n 's/^tracks:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
118
+ SNAME=$(sed -n 's/^name:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
119
+ [ -z "$SPATHS" ] && continue
120
+ [ -z "$STRACKS" ] && continue
121
+ [ -z "$SNAME" ] && continue
122
+
123
+ HIT=""
124
+ for G in $(printf '%s' "$SPATHS" | tr ',' ' '); do
125
+ # Claude Code writes these gitignore-style. A case glob has no **, but *
126
+ # already crosses / here, so dropping the **/ prefix is equivalent.
127
+ G=$(printf '%s' "$G" | sed 's!\\*\\*/!!g')
128
+ case "$G" in \\**|/*) ;; *) G="*$G" ;; esac
129
+ for T in $TARGETS; do
130
+ case "$T" in $G) HIT=1 ;; esac
131
+ done
132
+ done
133
+ [ -z "$HIT" ] && continue
134
+
135
+ # A .tsx file is not proof of React — it could be Astro, Solid or Preact.
136
+ # The packages the skill tracks are the proof, so the manifests decide.
137
+ PY=""
138
+ case "$STRACKS" in *pypi*) PY=1 ;; esac
139
+ DEP=""
140
+ for P in $(printf '%s' "$STRACKS" | tr ',' ' '); do
141
+ case "$P" in *@*) ;; *) continue ;; esac
142
+ PKG=$(printf '%s' "$P" | sed 's/@[^@]*$//')
143
+ [ -z "$PKG" ] && continue
144
+ for M in $MANIFESTS; do
145
+ if [ -n "$PY" ]; then
146
+ grep -qiE "(^|[^A-Za-z0-9_.-])$PKG([^A-Za-z0-9_.-]|$)" "$M" 2>/dev/null && DEP=1
147
+ else
148
+ grep -qs "\\"$PKG\\"" "$M" && DEP=1
149
+ fi
150
+ done
151
+ done
152
+ [ -z "$DEP" ] && continue
153
+
154
+ SAFE=$(printf '%s' "$SNAME" | tr -cd 'A-Za-z0-9_-')
155
+ [ -f "/tmp/claude-skill-loaded-$SESSION_ID-$SAFE" ] && continue
156
+ case " $MISSING " in *" $SNAME "*) ;; *) MISSING="$MISSING $SNAME" ;; esac
157
+ done
158
+ fi
159
+
160
+ if [ -n "$MISSING" ]; then
161
+ LIST=$(printf '%s' "$MISSING" | sed 's/^ //; s/ /, /g')
162
+ FIRST=$(printf '%s' "$MISSING" | awk '{print $1}')
163
+ cat <<EOF
164
+ {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"BLOCKED: this file's stack has mandatory skills that are not loaded: $LIST.\\n\\nThere is no SKIP for these. The file matches each skill's declared paths AND this project depends on a package that skill tracks, so they apply as a matter of fact, not judgement. Touching the gate marker will not clear them.\\n\\nCall Skill($FIRST) now — then every other name in the list — and retry the edit."}}
165
+ EOF
166
+ exit 0
167
+ fi
168
+
169
+ if [ -f "$MARKER" ]; then
170
+ exit 0
89
171
  fi
90
172
 
91
173
 
@@ -685,6 +767,15 @@ CHANGED=$( { git diff "$BASE"...HEAD --name-only 2>/dev/null
685
767
  git diff HEAD --name-only 2>/dev/null
686
768
  git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )
687
769
 
770
+ # Only the files this gate can actually read go into the key. It used to be every
771
+ # changed file, so a version bump in package.json or a line in a changelog threw
772
+ # away a receipt the gate would have re-earned unchanged — fifteen minutes of
773
+ # mutation testing to prove nothing about the file that moved, at the one moment
774
+ # a release is trying to ship. An ignored extension is one no check here opens,
775
+ # so it cannot change a verdict. Unknown extensions stay in, because UNPROVEN is
776
+ # a verdict about exactly those.
777
+ KEYED=$(printf '%s\\n' "$CHANGED" | grep -viE "\\.($GAUNTLET_IGNORE_EXT)$")
778
+
688
779
  # The repo path plus the names and contents of the changed files, in a fixed
689
780
  # order. The path is in there because the key is otherwise pure content: two
690
781
  # repos holding the same file would share a receipt, and one would be waved
@@ -693,7 +784,7 @@ CHANGED=$( { git diff "$BASE"...HEAD --name-only 2>/dev/null
693
784
  # the moment it was committed. This is identical either side of a commit, and
694
785
  # changes the instant any of those files is edited.
695
786
  RECEIPT_KEY=$( { printf '%s\\n' "$PROJECT_DIR"
696
- printf '%s\\n' "$CHANGED" | while IFS= read -r f; do
787
+ printf '%s\\n' "$KEYED" | while IFS= read -r f; do
697
788
  [ -n "$f" ] || continue
698
789
  printf '%s\\n' "$f"
699
790
  [ -f "$f" ] && cat "$f"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.25.2",
3
+ "version": "2.26.0",
4
4
  "description": "Guardrails for Claude Code — best-practice skills, planning commands, and delivery gates.",
5
5
  "type": "module",
6
6
  "repository": {