@slamb2k/mad-skills 2.0.12 → 2.0.14

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,7 +1,7 @@
1
1
  {
2
2
  "name": "mad-skills",
3
3
  "description": "AI-assisted planning, development and governance tools",
4
- "version": "2.0.12",
4
+ "version": "2.0.14",
5
5
  "author": {
6
6
  "name": "slamb2k",
7
7
  "url": "https://github.com/slamb2k"
package/hooks/hooks.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "hooks": {
3
3
  "SessionStart": [{
4
4
  "matcher": "startup|clear|compact",
5
- "hooks": [{ "type": "command", "command": "node \"$HOME/.claude/hooks/session-guard.js\" check", "timeout": 30 }]
5
+ "hooks": [{ "type": "command", "command": "node ./hooks/session-guard.cjs check", "timeout": 30 }]
6
6
  }],
7
7
  "UserPromptSubmit": [{
8
- "hooks": [{ "type": "command", "command": "node \"$HOME/.claude/hooks/session-guard.js\" remind", "timeout": 10 }]
8
+ "hooks": [{ "type": "command", "command": "node ./hooks/session-guard.cjs remind", "timeout": 10 }]
9
9
  }]
10
10
  }
11
11
  }
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const config = require('./config');
3
+ const config = require('./config.cjs');
4
4
 
5
5
  // prettier-ignore
6
6
  const BANNER_LINES = [
@@ -1,7 +1,24 @@
1
1
  'use strict';
2
2
 
3
+ const { readFileSync } = require('fs');
4
+ const { join, dirname } = require('path');
5
+
6
+ function getVersion() {
7
+ // Walk up from lib/ to find package.json (mad-skills plugin root)
8
+ const hooksDir = dirname(__dirname);
9
+ for (const candidate of [
10
+ join(hooksDir, '..', 'package.json'),
11
+ join(hooksDir, '..', '.claude-plugin', 'plugin.json'),
12
+ ]) {
13
+ try {
14
+ return JSON.parse(readFileSync(candidate, 'utf8')).version;
15
+ } catch {}
16
+ }
17
+ return '0.0.0-dev';
18
+ }
19
+
3
20
  module.exports = {
4
- version: '1.0.0',
21
+ version: getVersion(),
5
22
 
6
23
  staleness: {
7
24
  threshold: 3,
@@ -2,8 +2,8 @@
2
2
 
3
3
  const { existsSync } = require('fs');
4
4
  const { join, dirname, basename } = require('path');
5
- const config = require('./config');
6
- const { git, readJson, countFiles } = require('./utils');
5
+ const config = require('./config.cjs');
6
+ const { git, readJson, countFiles } = require('./utils.cjs');
7
7
 
8
8
  /**
9
9
  * Validate git repository state.
@@ -2,8 +2,8 @@
2
2
 
3
3
  const { existsSync } = require('fs');
4
4
  const { join } = require('path');
5
- const config = require('./config');
6
- const { fileMtime, git, readJson, readText, getDirectories } = require('./utils');
5
+ const config = require('./config.cjs');
6
+ const { fileMtime, git, readJson, readText, getDirectories } = require('./utils.cjs');
7
7
 
8
8
  /**
9
9
  * Evaluate all staleness signals for CLAUDE.md.
@@ -3,8 +3,8 @@
3
3
  const { existsSync } = require('fs');
4
4
  const { join, basename } = require('path');
5
5
  const { homedir } = require('os');
6
- const config = require('./config');
7
- const { readJson, git } = require('./utils');
6
+ const config = require('./config.cjs');
7
+ const { readJson, git } = require('./utils.cjs');
8
8
 
9
9
  /**
10
10
  * Check if a persistent Task List ID is configured.
@@ -20,13 +20,13 @@
20
20
  const { existsSync } = require('fs');
21
21
  const { join } = require('path');
22
22
 
23
- const config = require('./lib/config');
24
- const state = require('./lib/state');
25
- const { OutputBuilder } = require('./lib/output');
26
- const { getBanner, BANNER_MARKER } = require('./lib/banner');
27
- const { checkGit } = require('./lib/git-checks');
28
- const { checkTaskList } = require('./lib/task-checks');
29
- const { checkStaleness } = require('./lib/staleness');
23
+ const config = require('./lib/config.cjs');
24
+ const state = require('./lib/state.cjs');
25
+ const { OutputBuilder } = require('./lib/output.cjs');
26
+ const { getBanner, BANNER_MARKER } = require('./lib/banner.cjs');
27
+ const { checkGit } = require('./lib/git-checks.cjs');
28
+ const { checkTaskList } = require('./lib/task-checks.cjs');
29
+ const { checkStaleness } = require('./lib/staleness.cjs');
30
30
 
31
31
  const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd();
32
32
  const CLAUDE_MD = join(PROJECT_DIR, 'CLAUDE.md');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slamb2k/mad-skills",
3
- "version": "2.0.12",
3
+ "version": "2.0.14",
4
4
  "description": "Claude Code skills collection — planning, development and governance tools",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "generated": "2026-03-09T13:01:19.482Z",
2
+ "generated": "2026-03-09T14:11:12.798Z",
3
3
  "count": 8,
4
4
  "skills": [
5
5
  {
@@ -1,59 +0,0 @@
1
- #!/usr/bin/env bash
2
- # session-guard-prompt.sh — UserPromptSubmit companion hook for session-guard
3
- #
4
- # Checks if session-guard.sh left a pending context file from SessionStart.
5
- # If found, re-emits the context as additionalContext on the first user prompt,
6
- # then deletes the flag file so it only fires once.
7
- #
8
- # This works around the known limitation where SessionStart hook output is
9
- # silently injected and may not surface until after the first prompt is
10
- # already processed (see anthropics/claude-code#10808).
11
- #
12
- # Install globally in ~/.claude/settings.json:
13
- # "command": "\"$HOME\"/.claude/hooks/session-guard-prompt.sh"
14
- #
15
- # Or per-project in .claude/settings.json:
16
- # "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-guard-prompt.sh"
17
-
18
- set -uo pipefail
19
-
20
- PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
21
- PENDING_DIR="${TMPDIR:-/tmp}/claude-session-guard"
22
- GUARD_KEY=$(echo "$PROJECT_DIR" | md5sum 2>/dev/null | cut -d' ' -f1 || echo "default")
23
- PENDING_FILE="$PENDING_DIR/$GUARD_KEY.pending"
24
-
25
- # No pending file — fast exit
26
- if [[ ! -f "$PENDING_FILE" ]]; then
27
- jq -n '{}'
28
- exit 0
29
- fi
30
-
31
- CONTEXT=$(cat "$PENDING_FILE")
32
- rm -f "$PENDING_FILE"
33
-
34
- # Also clean up the dedup lock from session-guard.sh
35
- rm -f "$PENDING_DIR/$GUARD_KEY.lock"
36
-
37
- # Skip re-emit if session-guard found no issues (just the ✅ line, no warnings)
38
- if [[ $(echo "$CONTEXT" | grep -c '⚠️\|ℹ️') -eq 0 ]]; then
39
- jq -n '{}'
40
- exit 0
41
- fi
42
-
43
- WRAPPED=$(cat <<EOF
44
- [SESSION GUARD — FIRST PROMPT REMINDER]
45
- The following was detected at session start. Act on these items NOW using
46
- AskUserQuestion BEFORE proceeding with the user's request.
47
-
48
- $CONTEXT
49
- EOF
50
- )
51
-
52
- jq -n --arg ctx "$WRAPPED" '{
53
- hookSpecificOutput: {
54
- hookEventName: "UserPromptSubmit",
55
- additionalContext: $ctx
56
- }
57
- }'
58
-
59
- exit 0
@@ -1,400 +0,0 @@
1
- #!/usr/bin/env bash
2
- # session-guard.sh — Claude Code SessionStart hook
3
- # Validates Git repo, CLAUDE.md existence/freshness, Task List ID config,
4
- # and checks for staleness.
5
- #
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).
17
-
18
- # NOTE: We intentionally avoid `set -e` here. Many commands (grep, git, find,
19
- # jq, stat) return non-zero for perfectly normal reasons (no matches, not a
20
- # repo, missing files). Letting them fail gracefully is simpler and more
21
- # reliable than wrapping every line in `|| true`.
22
- set -uo pipefail
23
-
24
- # ---------------------------------------------------------------------------
25
- # Config
26
- # ---------------------------------------------------------------------------
27
- PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
28
- CLAUDE_MD="$PROJECT_DIR/CLAUDE.md"
29
- NOW=$(date +%s)
30
- STALENESS_THRESHOLD=3 # Accumulated score >= this triggers user prompt
31
- PENDING_DIR="${TMPDIR:-/tmp}/claude-session-guard"
32
-
33
- # ---------------------------------------------------------------------------
34
- # State
35
- # ---------------------------------------------------------------------------
36
- EARLY_CONTEXT_PARTS=()
37
- STALENESS_SIGNALS=()
38
- STALENESS_SCORE=0
39
-
40
- add_staleness() {
41
- local msg="$1"
42
- local weight="${2:-1}"
43
- STALENESS_SIGNALS+=("⚠ $msg")
44
- STALENESS_SCORE=$((STALENESS_SCORE + weight))
45
- }
46
-
47
- # Portable stat wrapper: returns mtime as epoch seconds
48
- file_mtime() {
49
- stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo "$NOW"
50
- }
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
-
68
- # ---------------------------------------------------------------------------
69
- # 0) Git repository check
70
- # ---------------------------------------------------------------------------
71
- GIT_ROOT=""
72
-
73
- if command -v git &>/dev/null; then
74
- GIT_ROOT=$(git -C "$PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null) || true
75
-
76
- if [[ -z "$GIT_ROOT" ]]; then
77
- EARLY_CONTEXT_PARTS+=("[SESSION GUARD] ⚠️ This directory is NOT tracked by Git.")
78
- EARLY_CONTEXT_PARTS+=("")
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")
85
- EARLY_CONTEXT_PARTS+=("")
86
-
87
- elif [[ "$GIT_ROOT" != "$PROJECT_DIR" ]]; then
88
- DEPTH=0
89
- CHECK_DIR="$PROJECT_DIR"
90
- while [[ "$CHECK_DIR" != "$GIT_ROOT" && "$CHECK_DIR" != "/" ]]; do
91
- CHECK_DIR=$(dirname "$CHECK_DIR")
92
- DEPTH=$((DEPTH + 1))
93
- done
94
-
95
- MONOREPO_SIGNALS=()
96
-
97
- if [[ -f "$GIT_ROOT/package.json" ]] && command -v jq &>/dev/null; then
98
- jq -e '.workspaces // empty' "$GIT_ROOT/package.json" &>/dev/null && \
99
- MONOREPO_SIGNALS+=("package.json has 'workspaces' field")
100
- fi
101
- [[ -f "$GIT_ROOT/pnpm-workspace.yaml" ]] && MONOREPO_SIGNALS+=("pnpm-workspace.yaml exists")
102
- [[ -f "$GIT_ROOT/lerna.json" ]] && MONOREPO_SIGNALS+=("lerna.json exists")
103
- [[ -f "$GIT_ROOT/nx.json" ]] && MONOREPO_SIGNALS+=("nx.json exists")
104
- [[ -f "$GIT_ROOT/turbo.json" ]] && MONOREPO_SIGNALS+=("turbo.json exists")
105
- [[ -f "$GIT_ROOT/rush.json" ]] && MONOREPO_SIGNALS+=("rush.json exists")
106
-
107
- for d in packages apps services libs modules projects; do
108
- [[ -d "$GIT_ROOT/$d" ]] && MONOREPO_SIGNALS+=("'$d/' directory exists at git root")
109
- done
110
-
111
- PKG_COUNT=$(find "$GIT_ROOT" -maxdepth 3 -name "package.json" -not -path "*/node_modules/*" 2>/dev/null | wc -l | tr -d ' ')
112
- (( PKG_COUNT > 2 )) && MONOREPO_SIGNALS+=("${PKG_COUNT} package.json files found")
113
-
114
- CLAUDE_MD_COUNT=$(find "$GIT_ROOT" -maxdepth 3 -name "CLAUDE.md" 2>/dev/null | wc -l | tr -d ' ')
115
- (( CLAUDE_MD_COUNT > 1 )) && MONOREPO_SIGNALS+=("${CLAUDE_MD_COUNT} CLAUDE.md files found (per-package setup)")
116
-
117
- RELATIVE_PATH="${PROJECT_DIR#"$GIT_ROOT/"}"
118
-
119
- if (( ${#MONOREPO_SIGNALS[@]} >= 2 )); then
120
- EARLY_CONTEXT_PARTS+=("[SESSION GUARD] ℹ️ Git root is ${DEPTH} level(s) above CWD.")
121
- EARLY_CONTEXT_PARTS+=(" Git root: $GIT_ROOT")
122
- EARLY_CONTEXT_PARTS+=(" Working dir: $PROJECT_DIR")
123
- EARLY_CONTEXT_PARTS+=(" Monorepo signals: ${MONOREPO_SIGNALS[*]}")
124
- EARLY_CONTEXT_PARTS+=("")
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\"")
131
- EARLY_CONTEXT_PARTS+=("")
132
-
133
- else
134
- GIT_ROOT_FILE_COUNT=$(find "$GIT_ROOT" -maxdepth 1 -not -name '.*' 2>/dev/null | wc -l | tr -d ' ')
135
-
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)")
138
- EARLY_CONTEXT_PARTS+=(" Working dir: $PROJECT_DIR")
139
- if (( ${#MONOREPO_SIGNALS[@]} > 0 )); then
140
- EARLY_CONTEXT_PARTS+=(" Weak signals: ${MONOREPO_SIGNALS[*]}")
141
- fi
142
- EARLY_CONTEXT_PARTS+=("")
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")
150
- EARLY_CONTEXT_PARTS+=("")
151
- fi
152
- fi
153
- fi
154
-
155
- # ---------------------------------------------------------------------------
156
- # 1) CLAUDE.md existence check
157
- # ---------------------------------------------------------------------------
158
- if [[ ! -f "$CLAUDE_MD" ]]; then
159
- EARLY_CONTEXT_PARTS+=("[SESSION GUARD] ⚠️ No CLAUDE.md found in project root.")
160
- EARLY_CONTEXT_PARTS+=("")
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")
167
- EARLY_CONTEXT_PARTS+=("")
168
-
169
- # Emit and write pending flag, then exit early
170
- CONTEXT=$(printf '%s\n' "${EARLY_CONTEXT_PARTS[@]}")
171
- jq -n --arg ctx "$CONTEXT" '{
172
- hookSpecificOutput: {
173
- hookEventName: "SessionStart",
174
- additionalContext: $ctx
175
- }
176
- }'
177
- PENDING_FILE="$PENDING_DIR/$GUARD_KEY.pending"
178
- echo "$CONTEXT" > "$PENDING_FILE"
179
- exit 0
180
- fi
181
-
182
- # ---------------------------------------------------------------------------
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
223
- # ---------------------------------------------------------------------------
224
- CLAUDE_MD_MTIME=$(file_mtime "$CLAUDE_MD")
225
- CLAUDE_MD_AGE_DAYS=$(( (NOW - CLAUDE_MD_MTIME) / 86400 ))
226
-
227
- # --- Age-based --------------------------------------------------------------
228
- if (( CLAUDE_MD_AGE_DAYS > 14 )); then
229
- add_staleness "CLAUDE.md last modified ${CLAUDE_MD_AGE_DAYS} days ago" 2
230
- elif (( CLAUDE_MD_AGE_DAYS > 7 )); then
231
- add_staleness "CLAUDE.md last modified ${CLAUDE_MD_AGE_DAYS} days ago" 1
232
- fi
233
-
234
- # --- Directory structure drift ----------------------------------------------
235
- if command -v tree &>/dev/null; then
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
237
- if [[ -n "$CURRENT_TREE" ]]; then
238
- TREE_DIRS=$(echo "$CURRENT_TREE" | tail -n +2 \
239
- | sed 's/[│├└─┬┤┼┐┘┌┏┗┓┛]//g; s/[|`]//g; s/--*//g' \
240
- | sed 's/^[[:space:]]*//' | { grep -v '^$' || true; } | sort -u)
241
- MISSING_DIRS=()
242
- while IFS= read -r dir; do
243
- [[ -z "$dir" ]] && continue
244
- grep -qi "$dir" "$CLAUDE_MD" 2>/dev/null || MISSING_DIRS+=("$dir")
245
- done <<< "$TREE_DIRS"
246
- if (( ${#MISSING_DIRS[@]} > 2 )); then
247
- add_staleness "Directories not in CLAUDE.md: ${MISSING_DIRS[*]}" 2
248
- elif (( ${#MISSING_DIRS[@]} > 0 )); then
249
- add_staleness "Directories not in CLAUDE.md: ${MISSING_DIRS[*]}" 1
250
- fi
251
- fi
252
- fi
253
-
254
- # --- package.json drift -----------------------------------------------------
255
- if [[ -f "$PROJECT_DIR/package.json" ]]; then
256
- PKG_MTIME=$(file_mtime "$PROJECT_DIR/package.json")
257
-
258
- if (( PKG_MTIME > CLAUDE_MD_MTIME )); then
259
- PKG_DELTA=$(( (PKG_MTIME - CLAUDE_MD_MTIME) / 86400 ))
260
- add_staleness "package.json modified ${PKG_DELTA} day(s) after CLAUDE.md" 1
261
- fi
262
-
263
- if command -v jq &>/dev/null; then
264
- DEP_COUNT=$(jq '[(.dependencies // {} | length), (.devDependencies // {} | length)] | add' "$PROJECT_DIR/package.json" 2>/dev/null) || DEP_COUNT="0"
265
- DOCUMENTED_COUNT=$(grep -oiP '\d+\s*(dependencies|deps)' "$CLAUDE_MD" 2>/dev/null | head -1 | grep -oP '\d+') || true
266
- if [[ -n "$DOCUMENTED_COUNT" && -n "$DEP_COUNT" ]]; then
267
- DRIFT=$(( DEP_COUNT - DOCUMENTED_COUNT ))
268
- DRIFT_ABS=${DRIFT#-}
269
- if (( DRIFT_ABS > 5 )); then
270
- add_staleness "Dep count drift: CLAUDE.md ~${DOCUMENTED_COUNT}, actual ${DEP_COUNT} (Δ${DRIFT})" 2
271
- elif (( DRIFT_ABS > 0 )); then
272
- add_staleness "Dep count drift: CLAUDE.md ~${DOCUMENTED_COUNT}, actual ${DEP_COUNT}" 1
273
- fi
274
- fi
275
-
276
- KEY_DEPS=$(jq -r '(.dependencies // {}) | keys[]' "$PROJECT_DIR/package.json" 2>/dev/null) || true
277
- UNDOCUMENTED=()
278
- while IFS= read -r dep; do
279
- [[ -z "$dep" ]] && continue
280
- grep -qi "$dep" "$CLAUDE_MD" 2>/dev/null || UNDOCUMENTED+=("$dep")
281
- done <<< "$KEY_DEPS"
282
- if (( ${#UNDOCUMENTED[@]} > 5 )); then
283
- add_staleness "${#UNDOCUMENTED[@]} production deps not in CLAUDE.md (e.g. ${UNDOCUMENTED[*]:0:5})" 2
284
- fi
285
- fi
286
- fi
287
-
288
- # --- Python dependency drift ------------------------------------------------
289
- for PYFILE in "$PROJECT_DIR/pyproject.toml" "$PROJECT_DIR/requirements.txt" "$PROJECT_DIR/setup.py"; do
290
- if [[ -f "$PYFILE" ]]; then
291
- PY_MTIME=$(file_mtime "$PYFILE")
292
- (( PY_MTIME > CLAUDE_MD_MTIME )) && add_staleness "$(basename "$PYFILE") modified after CLAUDE.md" 1
293
- fi
294
- done
295
-
296
- # --- Key config files -------------------------------------------------------
297
- for CFG in "$PROJECT_DIR/tsconfig.json" \
298
- "$PROJECT_DIR/.env.example" \
299
- "$PROJECT_DIR/docker-compose.yml" \
300
- "$PROJECT_DIR/Dockerfile" \
301
- "$PROJECT_DIR/Makefile" \
302
- "$PROJECT_DIR/Cargo.toml" \
303
- "$PROJECT_DIR/go.mod"; do
304
- if [[ -f "$CFG" ]]; then
305
- CFG_MTIME=$(file_mtime "$CFG")
306
- (( CFG_MTIME > CLAUDE_MD_MTIME )) && add_staleness "$(basename "$CFG") modified after CLAUDE.md" 1
307
- fi
308
- done
309
-
310
- # --- Git-based checks -------------------------------------------------------
311
- if command -v git &>/dev/null && [[ -n "$GIT_ROOT" ]]; then
312
- CLAUDE_MD_DATE=$(date -d "@$CLAUDE_MD_MTIME" --iso-8601=seconds 2>/dev/null) \
313
- || CLAUDE_MD_DATE=$(date -r "$CLAUDE_MD_MTIME" +%Y-%m-%dT%H:%M:%S 2>/dev/null) \
314
- || CLAUDE_MD_DATE=""
315
-
316
- if [[ -n "$CLAUDE_MD_DATE" ]]; then
317
- COMMITS_SINCE=$(git -C "$PROJECT_DIR" rev-list --count --since="$CLAUDE_MD_DATE" HEAD 2>/dev/null) || COMMITS_SINCE="0"
318
- if (( COMMITS_SINCE > 50 )); then
319
- add_staleness "${COMMITS_SINCE} commits since CLAUDE.md updated" 2
320
- elif (( COMMITS_SINCE > 20 )); then
321
- add_staleness "${COMMITS_SINCE} commits since CLAUDE.md updated" 1
322
- fi
323
-
324
- CHANGED_FILES=$(git -C "$PROJECT_DIR" diff --name-only --diff-filter=AD HEAD~20..HEAD 2>/dev/null | head -20) || true
325
- NEW_TOP_LEVEL=$(echo "$CHANGED_FILES" | { grep -v '/' || true; } | { grep -v '^\.' || true; } | sort -u)
326
- if [[ -n "$NEW_TOP_LEVEL" ]]; then
327
- NEW_COUNT=$(echo "$NEW_TOP_LEVEL" | wc -l | tr -d ' ')
328
- (( NEW_COUNT > 3 )) && add_staleness "${NEW_COUNT} top-level files added/removed recently" 1
329
- fi
330
- fi
331
- fi
332
-
333
- # --- Lock file drift --------------------------------------------------------
334
- for LOCK in "$PROJECT_DIR/package-lock.json" \
335
- "$PROJECT_DIR/yarn.lock" \
336
- "$PROJECT_DIR/pnpm-lock.yaml" \
337
- "$PROJECT_DIR/Cargo.lock" \
338
- "$PROJECT_DIR/poetry.lock"; do
339
- if [[ -f "$LOCK" ]]; then
340
- LOCK_MTIME=$(file_mtime "$LOCK")
341
- LOCK_DELTA=$(( (LOCK_MTIME - CLAUDE_MD_MTIME) / 86400 ))
342
- if (( LOCK_DELTA > 7 )); then
343
- add_staleness "$(basename "$LOCK") is ${LOCK_DELTA} days newer than CLAUDE.md" 1
344
- break
345
- fi
346
- fi
347
- done
348
-
349
- # ---------------------------------------------------------------------------
350
- # 4) Produce output
351
- # ---------------------------------------------------------------------------
352
- OUTPUT_PARTS=()
353
-
354
- for part in "${EARLY_CONTEXT_PARTS[@]+"${EARLY_CONTEXT_PARTS[@]}"}"; do
355
- OUTPUT_PARTS+=("$part")
356
- done
357
-
358
- OUTPUT_PARTS+=("[SESSION GUARD] ✅ CLAUDE.md found in: $PROJECT_DIR")
359
-
360
- if (( STALENESS_SCORE >= STALENESS_THRESHOLD )); then
361
- OUTPUT_PARTS+=("")
362
- OUTPUT_PARTS+=("[SESSION GUARD] ⚠️ CLAUDE.md appears STALE (score: ${STALENESS_SCORE}/${STALENESS_THRESHOLD})")
363
- OUTPUT_PARTS+=("")
364
- OUTPUT_PARTS+=("Signals:")
365
- for sig in "${STALENESS_SIGNALS[@]}"; do
366
- OUTPUT_PARTS+=(" $sig")
367
- done
368
- OUTPUT_PARTS+=("")
369
- OUTPUT_PARTS+=("Use AskUserQuestion to prompt:")
370
- OUTPUT_PARTS+=(" Question: \"CLAUDE.md appears out of date (${#STALENESS_SIGNALS[@]} signals detected). What would you like to do?\"")
371
- OUTPUT_PARTS+=(" Type: single_select")
372
- OUTPUT_PARTS+=(" Options:")
373
- OUTPUT_PARTS+=(" 1. \"Update it\" — review project structure, deps, recent changes and update CLAUDE.md (preserve user-written notes)")
374
- OUTPUT_PARTS+=(" 2. \"Show signals\" — list what's drifted before deciding")
375
- OUTPUT_PARTS+=(" 3. \"Skip\" — continue with current CLAUDE.md")
376
- OUTPUT_PARTS+=("")
377
- elif (( ${#STALENESS_SIGNALS[@]} > 0 )); then
378
- OUTPUT_PARTS+=("")
379
- OUTPUT_PARTS+=("[SESSION GUARD] ℹ️ Minor drift (score: ${STALENESS_SCORE}/${STALENESS_THRESHOLD}) — not flagging:")
380
- for sig in "${STALENESS_SIGNALS[@]}"; do
381
- OUTPUT_PARTS+=(" $sig")
382
- done
383
- fi
384
-
385
- CONTEXT=$(printf '%s\n' "${OUTPUT_PARTS[@]}")
386
-
387
- jq -n --arg ctx "$CONTEXT" '{
388
- hookSpecificOutput: {
389
- hookEventName: "SessionStart",
390
- additionalContext: $ctx
391
- }
392
- }'
393
-
394
- # ---------------------------------------------------------------------------
395
- # 5) Write pending flag for UserPromptSubmit companion hook
396
- # ---------------------------------------------------------------------------
397
- PENDING_FILE="$PENDING_DIR/$GUARD_KEY.pending"
398
- echo "$CONTEXT" > "$PENDING_FILE"
399
-
400
- exit 0
File without changes
File without changes
File without changes