@slamb2k/mad-skills 2.0.7 → 2.0.8

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.
@@ -1,22 +1,19 @@
1
1
  #!/usr/bin/env bash
2
2
  # session-guard.sh — Claude Code SessionStart hook
3
- # Validates Git repo, CLAUDE.md existence/freshness, and checks for staleness.
3
+ # Validates Git repo, CLAUDE.md existence/freshness, Task List ID config,
4
+ # and checks for staleness.
4
5
  #
5
- # Install: Add to ~/.claude/settings.json or .claude/settings.json:
6
- # {
7
- # "hooks": {
8
- # "SessionStart": [
9
- # {
10
- # "hooks": [
11
- # {
12
- # "type": "command",
13
- # "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-guard.sh"
14
- # }
15
- # ]
16
- # }
17
- # ]
18
- # }
19
- # }
6
+ # All user-facing questions instruct Claude to use the AskUserQuestion tool.
7
+ #
8
+ # Install globally in ~/.claude/settings.json:
9
+ # "command": "\"$HOME\"/.claude/hooks/session-guard.sh"
10
+ #
11
+ # Or per-project in .claude/settings.json:
12
+ # "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-guard.sh"
13
+ #
14
+ # TIMING NOTE: SessionStart hook output is silently injected and only surfaces
15
+ # on the user's first prompt. Pair with session-guard-prompt.sh on
16
+ # UserPromptSubmit for immediate feedback (see companion hook).
20
17
 
21
18
  # NOTE: We intentionally avoid `set -e` here. Many commands (grep, git, find,
22
19
  # jq, stat) return non-zero for perfectly normal reasons (no matches, not a
@@ -31,11 +28,12 @@ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
31
28
  CLAUDE_MD="$PROJECT_DIR/CLAUDE.md"
32
29
  NOW=$(date +%s)
33
30
  STALENESS_THRESHOLD=3 # Accumulated score >= this triggers user prompt
31
+ PENDING_DIR="${TMPDIR:-/tmp}/claude-session-guard"
34
32
 
35
33
  # ---------------------------------------------------------------------------
36
34
  # State
37
35
  # ---------------------------------------------------------------------------
38
- EARLY_CONTEXT_PARTS=() # Git/CLAUDE.md warnings (emitted before staleness)
36
+ EARLY_CONTEXT_PARTS=()
39
37
  STALENESS_SIGNALS=()
40
38
  STALENESS_SCORE=0
41
39
 
@@ -51,6 +49,22 @@ file_mtime() {
51
49
  stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo "$NOW"
52
50
  }
53
51
 
52
+ # ---------------------------------------------------------------------------
53
+ # Dedup: prevent double-firing if configured at both global and project level
54
+ # ---------------------------------------------------------------------------
55
+ mkdir -p "$PENDING_DIR"
56
+ GUARD_KEY=$(echo "$PROJECT_DIR" | md5sum 2>/dev/null | cut -d' ' -f1 || echo "default")
57
+ LOCK_FILE="$PENDING_DIR/$GUARD_KEY.lock"
58
+
59
+ if [[ -f "$LOCK_FILE" ]]; then
60
+ LOCK_AGE=$(( NOW - $(file_mtime "$LOCK_FILE") ))
61
+ if (( LOCK_AGE < 5 )); then
62
+ jq -n '{}'
63
+ exit 0
64
+ fi
65
+ fi
66
+ touch "$LOCK_FILE"
67
+
54
68
  # ---------------------------------------------------------------------------
55
69
  # 0) Git repository check
56
70
  # ---------------------------------------------------------------------------
@@ -60,24 +74,17 @@ if command -v git &>/dev/null; then
60
74
  GIT_ROOT=$(git -C "$PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null) || true
61
75
 
62
76
  if [[ -z "$GIT_ROOT" ]]; then
63
- # ---- Not inside any git repository ----------------------------------
64
77
  EARLY_CONTEXT_PARTS+=("[SESSION GUARD] ⚠️ This directory is NOT tracked by Git.")
65
78
  EARLY_CONTEXT_PARTS+=("")
66
- EARLY_CONTEXT_PARTS+=("Please ask the user:")
67
- EARLY_CONTEXT_PARTS+=("")
68
- EARLY_CONTEXT_PARTS+=("\"This directory isn't inside a Git repository. Would you like me to:")
69
- EARLY_CONTEXT_PARTS+=("")
70
- EARLY_CONTEXT_PARTS+=("1. **Initialise Git**I'll run \`git init\` to start tracking this project")
71
- EARLY_CONTEXT_PARTS+=("2. **Skip**Continue without version control")
72
- EARLY_CONTEXT_PARTS+=("")
73
- EARLY_CONTEXT_PARTS+=("What would you prefer?\"")
74
- EARLY_CONTEXT_PARTS+=("")
75
- EARLY_CONTEXT_PARTS+=("If the user chooses to initialise, run \`git init\` in the project directory, then suggest creating a .gitignore if one doesn't exist.")
76
- EARLY_CONTEXT_PARTS+=("If the user chooses to skip, continue normally.")
79
+ EARLY_CONTEXT_PARTS+=("Use AskUserQuestion to prompt:")
80
+ EARLY_CONTEXT_PARTS+=(" Question: \"This directory isn't inside a Git repository. What would you like to do?\"")
81
+ EARLY_CONTEXT_PARTS+=(" Type: single_select")
82
+ EARLY_CONTEXT_PARTS+=(" Options:")
83
+ EARLY_CONTEXT_PARTS+=(" 1. \"Initialise Git\" — run \`git init\` and suggest creating .gitignore")
84
+ EARLY_CONTEXT_PARTS+=(" 2. \"Skip\"continue without version control")
77
85
  EARLY_CONTEXT_PARTS+=("")
78
86
 
79
87
  elif [[ "$GIT_ROOT" != "$PROJECT_DIR" ]]; then
80
- # ---- Git root is an ancestor folder ---------------------------------
81
88
  DEPTH=0
82
89
  CHECK_DIR="$PROJECT_DIR"
83
90
  while [[ "$CHECK_DIR" != "$GIT_ROOT" && "$CHECK_DIR" != "/" ]]; do
@@ -85,14 +92,11 @@ if command -v git &>/dev/null; then
85
92
  DEPTH=$((DEPTH + 1))
86
93
  done
87
94
 
88
- # Gather monorepo signals at the git root
89
95
  MONOREPO_SIGNALS=()
90
96
 
91
- # Workspace configs
92
97
  if [[ -f "$GIT_ROOT/package.json" ]] && command -v jq &>/dev/null; then
93
- if jq -e '.workspaces // empty' "$GIT_ROOT/package.json" &>/dev/null; then
98
+ jq -e '.workspaces // empty' "$GIT_ROOT/package.json" &>/dev/null && \
94
99
  MONOREPO_SIGNALS+=("package.json has 'workspaces' field")
95
- fi
96
100
  fi
97
101
  [[ -f "$GIT_ROOT/pnpm-workspace.yaml" ]] && MONOREPO_SIGNALS+=("pnpm-workspace.yaml exists")
98
102
  [[ -f "$GIT_ROOT/lerna.json" ]] && MONOREPO_SIGNALS+=("lerna.json exists")
@@ -100,98 +104,69 @@ if command -v git &>/dev/null; then
100
104
  [[ -f "$GIT_ROOT/turbo.json" ]] && MONOREPO_SIGNALS+=("turbo.json exists")
101
105
  [[ -f "$GIT_ROOT/rush.json" ]] && MONOREPO_SIGNALS+=("rush.json exists")
102
106
 
103
- # Common monorepo directory patterns
104
107
  for d in packages apps services libs modules projects; do
105
108
  [[ -d "$GIT_ROOT/$d" ]] && MONOREPO_SIGNALS+=("'$d/' directory exists at git root")
106
109
  done
107
110
 
108
- # Multiple package.json files (strong monorepo indicator)
109
111
  PKG_COUNT=$(find "$GIT_ROOT" -maxdepth 3 -name "package.json" -not -path "*/node_modules/*" 2>/dev/null | wc -l | tr -d ' ')
110
- (( PKG_COUNT > 2 )) && MONOREPO_SIGNALS+=("${PKG_COUNT} package.json files found within the repo")
112
+ (( PKG_COUNT > 2 )) && MONOREPO_SIGNALS+=("${PKG_COUNT} package.json files found")
111
113
 
112
- # Multiple CLAUDE.md files (intentional per-package setup)
113
114
  CLAUDE_MD_COUNT=$(find "$GIT_ROOT" -maxdepth 3 -name "CLAUDE.md" 2>/dev/null | wc -l | tr -d ' ')
114
- (( CLAUDE_MD_COUNT > 1 )) && MONOREPO_SIGNALS+=("${CLAUDE_MD_COUNT} CLAUDE.md files found (suggests per-package setup)")
115
+ (( CLAUDE_MD_COUNT > 1 )) && MONOREPO_SIGNALS+=("${CLAUDE_MD_COUNT} CLAUDE.md files found (per-package setup)")
115
116
 
116
117
  RELATIVE_PATH="${PROJECT_DIR#"$GIT_ROOT/"}"
117
118
 
118
119
  if (( ${#MONOREPO_SIGNALS[@]} >= 2 )); then
119
- # ---- Likely a legitimate monorepo ---------------------------------
120
- EARLY_CONTEXT_PARTS+=("[SESSION GUARD] ℹ️ Git root is ${DEPTH} level(s) above the current directory.")
120
+ EARLY_CONTEXT_PARTS+=("[SESSION GUARD] ℹ️ Git root is ${DEPTH} level(s) above CWD.")
121
121
  EARLY_CONTEXT_PARTS+=(" Git root: $GIT_ROOT")
122
122
  EARLY_CONTEXT_PARTS+=(" Working dir: $PROJECT_DIR")
123
+ EARLY_CONTEXT_PARTS+=(" Monorepo signals: ${MONOREPO_SIGNALS[*]}")
123
124
  EARLY_CONTEXT_PARTS+=("")
124
- EARLY_CONTEXT_PARTS+=("This appears to be a **monorepo** based on these signals:")
125
- for sig in "${MONOREPO_SIGNALS[@]}"; do
126
- EARLY_CONTEXT_PARTS+=(" $sig")
127
- done
128
- EARLY_CONTEXT_PARTS+=("")
129
- EARLY_CONTEXT_PARTS+=("Briefly let the user know:")
130
- EARLY_CONTEXT_PARTS+=("")
131
- EARLY_CONTEXT_PARTS+=("\"I notice the Git repository root is at \`$GIT_ROOT\`, which looks like a monorepo. I'm working in the \`${RELATIVE_PATH}\` package. Just confirming — is this the right context, or did you mean to open Claude Code at the repo root?\"")
132
- EARLY_CONTEXT_PARTS+=("")
133
- EARLY_CONTEXT_PARTS+=("This is a low-priority confirmation — don't block on it. Continue with the session regardless.")
125
+ EARLY_CONTEXT_PARTS+=("Use AskUserQuestion (low priority, don't block):")
126
+ EARLY_CONTEXT_PARTS+=(" Question: \"Git root is at \`$GIT_ROOT\` (monorepo). Working in \`${RELATIVE_PATH}\`. Correct context?\"")
127
+ EARLY_CONTEXT_PARTS+=(" Type: single_select")
128
+ EARLY_CONTEXT_PARTS+=(" Options:")
129
+ EARLY_CONTEXT_PARTS+=(" 1. \"Yes, correct package\"")
130
+ EARLY_CONTEXT_PARTS+=(" 2. \"No, switch to repo root\"")
134
131
  EARLY_CONTEXT_PARTS+=("")
135
132
 
136
133
  else
137
- # ---- Ancestor may be incorrectly initialised ----------------------
138
134
  GIT_ROOT_FILE_COUNT=$(find "$GIT_ROOT" -maxdepth 1 -not -name '.*' 2>/dev/null | wc -l | tr -d ' ')
139
135
 
140
- EARLY_CONTEXT_PARTS+=("[SESSION GUARD] ⚠️ Git root is ${DEPTH} level(s) above the current directory and does NOT look like a monorepo.")
141
- EARLY_CONTEXT_PARTS+=(" Git root: $GIT_ROOT")
136
+ EARLY_CONTEXT_PARTS+=("[SESSION GUARD] ⚠️ Git root is ${DEPTH} level(s) above CWD does NOT look like a monorepo.")
137
+ EARLY_CONTEXT_PARTS+=(" Git root: $GIT_ROOT (${GIT_ROOT_FILE_COUNT} files)")
142
138
  EARLY_CONTEXT_PARTS+=(" Working dir: $PROJECT_DIR")
143
- EARLY_CONTEXT_PARTS+=(" Files at git root: ~${GIT_ROOT_FILE_COUNT}")
144
- EARLY_CONTEXT_PARTS+=("")
145
-
146
139
  if (( ${#MONOREPO_SIGNALS[@]} > 0 )); then
147
- EARLY_CONTEXT_PARTS+=("Weak signals found (not enough for monorepo classification):")
148
- for sig in "${MONOREPO_SIGNALS[@]}"; do
149
- EARLY_CONTEXT_PARTS+=(" • $sig")
150
- done
151
- EARLY_CONTEXT_PARTS+=("")
140
+ EARLY_CONTEXT_PARTS+=(" Weak signals: ${MONOREPO_SIGNALS[*]}")
152
141
  fi
153
-
154
- EARLY_CONTEXT_PARTS+=("This may indicate that an ancestor directory was accidentally initialised with \`git init\`.")
155
- EARLY_CONTEXT_PARTS+=("")
156
- EARLY_CONTEXT_PARTS+=("Please ask the user:")
157
142
  EARLY_CONTEXT_PARTS+=("")
158
- EARLY_CONTEXT_PARTS+=("\"I notice the Git repository root is at \`$GIT_ROOT\`, which is ${DEPTH} level(s) above your current working directory. This doesn't look like a monorepo setup, so the Git repo at that level may have been created accidentally.")
159
- EARLY_CONTEXT_PARTS+=("")
160
- EARLY_CONTEXT_PARTS+=("A few options:")
161
- EARLY_CONTEXT_PARTS+=("1. **It's correct** — The repo root at \`$GIT_ROOT\` is intentional, carry on")
162
- EARLY_CONTEXT_PARTS+=("2. **Initialise here instead** I'll run \`git init\` in this directory to create a separate repo")
163
- EARLY_CONTEXT_PARTS+=("3. **Investigate** I'll look at the git root structure to help you decide")
164
- EARLY_CONTEXT_PARTS+=("")
165
- EARLY_CONTEXT_PARTS+=("What would you prefer?\"")
166
- EARLY_CONTEXT_PARTS+=("")
167
- EARLY_CONTEXT_PARTS+=("If 'correct': continue normally.")
168
- EARLY_CONTEXT_PARTS+=("If 'initialise here': run \`git init\` in the project directory. Warn the user that the ancestor .git will still exist and they may want to remove it later.")
169
- EARLY_CONTEXT_PARTS+=("If 'investigate': list the git root contents and recent commits to help the user understand what's tracked.")
143
+ EARLY_CONTEXT_PARTS+=("Use AskUserQuestion to resolve:")
144
+ EARLY_CONTEXT_PARTS+=(" Question: \"Git root is at \`$GIT_ROOT\` (${DEPTH} levels up), which doesn't look like a monorepo. May have been created accidentally.\"")
145
+ EARLY_CONTEXT_PARTS+=(" Type: single_select")
146
+ EARLY_CONTEXT_PARTS+=(" Options:")
147
+ EARLY_CONTEXT_PARTS+=(" 1. \"It's correct\"continue normally")
148
+ EARLY_CONTEXT_PARTS+=(" 2. \"Initialise here instead\" run \`git init\` here (warn ancestor .git still exists)")
149
+ EARLY_CONTEXT_PARTS+=(" 3. \"Investigate\" — list git root contents and recent commits")
170
150
  EARLY_CONTEXT_PARTS+=("")
171
151
  fi
172
152
  fi
173
- # else: GIT_ROOT == PROJECT_DIR — all good, no action needed
174
153
  fi
175
154
 
176
155
  # ---------------------------------------------------------------------------
177
156
  # 1) CLAUDE.md existence check
178
157
  # ---------------------------------------------------------------------------
179
158
  if [[ ! -f "$CLAUDE_MD" ]]; then
180
- EARLY_CONTEXT_PARTS+=("[SESSION GUARD] No CLAUDE.md was found in the project root.")
181
- EARLY_CONTEXT_PARTS+=("")
182
- EARLY_CONTEXT_PARTS+=("This directory has not been initialised for Claude Code. Please ask the user:")
183
- EARLY_CONTEXT_PARTS+=("")
184
- EARLY_CONTEXT_PARTS+=("\"I notice this directory doesn't have a CLAUDE.md file, so it isn't set up as a Claude Code project yet. Would you like me to:")
185
- EARLY_CONTEXT_PARTS+=("")
186
- EARLY_CONTEXT_PARTS+=("1. **Initialise it** — I'll run \`/init\` to scaffold a CLAUDE.md with project context")
187
- EARLY_CONTEXT_PARTS+=("2. **Skip for now** — Continue without one (you may lose project-specific context)")
159
+ EARLY_CONTEXT_PARTS+=("[SESSION GUARD] ⚠️ No CLAUDE.md found in project root.")
188
160
  EARLY_CONTEXT_PARTS+=("")
189
- EARLY_CONTEXT_PARTS+=("What would you prefer?\"")
161
+ EARLY_CONTEXT_PARTS+=("Use AskUserQuestion to prompt:")
162
+ EARLY_CONTEXT_PARTS+=(" Question: \"No CLAUDE.md found. Want me to set up this project for Claude Code?\"")
163
+ EARLY_CONTEXT_PARTS+=(" Type: single_select")
164
+ EARLY_CONTEXT_PARTS+=(" Options:")
165
+ EARLY_CONTEXT_PARTS+=(" 1. \"Initialise\" — run \`/init\` to scaffold CLAUDE.md")
166
+ EARLY_CONTEXT_PARTS+=(" 2. \"Skip\" — continue without one")
190
167
  EARLY_CONTEXT_PARTS+=("")
191
- EARLY_CONTEXT_PARTS+=("If the user chooses to initialise, run the /init slash command.")
192
- EARLY_CONTEXT_PARTS+=("If the user chooses to skip, continue normally.")
193
168
 
194
- # Emit everything collected (git warnings + CLAUDE.md missing) and exit
169
+ # Emit and write pending flag, then exit early
195
170
  CONTEXT=$(printf '%s\n' "${EARLY_CONTEXT_PARTS[@]}")
196
171
  jq -n --arg ctx "$CONTEXT" '{
197
172
  hookSpecificOutput: {
@@ -199,98 +174,126 @@ if [[ ! -f "$CLAUDE_MD" ]]; then
199
174
  additionalContext: $ctx
200
175
  }
201
176
  }'
177
+ PENDING_FILE="$PENDING_DIR/$GUARD_KEY.pending"
178
+ echo "$CONTEXT" > "$PENDING_FILE"
202
179
  exit 0
203
180
  fi
204
181
 
205
182
  # ---------------------------------------------------------------------------
206
- # 2) Staleness evaluation
183
+ # 2) Task List ID check
184
+ # ---------------------------------------------------------------------------
185
+ TASK_LIST_CONFIGURED=false
186
+ SETTINGS_FILE="$PROJECT_DIR/.claude/settings.json"
187
+
188
+ [[ -n "${CLAUDE_CODE_TASK_LIST_ID:-}" ]] && TASK_LIST_CONFIGURED=true
189
+
190
+ if [[ "$TASK_LIST_CONFIGURED" == false ]] && command -v jq &>/dev/null; then
191
+ for CFG_FILE in "$SETTINGS_FILE" "$HOME/.claude/settings.json"; do
192
+ if [[ -f "$CFG_FILE" ]]; then
193
+ TASK_ID=$(jq -r '.env.CLAUDE_CODE_TASK_LIST_ID // empty' "$CFG_FILE" 2>/dev/null) || true
194
+ if [[ -n "$TASK_ID" ]]; then
195
+ TASK_LIST_CONFIGURED=true
196
+ break
197
+ fi
198
+ fi
199
+ done
200
+ fi
201
+
202
+ if [[ "$TASK_LIST_CONFIGURED" == false && -n "$GIT_ROOT" ]]; then
203
+ COMMIT_COUNT=$(git -C "$PROJECT_DIR" rev-list --count HEAD 2>/dev/null) || COMMIT_COUNT=0
204
+ FILE_COUNT=$(git -C "$PROJECT_DIR" ls-files 2>/dev/null | wc -l | tr -d ' ') || FILE_COUNT=0
205
+
206
+ if (( COMMIT_COUNT > 20 || FILE_COUNT > 30 )); then
207
+ REPO_NAME=$(basename "$GIT_ROOT")
208
+ EARLY_CONTEXT_PARTS+=("[SESSION GUARD] ℹ️ No persistent Task List ID configured.")
209
+ EARLY_CONTEXT_PARTS+=(" Project: ${COMMIT_COUNT} commits, ${FILE_COUNT} tracked files.")
210
+ EARLY_CONTEXT_PARTS+=("")
211
+ EARLY_CONTEXT_PARTS+=("Use AskUserQuestion (low priority, don't block):")
212
+ EARLY_CONTEXT_PARTS+=(" Question: \"No persistent Task List ID configured. For a project this size, tasks won't survive across sessions. Add one?\"")
213
+ EARLY_CONTEXT_PARTS+=(" Type: single_select")
214
+ EARLY_CONTEXT_PARTS+=(" Options:")
215
+ EARLY_CONTEXT_PARTS+=(" 1. \"Yes\" — add {\"env\": {\"CLAUDE_CODE_TASK_LIST_ID\": \"${REPO_NAME}\"}} to .claude/settings.json")
216
+ EARLY_CONTEXT_PARTS+=(" 2. \"Skip\" — continue without persistent tasks")
217
+ EARLY_CONTEXT_PARTS+=("")
218
+ fi
219
+ fi
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # 3) Staleness evaluation
207
223
  # ---------------------------------------------------------------------------
208
224
  CLAUDE_MD_MTIME=$(file_mtime "$CLAUDE_MD")
209
225
  CLAUDE_MD_AGE_DAYS=$(( (NOW - CLAUDE_MD_MTIME) / 86400 ))
210
- CLAUDE_MD_CONTENT=$(cat "$CLAUDE_MD")
211
226
 
212
- # --- 3a) Age-based check ---------------------------------------------------
227
+ # --- Age-based --------------------------------------------------------------
213
228
  if (( CLAUDE_MD_AGE_DAYS > 14 )); then
214
- add_staleness "CLAUDE.md was last modified ${CLAUDE_MD_AGE_DAYS} days ago" 2
229
+ add_staleness "CLAUDE.md last modified ${CLAUDE_MD_AGE_DAYS} days ago" 2
215
230
  elif (( CLAUDE_MD_AGE_DAYS > 7 )); then
216
- add_staleness "CLAUDE.md was last modified ${CLAUDE_MD_AGE_DAYS} days ago" 1
231
+ add_staleness "CLAUDE.md last modified ${CLAUDE_MD_AGE_DAYS} days ago" 1
217
232
  fi
218
233
 
219
- # --- 3b) Directory structure drift ------------------------------------------
234
+ # --- Directory structure drift ----------------------------------------------
220
235
  if command -v tree &>/dev/null; then
221
236
  CURRENT_TREE=$(tree -L 2 -d -I 'node_modules|.git|__pycache__|.venv|venv|dist|build|.next|.nuxt|coverage|.claude' --noreport "$PROJECT_DIR" 2>/dev/null) || true
222
237
  if [[ -n "$CURRENT_TREE" ]]; then
223
- # Strip box-drawing characters, pipes, dashes, whitespace to get clean dir names
224
238
  TREE_DIRS=$(echo "$CURRENT_TREE" | tail -n +2 \
225
239
  | sed 's/[│├└─┬┤┼┐┘┌┏┗┓┛]//g; s/[|`]//g; s/--*//g' \
226
240
  | sed 's/^[[:space:]]*//' | { grep -v '^$' || true; } | sort -u)
227
241
  MISSING_DIRS=()
228
242
  while IFS= read -r dir; do
229
243
  [[ -z "$dir" ]] && continue
230
- if ! grep -qi "$dir" "$CLAUDE_MD" 2>/dev/null; then
231
- MISSING_DIRS+=("$dir")
232
- fi
244
+ grep -qi "$dir" "$CLAUDE_MD" 2>/dev/null || MISSING_DIRS+=("$dir")
233
245
  done <<< "$TREE_DIRS"
234
246
  if (( ${#MISSING_DIRS[@]} > 2 )); then
235
- add_staleness "Directories not mentioned in CLAUDE.md: ${MISSING_DIRS[*]}" 2
247
+ add_staleness "Directories not in CLAUDE.md: ${MISSING_DIRS[*]}" 2
236
248
  elif (( ${#MISSING_DIRS[@]} > 0 )); then
237
- add_staleness "Directories not mentioned in CLAUDE.md: ${MISSING_DIRS[*]}" 1
249
+ add_staleness "Directories not in CLAUDE.md: ${MISSING_DIRS[*]}" 1
238
250
  fi
239
251
  fi
240
252
  fi
241
253
 
242
- # --- 3c) package.json dependency drift --------------------------------------
254
+ # --- package.json drift -----------------------------------------------------
243
255
  if [[ -f "$PROJECT_DIR/package.json" ]]; then
244
256
  PKG_MTIME=$(file_mtime "$PROJECT_DIR/package.json")
245
257
 
246
- # Check if package.json is newer than CLAUDE.md
247
258
  if (( PKG_MTIME > CLAUDE_MD_MTIME )); then
248
259
  PKG_DELTA=$(( (PKG_MTIME - CLAUDE_MD_MTIME) / 86400 ))
249
- add_staleness "package.json was modified ${PKG_DELTA} day(s) after CLAUDE.md" 1
260
+ add_staleness "package.json modified ${PKG_DELTA} day(s) after CLAUDE.md" 1
250
261
  fi
251
262
 
252
- # Count dependencies vs what's documented
253
263
  if command -v jq &>/dev/null; then
254
264
  DEP_COUNT=$(jq '[(.dependencies // {} | length), (.devDependencies // {} | length)] | add' "$PROJECT_DIR/package.json" 2>/dev/null) || DEP_COUNT="0"
255
- # Look for numbers near "dependenc" in CLAUDE.md (e.g. "23 dependencies")
256
265
  DOCUMENTED_COUNT=$(grep -oiP '\d+\s*(dependencies|deps)' "$CLAUDE_MD" 2>/dev/null | head -1 | grep -oP '\d+') || true
257
266
  if [[ -n "$DOCUMENTED_COUNT" && -n "$DEP_COUNT" ]]; then
258
267
  DRIFT=$(( DEP_COUNT - DOCUMENTED_COUNT ))
259
268
  DRIFT_ABS=${DRIFT#-}
260
269
  if (( DRIFT_ABS > 5 )); then
261
- add_staleness "Dependency count drifted: CLAUDE.md says ~${DOCUMENTED_COUNT}, actual is ${DEP_COUNT} (Δ${DRIFT})" 2
270
+ add_staleness "Dep count drift: CLAUDE.md ~${DOCUMENTED_COUNT}, actual ${DEP_COUNT} (Δ${DRIFT})" 2
262
271
  elif (( DRIFT_ABS > 0 )); then
263
- add_staleness "Dependency count drifted slightly: CLAUDE.md says ~${DOCUMENTED_COUNT}, actual is ${DEP_COUNT}" 1
272
+ add_staleness "Dep count drift: CLAUDE.md ~${DOCUMENTED_COUNT}, actual ${DEP_COUNT}" 1
264
273
  fi
265
274
  fi
266
275
 
267
- # Check for key dependencies not documented
268
276
  KEY_DEPS=$(jq -r '(.dependencies // {}) | keys[]' "$PROJECT_DIR/package.json" 2>/dev/null) || true
269
- UNDOCUMENTED_KEY_DEPS=()
277
+ UNDOCUMENTED=()
270
278
  while IFS= read -r dep; do
271
279
  [[ -z "$dep" ]] && continue
272
- if ! grep -qi "$dep" "$CLAUDE_MD" 2>/dev/null; then
273
- UNDOCUMENTED_KEY_DEPS+=("$dep")
274
- fi
280
+ grep -qi "$dep" "$CLAUDE_MD" 2>/dev/null || UNDOCUMENTED+=("$dep")
275
281
  done <<< "$KEY_DEPS"
276
- if (( ${#UNDOCUMENTED_KEY_DEPS[@]} > 5 )); then
277
- SAMPLE="${UNDOCUMENTED_KEY_DEPS[*]:0:5}"
278
- add_staleness "Multiple production deps not in CLAUDE.md (${#UNDOCUMENTED_KEY_DEPS[@]} total, e.g. ${SAMPLE})" 2
282
+ if (( ${#UNDOCUMENTED[@]} > 5 )); then
283
+ add_staleness "${#UNDOCUMENTED[@]} production deps not in CLAUDE.md (e.g. ${UNDOCUMENTED[*]:0:5})" 2
279
284
  fi
280
285
  fi
281
286
  fi
282
287
 
283
- # --- 3d) pyproject.toml / requirements.txt drift ----------------------------
288
+ # --- Python dependency drift ------------------------------------------------
284
289
  for PYFILE in "$PROJECT_DIR/pyproject.toml" "$PROJECT_DIR/requirements.txt" "$PROJECT_DIR/setup.py"; do
285
290
  if [[ -f "$PYFILE" ]]; then
286
291
  PY_MTIME=$(file_mtime "$PYFILE")
287
- if (( PY_MTIME > CLAUDE_MD_MTIME )); then
288
- add_staleness "$(basename "$PYFILE") was modified after CLAUDE.md" 1
289
- fi
292
+ (( PY_MTIME > CLAUDE_MD_MTIME )) && add_staleness "$(basename "$PYFILE") modified after CLAUDE.md" 1
290
293
  fi
291
294
  done
292
295
 
293
- # --- 3e) Key config files newer than CLAUDE.md ------------------------------
296
+ # --- Key config files -------------------------------------------------------
294
297
  for CFG in "$PROJECT_DIR/tsconfig.json" \
295
298
  "$PROJECT_DIR/.env.example" \
296
299
  "$PROJECT_DIR/docker-compose.yml" \
@@ -300,15 +303,12 @@ for CFG in "$PROJECT_DIR/tsconfig.json" \
300
303
  "$PROJECT_DIR/go.mod"; do
301
304
  if [[ -f "$CFG" ]]; then
302
305
  CFG_MTIME=$(file_mtime "$CFG")
303
- if (( CFG_MTIME > CLAUDE_MD_MTIME )); then
304
- add_staleness "$(basename "$CFG") modified after CLAUDE.md" 1
305
- fi
306
+ (( CFG_MTIME > CLAUDE_MD_MTIME )) && add_staleness "$(basename "$CFG") modified after CLAUDE.md" 1
306
307
  fi
307
308
  done
308
309
 
309
- # --- 3f) Git-based checks ---------------------------------------------------
310
+ # --- Git-based checks -------------------------------------------------------
310
311
  if command -v git &>/dev/null && [[ -n "$GIT_ROOT" ]]; then
311
- # Convert CLAUDE.md mtime to ISO date for git
312
312
  CLAUDE_MD_DATE=$(date -d "@$CLAUDE_MD_MTIME" --iso-8601=seconds 2>/dev/null) \
313
313
  || CLAUDE_MD_DATE=$(date -r "$CLAUDE_MD_MTIME" +%Y-%m-%dT%H:%M:%S 2>/dev/null) \
314
314
  || CLAUDE_MD_DATE=""
@@ -316,24 +316,21 @@ if command -v git &>/dev/null && [[ -n "$GIT_ROOT" ]]; then
316
316
  if [[ -n "$CLAUDE_MD_DATE" ]]; then
317
317
  COMMITS_SINCE=$(git -C "$PROJECT_DIR" rev-list --count --since="$CLAUDE_MD_DATE" HEAD 2>/dev/null) || COMMITS_SINCE="0"
318
318
  if (( COMMITS_SINCE > 50 )); then
319
- add_staleness "${COMMITS_SINCE} commits since CLAUDE.md was last updated" 2
319
+ add_staleness "${COMMITS_SINCE} commits since CLAUDE.md updated" 2
320
320
  elif (( COMMITS_SINCE > 20 )); then
321
- add_staleness "${COMMITS_SINCE} commits since CLAUDE.md was last updated" 1
321
+ add_staleness "${COMMITS_SINCE} commits since CLAUDE.md updated" 1
322
322
  fi
323
323
 
324
- # Check for structural file additions/deletions
325
324
  CHANGED_FILES=$(git -C "$PROJECT_DIR" diff --name-only --diff-filter=AD HEAD~20..HEAD 2>/dev/null | head -20) || true
326
325
  NEW_TOP_LEVEL=$(echo "$CHANGED_FILES" | { grep -v '/' || true; } | { grep -v '^\.' || true; } | sort -u)
327
326
  if [[ -n "$NEW_TOP_LEVEL" ]]; then
328
327
  NEW_COUNT=$(echo "$NEW_TOP_LEVEL" | wc -l | tr -d ' ')
329
- if (( NEW_COUNT > 3 )); then
330
- add_staleness "Multiple top-level files added/removed since last update (${NEW_COUNT} files)" 1
331
- fi
328
+ (( NEW_COUNT > 3 )) && add_staleness "${NEW_COUNT} top-level files added/removed recently" 1
332
329
  fi
333
330
  fi
334
331
  fi
335
332
 
336
- # --- 3g) Lock file drift (structural indicator) -----------------------------
333
+ # --- Lock file drift --------------------------------------------------------
337
334
  for LOCK in "$PROJECT_DIR/package-lock.json" \
338
335
  "$PROJECT_DIR/yarn.lock" \
339
336
  "$PROJECT_DIR/pnpm-lock.yaml" \
@@ -344,7 +341,7 @@ for LOCK in "$PROJECT_DIR/package-lock.json" \
344
341
  LOCK_DELTA=$(( (LOCK_MTIME - CLAUDE_MD_MTIME) / 86400 ))
345
342
  if (( LOCK_DELTA > 7 )); then
346
343
  add_staleness "$(basename "$LOCK") is ${LOCK_DELTA} days newer than CLAUDE.md" 1
347
- break # Only flag once for lock files
344
+ break
348
345
  fi
349
346
  fi
350
347
  done
@@ -354,42 +351,43 @@ done
354
351
  # ---------------------------------------------------------------------------
355
352
  OUTPUT_PARTS=()
356
353
 
357
- # Include any git warnings collected earlier (monorepo / suspect ancestor)
354
+ # --- Welcome banner ---------------------------------------------------------
355
+ SKILL_COUNT=$(find "$PROJECT_DIR/skills" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l | tr -d ' ') || SKILL_COUNT=0
356
+ if (( SKILL_COUNT > 0 )); then
357
+ OUTPUT_PARTS+=("[MAD SKILLS] Active — ${SKILL_COUNT} skills loaded")
358
+ fi
359
+
358
360
  for part in "${EARLY_CONTEXT_PARTS[@]+"${EARLY_CONTEXT_PARTS[@]}"}"; do
359
361
  OUTPUT_PARTS+=("$part")
360
362
  done
361
363
 
362
- # Confirm CLAUDE.md found
363
364
  OUTPUT_PARTS+=("[SESSION GUARD] ✅ CLAUDE.md found in: $PROJECT_DIR")
364
365
 
365
366
  if (( STALENESS_SCORE >= STALENESS_THRESHOLD )); then
366
367
  OUTPUT_PARTS+=("")
367
- OUTPUT_PARTS+=("[SESSION GUARD] ⚠️ CLAUDE.md appears to be STALE (staleness score: ${STALENESS_SCORE}/${STALENESS_THRESHOLD} threshold)")
368
+ OUTPUT_PARTS+=("[SESSION GUARD] ⚠️ CLAUDE.md appears STALE (score: ${STALENESS_SCORE}/${STALENESS_THRESHOLD})")
368
369
  OUTPUT_PARTS+=("")
369
- OUTPUT_PARTS+=("Staleness signals detected:")
370
+ OUTPUT_PARTS+=("Signals:")
370
371
  for sig in "${STALENESS_SIGNALS[@]}"; do
371
372
  OUTPUT_PARTS+=(" $sig")
372
373
  done
373
374
  OUTPUT_PARTS+=("")
374
- OUTPUT_PARTS+=("Please inform the user with something like:")
375
+ OUTPUT_PARTS+=("Use AskUserQuestion to prompt:")
376
+ OUTPUT_PARTS+=(" Question: \"CLAUDE.md appears out of date (${#STALENESS_SIGNALS[@]} signals detected). What would you like to do?\"")
377
+ OUTPUT_PARTS+=(" Type: single_select")
378
+ OUTPUT_PARTS+=(" Options:")
379
+ OUTPUT_PARTS+=(" 1. \"Update it\" — review project structure, deps, recent changes and update CLAUDE.md (preserve user-written notes)")
380
+ OUTPUT_PARTS+=(" 2. \"Show signals\" — list what's drifted before deciding")
381
+ OUTPUT_PARTS+=(" 3. \"Skip\" — continue with current CLAUDE.md")
375
382
  OUTPUT_PARTS+=("")
376
- OUTPUT_PARTS+=("\"I've detected that your CLAUDE.md may be out of date based on these signals:")
377
- for sig in "${STALENESS_SIGNALS[@]}"; do
378
- OUTPUT_PARTS+=("- ${sig#⚠ }")
379
- done
380
- OUTPUT_PARTS+=("")
381
- OUTPUT_PARTS+=("Would you like me to review and update CLAUDE.md to reflect the current state of the project?\"")
382
- OUTPUT_PARTS+=("")
383
- OUTPUT_PARTS+=("If the user agrees, read the current CLAUDE.md, analyse the project structure, dependencies, and recent changes, then update CLAUDE.md accordingly. Preserve any user-written notes or conventions that are still accurate.")
384
383
  elif (( ${#STALENESS_SIGNALS[@]} > 0 )); then
385
384
  OUTPUT_PARTS+=("")
386
- OUTPUT_PARTS+=("[SESSION GUARD] ℹ️ Minor drift detected (score: ${STALENESS_SCORE}/${STALENESS_THRESHOLD} threshold) — not flagging to user:")
385
+ OUTPUT_PARTS+=("[SESSION GUARD] ℹ️ Minor drift (score: ${STALENESS_SCORE}/${STALENESS_THRESHOLD}) — not flagging:")
387
386
  for sig in "${STALENESS_SIGNALS[@]}"; do
388
387
  OUTPUT_PARTS+=(" $sig")
389
388
  done
390
389
  fi
391
390
 
392
- # Join and emit
393
391
  CONTEXT=$(printf '%s\n' "${OUTPUT_PARTS[@]}")
394
392
 
395
393
  jq -n --arg ctx "$CONTEXT" '{
@@ -399,4 +397,10 @@ jq -n --arg ctx "$CONTEXT" '{
399
397
  }
400
398
  }'
401
399
 
400
+ # ---------------------------------------------------------------------------
401
+ # 5) Write pending flag for UserPromptSubmit companion hook
402
+ # ---------------------------------------------------------------------------
403
+ PENDING_FILE="$PENDING_DIR/$GUARD_KEY.pending"
404
+ echo "$CONTEXT" > "$PENDING_FILE"
405
+
402
406
  exit 0
package/package.json CHANGED
@@ -1,19 +1,14 @@
1
1
  {
2
2
  "name": "@slamb2k/mad-skills",
3
- "version": "2.0.7",
4
- "description": "Claude Code skills collection with CI/CD and npx installer",
3
+ "version": "2.0.8",
4
+ "description": "Claude Code skills collection planning, development and governance tools",
5
5
  "type": "module",
6
- "bin": {
7
- "claude-skills": "./src/cli.js"
8
- },
9
6
  "repository": {
10
7
  "type": "git",
11
8
  "url": "https://github.com/slamb2k/mad-skills"
12
9
  },
13
10
  "files": [
14
- "src/",
15
11
  "skills/",
16
- "commands/",
17
12
  "agents/",
18
13
  "hooks/",
19
14
  ".claude-plugin/"