Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
293 changes: 293 additions & 0 deletions .claude/hooks/post-tool-use-tracker.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
#!/bin/bash
# Note: Removed set -e to prevent hook failures from blocking edits

# IMPORTANT: All output MUST go to stderr (>&2), not stdout.
# stdout from PostToolUse hooks is injected into Claude's context.

# Post-tool-use hook that tracks edited files and their repos
# This runs after Edit, MultiEdit, or Write tools complete successfully

# Require jq for JSON parsing
if ! command -v jq &> /dev/null; then
exit 0
fi

# Exit early if CLAUDE_PROJECT_DIR is not set
if [[ -z "$CLAUDE_PROJECT_DIR" ]]; then
exit 0
fi

# Read tool information from stdin
tool_info=$(cat)


# Extract relevant data
tool_name=$(echo "$tool_info" | jq -r '.tool_name // empty')
file_path=$(echo "$tool_info" | jq -r '.tool_input.file_path // empty')
session_id=$(echo "$tool_info" | jq -r '.session_id // empty')


# Skip if not an edit tool or no file path
if [[ ! "$tool_name" =~ ^(Edit|MultiEdit|Write)$ ]] || [[ -z "$file_path" ]]; then
exit 0 # Exit 0 for skip conditions
fi

# Skip markdown files
if [[ "$file_path" =~ \.(md|markdown)$ ]]; then
exit 0 # Exit 0 for skip conditions
fi

# Create cache directory in project
cache_dir="$CLAUDE_PROJECT_DIR/.claude/tsc-cache/${session_id:-default}"
mkdir -p "$cache_dir"

# Function to detect repo from file path
detect_repo() {
local file="$1"
local project_root="$CLAUDE_PROJECT_DIR"

# Remove project root from path
local relative_path="${file#$project_root/}"

# Extract first directory component
local repo
repo=$(echo "$relative_path" | cut -d'/' -f1)

# Common project directory patterns
case "$repo" in
# Frontend variations
frontend|client|web|app|ui)
echo "$repo"
;;
# Backend variations
backend|server|api|src|services)
echo "$repo"
;;
# Database
database|prisma|migrations)
echo "$repo"
;;
# Package/monorepo structure
packages)
# For monorepos, get the package name
local package=$(echo "$relative_path" | cut -d'/' -f2)
if [[ -n "$package" ]]; then
echo "packages/$package"
else
echo "$repo"
fi
;;
# Examples directory
examples)
local example=$(echo "$relative_path" | cut -d'/' -f2)
if [[ -n "$example" ]]; then
echo "examples/$example"
else
echo "$repo"
fi
;;
*)
# Check if it's a source file in root
if [[ ! "$relative_path" =~ / ]]; then
echo "root"
else
echo "unknown"
fi
;;
esac
}

# Function to get build command for repo
get_build_command() {
local repo="$1"
local project_root="$CLAUDE_PROJECT_DIR"

# Map special repo names to actual paths
local repo_path
if [[ "$repo" == "root" ]] || [[ "$repo" == "src" ]] || [[ "$repo" == "unknown" ]]; then
repo_path="$project_root"
else
repo_path="$project_root/$repo"
fi

# Check if package.json exists and has a build script
if [[ -f "$repo_path/package.json" ]]; then
if grep -q '"build"' "$repo_path/package.json" 2>/dev/null; then
# Detect package manager (prefer pnpm, then npm, then yarn)
if [[ -f "$repo_path/pnpm-lock.yaml" ]]; then
echo "cd $repo_path && pnpm build"
elif [[ -f "$repo_path/package-lock.json" ]]; then
echo "cd $repo_path && npm run build"
elif [[ -f "$repo_path/yarn.lock" ]]; then
echo "cd $repo_path && yarn build"
else
echo "cd $repo_path && npm run build"
fi
return
fi
fi

# Special case for database with Prisma
if [[ "$repo" == "database" ]] || [[ "$repo" =~ prisma ]]; then
if [[ -f "$repo_path/schema.prisma" ]] || [[ -f "$repo_path/prisma/schema.prisma" ]]; then
echo "cd $repo_path && npx prisma generate"
return
fi
fi

# No build command found
echo ""
}

# Function to get TSC command for repo
get_tsc_command() {
local repo="$1"
local project_root="$CLAUDE_PROJECT_DIR"

# Map special repo names to actual paths
local repo_path
if [[ "$repo" == "root" ]] || [[ "$repo" == "src" ]] || [[ "$repo" == "unknown" ]]; then
repo_path="$project_root"
else
repo_path="$project_root/$repo"
fi

# Check if tsconfig.json exists
if [[ -f "$repo_path/tsconfig.json" ]]; then
# Check for Vite/React-specific tsconfig
if [[ -f "$repo_path/tsconfig.app.json" ]]; then
echo "cd $repo_path && npx tsc --project tsconfig.app.json --noEmit"
else
echo "cd $repo_path && npx tsc --noEmit"
fi
return
fi

# No TypeScript config found
echo ""
}

# Detect repo
repo=$(detect_repo "$file_path")

# Skip if unknown repo
if [[ "$repo" == "unknown" ]] || [[ -z "$repo" ]]; then
exit 0 # Exit 0 for skip conditions
fi

# Log edited file
echo "$(date +%s):$file_path:$repo" >> "$cache_dir/edited-files.log"

# Update affected repos list
if ! grep -q "^$repo$" "$cache_dir/affected-repos.txt" 2>/dev/null; then
echo "$repo" >> "$cache_dir/affected-repos.txt"
fi

# Store build commands
build_cmd=$(get_build_command "$repo")
tsc_cmd=$(get_tsc_command "$repo")

if [[ -n "$build_cmd" ]]; then
echo "$repo:build:$build_cmd" >> "$cache_dir/commands.txt.tmp"
fi

if [[ -n "$tsc_cmd" ]]; then
echo "$repo:tsc:$tsc_cmd" >> "$cache_dir/commands.txt.tmp"
fi

# Remove duplicates from commands
if [[ -f "$cache_dir/commands.txt.tmp" ]]; then
sort -u "$cache_dir/commands.txt.tmp" > "$cache_dir/commands.txt"
rm -f "$cache_dir/commands.txt.tmp"
fi

# ============================================
# SESSION-STICKY SKILLS TRACKING
# ============================================
# Detect which domain skill should be activated based on file path
# and persist it in session state for sticky behavior

detect_skill_domain() {
local file="$1"
local detected_skills=""

# Generated by aspens from skill-rules.json filePatterns
if [[ "$file" =~ /customize ]] || [[ "$file" =~ /customize-agents ]]; then
detected_skills="agent-customization"
elif [[ "$file" =~ /runner ]] || [[ "$file" =~ /skill-writer ]] || [[ "$file" =~ /prompts/ ]] || [[ "$file" =~ /tests/ ]]; then
detected_skills="claude-runner"
elif [[ "$file" =~ /doc-sync ]]; then
detected_skills="doc-sync"
elif [[ "$file" =~ /graph-builder ]] || [[ "$file" =~ /graph-builder.test ]]; then
detected_skills="import-graph"
elif [[ "$file" =~ /scanner ]] || [[ "$file" =~ /scan ]] || [[ "$file" =~ /scanner.test ]]; then
Comment on lines +217 to +223

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, view the full context around lines 217-223
head -n 230 .claude/hooks/post-tool-use-tracker.sh | tail -n 20

Repository: aspenkit/aspens

Length of output: 1236


🏁 Script executed:

# Find and examine the generator file that creates post-tool-use-tracker.sh
find . -type f -name "*.js" -o -name "*.sh" | head -20
git ls-files | grep -E "(skill-writer|generate|hook)" | head -20

Repository: aspenkit/aspens

Length of output: 964


🏁 Script executed:

# Search for references to post-tool-use-tracker.sh generation
rg "post-tool-use-tracker" --type sh --type js -B 2 -A 2

Repository: aspenkit/aspens

Length of output: 1668


🏁 Script executed:

# Check if there are actual test files that would match the patterns
find . -type f -name "*graph-builder.test*" -o -name "*scanner.test*"

Repository: aspenkit/aspens

Length of output: 112


🏁 Script executed:

# Find the skill-rules.json file
find . -name "skill-rules.json" -o -name "*skill*rules*"

Repository: aspenkit/aspens

Length of output: 92


🏁 Script executed:

# Check the template file to see the original rules
cat src/templates/hooks/post-tool-use-tracker.sh | head -50

Repository: aspenkit/aspens

Length of output: 1489


🏁 Script executed:

# View the skill-rules.json to see the source patterns
cat ./.claude/skills/skill-rules.json | jq . | head -100

Repository: aspenkit/aspens

Length of output: 2747


🏁 Script executed:

# Also check how the patterns are applied in doc-init.js
sed -n '1,50p' src/commands/doc-init.js | grep -A 20 "post-tool-use-tracker"

Repository: aspenkit/aspens

Length of output: 41


🏁 Script executed:

# Search for where the skill rules are processed/ordered
rg "filePatterns" -A 5 -B 5 --type js

Repository: aspenkit/aspens

Length of output: 4581


🏁 Script executed:

# Get the complete skill-rules.json to see all filePatterns
cat ./.claude/skills/skill-rules.json | jq '.skills | keys' && echo "---" && cat ./.claude/skills/skill-rules.json | jq '.skills | to_entries[] | {skill: .key, patterns: .value.filePatterns}'

Repository: aspenkit/aspens

Length of output: 1305


🏁 Script executed:

# Check the globToBashPattern function to understand the conversion
rg "globToBashPattern" -A 20 src/lib/skill-writer.js

Repository: aspenkit/aspens

Length of output: 1519


🏁 Script executed:

# See the complete globToBashPattern function to understand how tests/*extract* is converted
rg "function globToBashPattern" -A 40 src/lib/skill-writer.js

Repository: aspenkit/aspens

Length of output: 1349


🏁 Script executed:

# Verify what the actual generated patterns look like by checking if there's a debug mode or examining the actual conversion
python3 << 'EOF'
# Simulate the globToBashPattern logic
from pathlib import Path

def glob_to_bash_pattern(glob_str):
    """Mimic the globToBashPattern function logic"""
    if not glob_str:
        return None
    
    cleaned = glob_str
    # Remove leading **/ or */
    cleaned = cleaned.lstrip('*').lstrip('/')
    if glob_str.startswith('**/'):
        cleaned = glob_str[3:]
    elif glob_str.startswith('*/'):
        cleaned = glob_str[2:]
    else:
        cleaned = glob_str
    
    # Handle dir/**/*.ext patterns
    if cleaned.endswith('/**') or '//**/' in cleaned:
        # Extract directory
        parts = cleaned.split('/')
        if parts:
            return f"/{parts[0]}"
    
    # Check for file with extension and no wildcards
    if '.' in cleaned and '*' not in cleaned:
        filename = Path(cleaned).name
        name = filename.rsplit('.', 1)[0] if '.' in filename else filename
        if len(name) > 2:
            return f"/{name}"
        return None
    
    # For patterns with wildcards in middle (like tests/*extract*)
    # Extract the directory part before wildcard
    parts = cleaned.split('/')
    if parts and parts[0]:
        return f"/{parts[0]}"
    
    return None

patterns_to_test = [
    "src/lib/runner.js",
    "src/lib/skill-writer.js", 
    "src/prompts/**/*.md",
    "tests/*extract*",
    "src/lib/graph-builder.js",
    "tests/graph-builder.test.js",
    "src/lib/scanner.js",
    "tests/scanner.test.js"
]

for pattern in patterns_to_test:
    result = glob_to_bash_pattern(pattern)
    print(f"{pattern:40} → {result}")
EOF

Repository: aspenkit/aspens

Length of output: 494


The broad /tests/ match masks specific test-domain rules.

Detection is first-match-wins, so tests/graph-builder.test.js and tests/scanner.test.js will incorrectly activate claude-runner instead of import-graph and repo-scanning. The tests/*extract* pattern in claude-runner's filePatterns converts to /tests/, which is too broad. Fix this in skill-rules.json by making the claude-runner test pattern more specific (e.g., tests/*extract* should not match test files for other domains), or refactor the pattern ordering in the generator to prioritize specific filename matches over generic directory patterns.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/hooks/post-tool-use-tracker.sh around lines 217 - 223, The current
broad regex match for "/tests/" in the post-tool-use-tracker.sh detection logic
causes first-match-wins false positives (e.g., tests/graph-builder.test.js
hitting the claude-runner branch), so tighten the claude-runner test pattern in
skill-rules.json (replace the generic "tests/*" style pattern with the specific
"tests/*extract*" or similar more constrained pattern) or reorder rules so
filename-specific patterns (e.g., graph-builder.test, scanner.test) are
evaluated before generic directory matches; update the generator that produces
the /tests/ pattern so the claude-runner filePatterns no longer emit a plain
"/tests/" matcher and ensure detected_skills assignment
(detected_skills="claude-runner") only fires for the narrowed pattern.

detected_skills="repo-scanning"
elif [[ "$file" =~ /doc-init ]] || [[ "$file" =~ /doc-sync ]] || [[ "$file" =~ /customize ]] || [[ "$file" =~ /context-builder ]] || [[ "$file" =~ /runner ]] || [[ "$file" =~ /skill-writer ]] || [[ "$file" =~ /prompts/ ]]; then
detected_skills="skill-generation"
elif [[ "$file" =~ /add ]] || [[ "$file" =~ /customize ]] || [[ "$file" =~ /templates/ ]]; then
detected_skills="template-library"
fi

echo "$detected_skills"
}

# Create session file path based on project directory hash
get_session_file() {
local project_dir="$1"
local hash=$(echo -n "$project_dir" | md5 2>/dev/null || echo -n "$project_dir" | md5sum | cut -d' ' -f1)
echo "${TMPDIR:-/tmp}/claude-skills-${hash}.json"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Add skill to session state
add_skill_to_session() {
local skill="$1"
local session_file="$2"
local repo="$3"

if [[ -z "$skill" ]]; then
return
fi

# Create or update session file
if [[ -f "$session_file" ]]; then
# Check if jq is available
if command -v jq &> /dev/null; then
# Add skill to array, keeping unique values
jq --arg skill "$skill" --arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.active_skills = ((.active_skills + [$skill]) | unique) | .last_updated = $time' \
"$session_file" > "${session_file}.tmp" 2>/dev/null && \
mv "${session_file}.tmp" "$session_file"
else
# Fallback: simple append check without jq
if ! grep -q "\"$skill\"" "$session_file" 2>/dev/null; then
# Read existing skills from file, append new one, rewrite
local existing_skills=""
if [[ -f "$session_file" ]]; then
# Extract skills array content: strip brackets, quotes, whitespace
existing_skills=$(grep -o '"active_skills":\[[^]]*\]' "$session_file" 2>/dev/null | sed 's/"active_skills":\[//;s/\]//;s/"//g;s/ //g')
fi
# Build new skills list
local new_skills=""
if [[ -n "$existing_skills" ]]; then
new_skills="\"$(echo "$existing_skills" | sed 's/,/","/g')\",\"$skill\""
else
new_skills="\"$skill\""
fi
echo "{\"repo\":\"$repo\",\"active_skills\":[$new_skills],\"last_updated\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$session_file"
fi
fi
else
# Create new session file
echo "{\"repo\":\"$repo\",\"active_skills\":[\"$skill\"],\"last_updated\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$session_file"
fi
}

# Track skill domain for session-sticky behavior
skill_domain=$(detect_skill_domain "$file_path")
if [[ -n "$skill_domain" ]]; then
session_file=$(get_session_file "$CLAUDE_PROJECT_DIR")
add_skill_to_session "$skill_domain" "$session_file" "$repo"
fi

# Exit cleanly
exit 0
Loading
Loading