diff --git a/scripts/lib/error-handling.sh b/scripts/lib/error-handling.sh new file mode 100644 index 0000000..bd1a58f --- /dev/null +++ b/scripts/lib/error-handling.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# CodeSensei — Shared Error Handling Library +# Source this file in hook scripts for consistent error logging and JSON safety. +# +# Usage: +# source "$(dirname "$0")/lib/error-handling.sh" + +LOG_FILE="${HOME}/.code-sensei/error.log" +MAX_LOG_LINES=1000 + +# Log an error with timestamp and script name. +# Usage: log_error "script-name" "message" +log_error() { + local script_name="${1:-unknown}" + local message="$2" + local timestamp + timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date '+%Y-%m-%d') + mkdir -p "$(dirname "$LOG_FILE")" + printf '[%s] [%s] %s\n' "$timestamp" "$script_name" "$message" >> "$LOG_FILE" + # Cap log file to MAX_LOG_LINES + if [ -f "$LOG_FILE" ]; then + local line_count + line_count=$(wc -l < "$LOG_FILE" 2>/dev/null || echo 0) + if [ "$line_count" -gt "$MAX_LOG_LINES" ]; then + tail -n "$MAX_LOG_LINES" "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE" + fi + fi +} + +# Safely escape a string for JSON interpolation. +# Returns a JSON-encoded string including surrounding double quotes. +# Usage: escaped=$(json_escape "$var") +json_escape() { + local str="$1" + if command -v jq &>/dev/null; then + printf '%s' "$str" | jq -Rs '.' + else + # Basic fallback: escape backslashes, double quotes, and common control chars + printf '"%s"' "$(printf '%s' "$str" | sed 's/\\/\\\\/g; s/"/\\"/g')" + fi +} + +# Check that jq is installed. Logs a one-time warning per session if missing. +# Usage: check_jq "script-name" || exit 0 +check_jq() { + if ! command -v jq &>/dev/null; then + local warn_file="${HOME}/.code-sensei/.jq-warned" + if [ ! -f "$warn_file" ]; then + log_error "${1:-unknown}" "jq not installed — CodeSensei features limited. Install with: brew install jq" + touch "$warn_file" + fi + return 1 + fi + return 0 +} diff --git a/scripts/quiz-selector.sh b/scripts/quiz-selector.sh index 1e130ef..571c399 100755 --- a/scripts/quiz-selector.sh +++ b/scripts/quiz-selector.sh @@ -14,15 +14,34 @@ # "quiz_format": "multiple_choice" | "free_response" | "code_prediction" # } +SCRIPT_NAME="quiz-selector" PROFILE_DIR="$HOME/.code-sensei" PROFILE_FILE="$PROFILE_DIR/profile.json" PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$0")")}" QUIZ_BANK="$PLUGIN_ROOT/data/quiz-bank.json" +# Load shared error handling +LIB_DIR="$(dirname "$0")/lib" +if [ -f "$LIB_DIR/error-handling.sh" ]; then + source "$LIB_DIR/error-handling.sh" +else + LOG_FILE="${PROFILE_DIR}/error.log" + log_error() { printf '[%s] [%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date '+%Y-%m-%d')" "${1:-unknown}" "$2" >> "$LOG_FILE" 2>/dev/null; } + json_escape() { + local str="$1" + if command -v jq &>/dev/null; then + printf '%s' "$str" | jq -Rs '.' + else + printf '"%s"' "$(printf '%s' "$str" | sed 's/\\/\\\\/g; s/"/\\"/g')" + fi + } + check_jq() { command -v jq &>/dev/null; } +fi + # Default output if we can't determine anything DEFAULT_OUTPUT='{"mode":"dynamic","concept":null,"reason":"No profile data available","static_question":null,"belt":"white","quiz_format":"multiple_choice"}' -if ! command -v jq &> /dev/null; then +if ! check_jq "$SCRIPT_NAME"; then echo "$DEFAULT_OUTPUT" exit 0 fi @@ -33,12 +52,42 @@ if [ ! -f "$PROFILE_FILE" ]; then fi # Read profile data -BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE") -QUIZ_HISTORY=$(jq -c '.quiz_history // []' "$PROFILE_FILE") -CONCEPTS_SEEN=$(jq -c '.concepts_seen // []' "$PROFILE_FILE") -SESSION_CONCEPTS=$(jq -c '.session_concepts // []' "$PROFILE_FILE") -TOTAL_QUIZZES=$(jq -r '.quizzes.total // 0' "$PROFILE_FILE") -CORRECT_QUIZZES=$(jq -r '.quizzes.correct // 0' "$PROFILE_FILE") +BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading belt: $BELT" + echo "$DEFAULT_OUTPUT" + exit 0 +fi + +QUIZ_HISTORY=$(jq -c '.quiz_history // []' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading quiz_history: $QUIZ_HISTORY" + QUIZ_HISTORY="[]" +fi + +CONCEPTS_SEEN=$(jq -c '.concepts_seen // []' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading concepts_seen: $CONCEPTS_SEEN" + CONCEPTS_SEEN="[]" +fi + +SESSION_CONCEPTS=$(jq -c '.session_concepts // []' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading session_concepts: $SESSION_CONCEPTS" + SESSION_CONCEPTS="[]" +fi + +TOTAL_QUIZZES=$(jq -r '.quizzes.total // 0' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading quizzes.total: $TOTAL_QUIZZES" + TOTAL_QUIZZES=0 +fi + +CORRECT_QUIZZES=$(jq -r '.quizzes.correct // 0' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading quizzes.correct: $CORRECT_QUIZZES" + CORRECT_QUIZZES=0 +fi TODAY=$(date -u +%Y-%m-%d) NOW_EPOCH=$(date +%s) @@ -65,7 +114,7 @@ SPACED_REP_REASON="" if [ "$QUIZ_HISTORY" != "[]" ]; then # Get concepts that were answered incorrectly, with their last wrong date and wrong count - WRONG_CONCEPTS=$(echo "$QUIZ_HISTORY" | jq -c ' + WRONG_CONCEPTS=$(printf '%s' "$QUIZ_HISTORY" | jq -c ' [.[] | select(.result == "incorrect")] | group_by(.concept) | map({ @@ -74,20 +123,39 @@ if [ "$QUIZ_HISTORY" != "[]" ]; then last_wrong: (sort_by(.timestamp) | last | .timestamp), total_attempts: 0 }) - ') + ' 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed computing wrong concepts: $WRONG_CONCEPTS" + WRONG_CONCEPTS="[]" + fi # For each wrong concept, check if it's due for review - for ROW in $(echo "$WRONG_CONCEPTS" | jq -c '.[]'); do - CONCEPT=$(echo "$ROW" | jq -r '.concept') - WRONG_COUNT=$(echo "$ROW" | jq -r '.wrong_count') - LAST_WRONG=$(echo "$ROW" | jq -r '.last_wrong') + for ROW in $(printf '%s' "$WRONG_CONCEPTS" | jq -c '.[]' 2>/dev/null); do + CONCEPT=$(printf '%s' "$ROW" | jq -r '.concept' 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading concept from wrong row: $CONCEPT" + continue + fi - # Calculate days since last wrong answer - LAST_WRONG_DATE=$(echo "$LAST_WRONG" | cut -d'T' -f1) + WRONG_COUNT=$(printf '%s' "$ROW" | jq -r '.wrong_count' 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading wrong_count: $WRONG_COUNT" + continue + fi + + LAST_WRONG=$(printf '%s' "$ROW" | jq -r '.last_wrong' 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading last_wrong: $LAST_WRONG" + continue + fi + + # Calculate days since last wrong answer (handles both GNU and BSD date) + LAST_WRONG_DATE=$(printf '%s' "$LAST_WRONG" | cut -d'T' -f1) LAST_EPOCH=$(date -j -f "%Y-%m-%d" "$LAST_WRONG_DATE" +%s 2>/dev/null || date -d "$LAST_WRONG_DATE" +%s 2>/dev/null) if [ -n "$LAST_EPOCH" ]; then DAYS_SINCE=$(( (NOW_EPOCH - LAST_EPOCH) / 86400 )) else + log_error "$SCRIPT_NAME" "Could not parse date '$LAST_WRONG_DATE' for spaced repetition; defaulting days_since=999" DAYS_SINCE=999 fi @@ -100,9 +168,13 @@ if [ "$QUIZ_HISTORY" != "[]" ]; then fi # Check if enough time has passed and concept hasn't been mastered since - CORRECT_SINCE=$(echo "$QUIZ_HISTORY" | jq --arg c "$CONCEPT" --arg lw "$LAST_WRONG" ' + CORRECT_SINCE=$(printf '%s' "$QUIZ_HISTORY" | jq --arg c "$CONCEPT" --arg lw "$LAST_WRONG" ' [.[] | select(.concept == $c and .result == "correct" and .timestamp > $lw)] | length - ') + ' 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed computing correct_since for $CONCEPT: $CORRECT_SINCE" + CORRECT_SINCE=0 + fi if [ "$DAYS_SINCE" -ge "$REVIEW_INTERVAL" ] && [ "$CORRECT_SINCE" -lt 3 ]; then SPACED_REP_CONCEPT="$CONCEPT" @@ -118,23 +190,36 @@ if [ -n "$SPACED_REP_CONCEPT" ] && [ -f "$QUIZ_BANK" ]; then .quizzes[$concept] // [] | map(select(.belt == $belt or .belt == "white")) | first // null - ' "$QUIZ_BANK") + ' "$QUIZ_BANK" 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading static question for $SPACED_REP_CONCEPT: $STATIC_Q" + STATIC_Q="null" + fi + + ESCAPED_CONCEPT=$(json_escape "$SPACED_REP_CONCEPT") + ESCAPED_REASON=$(json_escape "$SPACED_REP_REASON") + ESCAPED_BELT=$(json_escape "$BELT") if [ "$STATIC_Q" != "null" ] && [ -n "$STATIC_Q" ]; then - echo "{\"mode\":\"spaced_repetition\",\"concept\":\"$SPACED_REP_CONCEPT\",\"reason\":\"$SPACED_REP_REASON\",\"static_question\":$STATIC_Q,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}" - exit 0 + printf '{"mode":"spaced_repetition","concept":%s,"reason":%s,"static_question":%s,"belt":%s,"quiz_format":"%s"}\n' \ + "$ESCAPED_CONCEPT" "$ESCAPED_REASON" "$STATIC_Q" "$ESCAPED_BELT" "$QUIZ_FORMAT" else - echo "{\"mode\":\"spaced_repetition\",\"concept\":\"$SPACED_REP_CONCEPT\",\"reason\":\"$SPACED_REP_REASON\",\"static_question\":null,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}" - exit 0 + printf '{"mode":"spaced_repetition","concept":%s,"reason":%s,"static_question":null,"belt":%s,"quiz_format":"%s"}\n' \ + "$ESCAPED_CONCEPT" "$ESCAPED_REASON" "$ESCAPED_BELT" "$QUIZ_FORMAT" fi + exit 0 fi # ─── PRIORITY 2: Unquizzed session concepts ─── # Concepts from this session that haven't been quizzed yet UNQUIZZED_CONCEPT="" if [ "$SESSION_CONCEPTS" != "[]" ]; then - for CONCEPT in $(echo "$SESSION_CONCEPTS" | jq -r '.[]'); do - BEEN_QUIZZED=$(echo "$QUIZ_HISTORY" | jq --arg c "$CONCEPT" '[.[] | select(.concept == $c)] | length') + for CONCEPT in $(printf '%s' "$SESSION_CONCEPTS" | jq -r '.[]' 2>/dev/null); do + BEEN_QUIZZED=$(printf '%s' "$QUIZ_HISTORY" | jq --arg c "$CONCEPT" '[.[] | select(.concept == $c)] | length' 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed checking quiz history for $CONCEPT: $BEEN_QUIZZED" + continue + fi if [ "$BEEN_QUIZZED" -eq 0 ]; then UNQUIZZED_CONCEPT="$CONCEPT" break @@ -147,15 +232,23 @@ if [ -n "$UNQUIZZED_CONCEPT" ] && [ -f "$QUIZ_BANK" ]; then .quizzes[$concept] // [] | map(select(.belt == $belt or .belt == "white")) | first // null - ' "$QUIZ_BANK") + ' "$QUIZ_BANK" 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading static question for $UNQUIZZED_CONCEPT: $STATIC_Q" + STATIC_Q="null" + fi + + ESCAPED_CONCEPT=$(json_escape "$UNQUIZZED_CONCEPT") + ESCAPED_BELT=$(json_escape "$BELT") if [ "$STATIC_Q" != "null" ] && [ -n "$STATIC_Q" ]; then - echo "{\"mode\":\"static\",\"concept\":\"$UNQUIZZED_CONCEPT\",\"reason\":\"New concept from this session — not yet quizzed.\",\"static_question\":$STATIC_Q,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}" - exit 0 + printf '{"mode":"static","concept":%s,"reason":"New concept from this session — not yet quizzed.","static_question":%s,"belt":%s,"quiz_format":"%s"}\n' \ + "$ESCAPED_CONCEPT" "$STATIC_Q" "$ESCAPED_BELT" "$QUIZ_FORMAT" else - echo "{\"mode\":\"dynamic\",\"concept\":\"$UNQUIZZED_CONCEPT\",\"reason\":\"New concept from this session — no static question available, generate dynamically.\",\"static_question\":null,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}" - exit 0 + printf '{"mode":"dynamic","concept":%s,"reason":"New concept from this session — no static question available, generate dynamically.","static_question":null,"belt":%s,"quiz_format":"%s"}\n' \ + "$ESCAPED_CONCEPT" "$ESCAPED_BELT" "$QUIZ_FORMAT" fi + exit 0 fi # ─── PRIORITY 3: Least-quizzed lifetime concepts ─── @@ -166,7 +259,11 @@ if [ "$CONCEPTS_SEEN" != "[]" ]; then .[] as $concept | ($history | [.[] | select(.concept == $concept)] | length) as $count | {concept: $concept, count: $count} - ' <<< "$CONCEPTS_SEEN" | jq -s 'sort_by(.count) | first | .concept // null' 2>/dev/null) + ' <<< "$CONCEPTS_SEEN" 2>&1 | jq -s 'sort_by(.count) | first | .concept // null' 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed computing least-quizzed concept: $LEAST_QUIZZED" + LEAST_QUIZZED="" + fi fi if [ -n "$LEAST_QUIZZED" ] && [ "$LEAST_QUIZZED" != "null" ] && [ -f "$QUIZ_BANK" ]; then @@ -174,12 +271,22 @@ if [ -n "$LEAST_QUIZZED" ] && [ "$LEAST_QUIZZED" != "null" ] && [ -f "$QUIZ_BANK .quizzes[$concept] // [] | map(select(.belt == $belt or .belt == "white")) | first // null - ' "$QUIZ_BANK") + ' "$QUIZ_BANK" 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading static question for $LEAST_QUIZZED: $STATIC_Q" + STATIC_Q="null" + fi + + ESCAPED_CONCEPT=$(json_escape "$LEAST_QUIZZED") + ESCAPED_BELT=$(json_escape "$BELT") - echo "{\"mode\":\"static\",\"concept\":\"$LEAST_QUIZZED\",\"reason\":\"Reinforcing least-practiced concept.\",\"static_question\":$STATIC_Q,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}" + printf '{"mode":"static","concept":%s,"reason":"Reinforcing least-practiced concept.","static_question":%s,"belt":%s,"quiz_format":"%s"}\n' \ + "$ESCAPED_CONCEPT" "$STATIC_Q" "$ESCAPED_BELT" "$QUIZ_FORMAT" exit 0 fi # ─── FALLBACK: Dynamic generation ─── -echo "{\"mode\":\"dynamic\",\"concept\":null,\"reason\":\"No specific concept to target — generate from current session context.\",\"static_question\":null,\"belt\":\"$BELT\",\"quiz_format\":\"$QUIZ_FORMAT\"}" +ESCAPED_BELT=$(json_escape "$BELT") +printf '{"mode":"dynamic","concept":null,"reason":"No specific concept to target — generate from current session context.","static_question":null,"belt":%s,"quiz_format":"%s"}\n' \ + "$ESCAPED_BELT" "$QUIZ_FORMAT" exit 0 diff --git a/scripts/session-start.sh b/scripts/session-start.sh index 5506f5a..ad90e7b 100755 --- a/scripts/session-start.sh +++ b/scripts/session-start.sh @@ -2,22 +2,43 @@ # CodeSensei — Session Start Hook # Loads user profile and updates streak on each Claude Code session start +SCRIPT_NAME="session-start" PROFILE_DIR="$HOME/.code-sensei" PROFILE_FILE="$PROFILE_DIR/profile.json" SESSION_LOG="$PROFILE_DIR/sessions.log" TODAY=$(date -u +%Y-%m-%d) +# Load shared error handling +# shellcheck source=lib/error-handling.sh +LIB_DIR="$(dirname "$0")/lib" +if [ -f "$LIB_DIR/error-handling.sh" ]; then + source "$LIB_DIR/error-handling.sh" +else + # Minimal inline fallback if lib is missing + LOG_FILE="${PROFILE_DIR}/error.log" + log_error() { printf '[%s] [%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date '+%Y-%m-%d')" "${1:-unknown}" "$2" >> "$LOG_FILE" 2>/dev/null; } + json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } + check_jq() { command -v jq &>/dev/null; } +fi + # Create profile directory if it doesn't exist -mkdir -p "$PROFILE_DIR" +if ! mkdir -p "$PROFILE_DIR" 2>&1 | grep -q .; then + : # directory created or already exists +fi +if [ ! -d "$PROFILE_DIR" ]; then + log_error "$SCRIPT_NAME" "Failed to create profile directory: $PROFILE_DIR" + exit 0 +fi # Create default profile if none exists if [ ! -f "$PROFILE_FILE" ]; then - cat > "$PROFILE_FILE" << PROFILE + CREATED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) + if ! cat > "$PROFILE_FILE" << PROFILE { "version": "1.0.0", "plugin": "code-sensei", "brand": "Dojo Coding", - "created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "created_at": "$CREATED_AT", "belt": "white", "xp": 0, "streak": { @@ -45,7 +66,7 @@ if [ ! -f "$PROFILE_FILE" ]; then "id": "first-session", "name": "First Steps", "description": "Started your first CodeSensei session", - "earned_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + "earned_at": "$CREATED_AT" } ], "preferences": { @@ -56,66 +77,107 @@ if [ ! -f "$PROFILE_FILE" ]; then "session_concepts": [] } PROFILE - echo "🥋 Welcome to CodeSensei by Dojo Coding! Use /code-sensei:progress to get started." + then + log_error "$SCRIPT_NAME" "Failed to write default profile to $PROFILE_FILE" + exit 0 + fi + echo "Welcome to CodeSensei by Dojo Coding! Use /code-sensei:progress to get started." exit 0 fi # Profile exists — update streak and session count -if command -v jq &> /dev/null; then - LAST_SESSION=$(jq -r '.streak.last_session_date // ""' "$PROFILE_FILE") - CURRENT_STREAK=$(jq -r '.streak.current // 0' "$PROFILE_FILE") - LONGEST_STREAK=$(jq -r '.streak.longest // 0' "$PROFILE_FILE") - TOTAL_SESSIONS=$(jq -r '.sessions.total // 0' "$PROFILE_FILE") - - # Calculate streak - if [ "$LAST_SESSION" = "$TODAY" ]; then - # Already logged today, no streak change - NEW_STREAK=$CURRENT_STREAK - elif [ -n "$LAST_SESSION" ]; then - # Check if last session was yesterday - YESTERDAY=$(date -u -d "yesterday" +%Y-%m-%d 2>/dev/null || date -u -v-1d +%Y-%m-%d 2>/dev/null) - if [ "$LAST_SESSION" = "$YESTERDAY" ]; then - NEW_STREAK=$((CURRENT_STREAK + 1)) - else - NEW_STREAK=1 - fi +if ! check_jq "$SCRIPT_NAME"; then + exit 0 +fi + +LAST_SESSION=$(jq -r '.streak.last_session_date // ""' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading last_session_date: $LAST_SESSION" + exit 0 +fi + +CURRENT_STREAK=$(jq -r '.streak.current // 0' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading streak.current: $CURRENT_STREAK" + exit 0 +fi + +LONGEST_STREAK=$(jq -r '.streak.longest // 0' "$PROFILE_FILE" 2>&1) +TOTAL_SESSIONS=$(jq -r '.sessions.total // 0' "$PROFILE_FILE" 2>&1) + +# Calculate streak +if [ "$LAST_SESSION" = "$TODAY" ]; then + # Already logged today, no streak change + NEW_STREAK=$CURRENT_STREAK +elif [ -n "$LAST_SESSION" ]; then + # Check if last session was yesterday (handles both GNU and BSD date) + YESTERDAY=$(date -u -d "yesterday" +%Y-%m-%d 2>/dev/null || date -u -v-1d +%Y-%m-%d 2>/dev/null) + if [ -z "$YESTERDAY" ]; then + log_error "$SCRIPT_NAME" "Failed to compute yesterday's date; resetting streak to 1" + YESTERDAY="" + fi + if [ "$LAST_SESSION" = "$YESTERDAY" ]; then + NEW_STREAK=$((CURRENT_STREAK + 1)) else NEW_STREAK=1 fi +else + NEW_STREAK=1 +fi - # Update longest streak - if [ "$NEW_STREAK" -gt "$LONGEST_STREAK" ]; then - NEW_LONGEST=$NEW_STREAK - else - NEW_LONGEST=$LONGEST_STREAK - fi +# Update longest streak +if [ "$NEW_STREAK" -gt "$LONGEST_STREAK" ]; then + NEW_LONGEST=$NEW_STREAK +else + NEW_LONGEST=$LONGEST_STREAK +fi - # Update profile - UPDATED=$(jq \ - --arg today "$TODAY" \ - --argjson streak "$NEW_STREAK" \ - --argjson longest "$NEW_LONGEST" \ - --argjson sessions "$((TOTAL_SESSIONS + 1))" \ - '.streak.current = $streak | - .streak.longest = $longest | - .streak.last_session_date = $today | - .sessions.total = $sessions | - .sessions.last_session = $today | - .session_concepts = []' \ - "$PROFILE_FILE") - - echo "$UPDATED" > "$PROFILE_FILE" - - # Log session - echo "$TODAY $(date -u +%H:%M:%S) session_start" >> "$SESSION_LOG" - - # Show streak info if notable - BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE") - XP=$(jq -r '.xp // 0' "$PROFILE_FILE") - - if [ "$NEW_STREAK" -ge 7 ] && [ "$NEW_STREAK" != "$CURRENT_STREAK" ]; then - echo "🔥 $NEW_STREAK-day streak! Consistency is the Dojo Way." - fi +# Update profile atomically (temp file + mv) +UPDATED=$(jq \ + --arg today "$TODAY" \ + --argjson streak "$NEW_STREAK" \ + --argjson longest "$NEW_LONGEST" \ + --argjson sessions "$((TOTAL_SESSIONS + 1))" \ + '.streak.current = $streak | + .streak.longest = $longest | + .streak.last_session_date = $today | + .sessions.total = $sessions | + .sessions.last_session = $today | + .session_concepts = []' \ + "$PROFILE_FILE" 2>&1) + +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed updating profile: $UPDATED" + exit 0 +fi + +TMPFILE=$(mktemp "${PROFILE_FILE}.XXXXXX" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "mktemp failed: $TMPFILE" + exit 0 +fi + +printf '%s\n' "$UPDATED" > "$TMPFILE" && mv "$TMPFILE" "$PROFILE_FILE" +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "Failed to write updated profile (atomic mv)" + rm -f "$TMPFILE" + exit 0 +fi + +# Log session +if ! printf '%s %s session_start\n' "$TODAY" "$(date -u +%H:%M:%S)" >> "$SESSION_LOG" 2>&1; then + log_error "$SCRIPT_NAME" "Failed to write to session log: $SESSION_LOG" +fi + +# Show streak info if notable +BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading belt after profile update: $BELT" + BELT="white" +fi + +if [ "$NEW_STREAK" -ge 7 ] && [ "$NEW_STREAK" != "$CURRENT_STREAK" ]; then + echo "${NEW_STREAK}-day streak! Consistency is the Dojo Way." fi exit 0 diff --git a/scripts/session-stop.sh b/scripts/session-stop.sh index 7a20e9e..3dd8861 100755 --- a/scripts/session-stop.sh +++ b/scripts/session-stop.sh @@ -2,33 +2,85 @@ # CodeSensei — Session Stop Hook # Saves session data and shows a mini-recap prompt +SCRIPT_NAME="session-stop" PROFILE_DIR="$HOME/.code-sensei" PROFILE_FILE="$PROFILE_DIR/profile.json" SESSION_LOG="$PROFILE_DIR/sessions.log" TODAY=$(date -u +%Y-%m-%d) +# Load shared error handling +LIB_DIR="$(dirname "$0")/lib" +if [ -f "$LIB_DIR/error-handling.sh" ]; then + source "$LIB_DIR/error-handling.sh" +else + LOG_FILE="${PROFILE_DIR}/error.log" + log_error() { printf '[%s] [%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date '+%Y-%m-%d')" "${1:-unknown}" "$2" >> "$LOG_FILE" 2>/dev/null; } + json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } + check_jq() { command -v jq &>/dev/null; } +fi + if [ ! -f "$PROFILE_FILE" ]; then exit 0 fi -if command -v jq &> /dev/null; then - # Count concepts learned this session - SESSION_CONCEPTS=$(jq -r '.session_concepts | length // 0' "$PROFILE_FILE") - XP=$(jq -r '.xp // 0' "$PROFILE_FILE") - BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE") - STREAK=$(jq -r '.streak.current // 0' "$PROFILE_FILE") +if ! check_jq "$SCRIPT_NAME"; then + exit 0 +fi + +# Count concepts learned this session +SESSION_CONCEPTS=$(jq -r '.session_concepts | length // 0' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading session_concepts length: $SESSION_CONCEPTS" + SESSION_CONCEPTS=0 +fi - # Log session end - echo "$TODAY $(date -u +%H:%M:%S) session_stop concepts=$SESSION_CONCEPTS xp=$XP belt=$BELT" >> "$SESSION_LOG" +XP=$(jq -r '.xp // 0' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading xp: $XP" + XP=0 +fi + +BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading belt: $BELT" + BELT="white" +fi + +STREAK=$(jq -r '.streak.current // 0' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading streak: $STREAK" + STREAK=0 +fi - # Clear session-specific data - UPDATED=$(jq '.session_concepts = []' "$PROFILE_FILE") - echo "$UPDATED" > "$PROFILE_FILE" +# Log session end +if ! printf '%s %s session_stop concepts=%s xp=%s belt=%s\n' \ + "$TODAY" "$(date -u +%H:%M:%S)" "$SESSION_CONCEPTS" "$XP" "$BELT" >> "$SESSION_LOG" 2>&1; then + log_error "$SCRIPT_NAME" "Failed to write to session log: $SESSION_LOG" +fi + +# Clear session-specific data atomically +UPDATED=$(jq '.session_concepts = []' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed clearing session_concepts: $UPDATED" + exit 0 +fi + +TMPFILE=$(mktemp "${PROFILE_FILE}.XXXXXX" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "mktemp failed: $TMPFILE" + exit 0 +fi + +printf '%s\n' "$UPDATED" > "$TMPFILE" && mv "$TMPFILE" "$PROFILE_FILE" +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "Failed to write updated profile (atomic mv)" + rm -f "$TMPFILE" + exit 0 +fi - # Show gentle reminder if they learned things but didn't recap - if [ "$SESSION_CONCEPTS" -gt 0 ]; then - echo "🥋 You encountered $SESSION_CONCEPTS new concepts this session! Use /code-sensei:recap next time for a full summary." - fi +# Show gentle reminder if they learned things but didn't recap +if [ "$SESSION_CONCEPTS" -gt 0 ]; then + echo "You encountered $SESSION_CONCEPTS new concepts this session! Use /code-sensei:recap next time for a full summary." fi exit 0 diff --git a/scripts/track-code-change.sh b/scripts/track-code-change.sh index 90d9f80..63b4629 100755 --- a/scripts/track-code-change.sh +++ b/scripts/track-code-change.sh @@ -3,77 +3,162 @@ # Records what files Claude creates or modifies for contextual teaching # This data is used by /explain and /recap to know what happened +SCRIPT_NAME="track-code-change" PROFILE_DIR="$HOME/.code-sensei" PROFILE_FILE="$PROFILE_DIR/profile.json" CHANGES_LOG="$PROFILE_DIR/session-changes.jsonl" +# Load shared error handling +LIB_DIR="$(dirname "$0")/lib" +if [ -f "$LIB_DIR/error-handling.sh" ]; then + source "$LIB_DIR/error-handling.sh" +else + LOG_FILE="${PROFILE_DIR}/error.log" + log_error() { printf '[%s] [%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date '+%Y-%m-%d')" "${1:-unknown}" "$2" >> "$LOG_FILE" 2>/dev/null; } + json_escape() { + local str="$1" + if command -v jq &>/dev/null; then + printf '%s' "$str" | jq -Rs '.' + else + printf '"%s"' "$(printf '%s' "$str" | sed 's/\\/\\\\/g; s/"/\\"/g')" + fi + } + check_jq() { command -v jq &>/dev/null; } +fi + # Read hook input from stdin INPUT=$(cat) if [ ! -d "$PROFILE_DIR" ]; then - mkdir -p "$PROFILE_DIR" + if ! mkdir -p "$PROFILE_DIR" 2>&1; then + log_error "$SCRIPT_NAME" "Failed to create profile directory: $PROFILE_DIR" + exit 0 + fi fi -if command -v jq &> /dev/null; then - # Extract file path and tool info from hook input - TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "unknown"') - FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.filePath // "unknown"') - TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) - - # Detect file type/technology for concept mapping - EXTENSION="${FILE_PATH##*.}" - TECH="" - case "$EXTENSION" in - html|htm) TECH="html" ;; - css|scss|sass|less) TECH="css" ;; - js|mjs) TECH="javascript" ;; - jsx) TECH="react" ;; - ts|tsx) TECH="typescript" ;; - py) TECH="python" ;; - sql) TECH="sql" ;; - json) TECH="json" ;; - md|mdx) TECH="markdown" ;; - sh|bash) TECH="shell" ;; - yaml|yml) TECH="yaml" ;; - toml) TECH="toml" ;; - env) TECH="environment-variables" ;; - dockerfile|Dockerfile) TECH="docker" ;; - *) TECH="other" ;; - esac - - # Log the change - echo "{\"timestamp\":\"$TIMESTAMP\",\"tool\":\"$TOOL_NAME\",\"file\":\"$FILE_PATH\",\"extension\":\"$EXTENSION\",\"tech\":\"$TECH\"}" >> "$CHANGES_LOG" - - # Track technology in session concepts if it's new - IS_FIRST_EVER="false" - if [ -f "$PROFILE_FILE" ] && [ "$TECH" != "other" ]; then - ALREADY_SEEN=$(jq --arg tech "$TECH" '.session_concepts | index($tech)' "$PROFILE_FILE") - if [ "$ALREADY_SEEN" = "null" ]; then - UPDATED=$(jq --arg tech "$TECH" '.session_concepts += [$tech]' "$PROFILE_FILE") - echo "$UPDATED" > "$PROFILE_FILE" - fi +if ! check_jq "$SCRIPT_NAME"; then + exit 0 +fi + +# Extract file path and tool info from hook input +TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // "unknown"' 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading tool_name: $TOOL_NAME" + TOOL_NAME="unknown" +fi + +FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // .tool_input.filePath // "unknown"' 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading file_path: $FILE_PATH" + FILE_PATH="unknown" +fi + +TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +# Detect file type/technology for concept mapping +EXTENSION="${FILE_PATH##*.}" +TECH="" +case "$EXTENSION" in + html|htm) TECH="html" ;; + css|scss|sass|less) TECH="css" ;; + js|mjs) TECH="javascript" ;; + jsx) TECH="react" ;; + ts|tsx) TECH="typescript" ;; + py) TECH="python" ;; + sql) TECH="sql" ;; + json) TECH="json" ;; + md|mdx) TECH="markdown" ;; + sh|bash) TECH="shell" ;; + yaml|yml) TECH="yaml" ;; + toml) TECH="toml" ;; + env) TECH="environment-variables" ;; + dockerfile|Dockerfile) TECH="docker" ;; + *) TECH="other" ;; +esac - # Also add to lifetime concepts_seen if new — and flag for micro-lesson - LIFETIME_SEEN=$(jq --arg tech "$TECH" '.concepts_seen | index($tech)' "$PROFILE_FILE") - if [ "$LIFETIME_SEEN" = "null" ]; then - UPDATED=$(jq --arg tech "$TECH" '.concepts_seen += [$tech]' "$PROFILE_FILE") - echo "$UPDATED" > "$PROFILE_FILE" - IS_FIRST_EVER="true" +# Log the change (escape dynamic values for JSON safety) +SAFE_FILE_PATH=$(printf '%s' "$FILE_PATH" | sed 's/\\/\\\\/g; s/"/\\"/g') +SAFE_TOOL_NAME=$(printf '%s' "$TOOL_NAME" | sed 's/\\/\\\\/g; s/"/\\"/g') +if ! printf '{"timestamp":"%s","tool":"%s","file":"%s","extension":"%s","tech":"%s"}\n' \ + "$TIMESTAMP" "$SAFE_TOOL_NAME" "$SAFE_FILE_PATH" "$EXTENSION" "$TECH" >> "$CHANGES_LOG" 2>&1; then + log_error "$SCRIPT_NAME" "Failed to write to changes log: $CHANGES_LOG" +fi + +# Track technology in session concepts if it's new +IS_FIRST_EVER="false" +if [ -f "$PROFILE_FILE" ] && [ "$TECH" != "other" ]; then + ALREADY_SEEN=$(jq --arg tech "$TECH" '.session_concepts | index($tech)' "$PROFILE_FILE" 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed checking session_concepts for $TECH: $ALREADY_SEEN" + ALREADY_SEEN="0" + fi + + if [ "$ALREADY_SEEN" = "null" ]; then + UPDATED=$(jq --arg tech "$TECH" '.session_concepts += [$tech]' "$PROFILE_FILE" 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed appending to session_concepts: $UPDATED" + else + TMPFILE=$(mktemp "${PROFILE_FILE}.XXXXXX" 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "mktemp failed: $TMPFILE" + else + printf '%s\n' "$UPDATED" > "$TMPFILE" && mv "$TMPFILE" "$PROFILE_FILE" + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "Failed atomic profile write (session_concepts)" + rm -f "$TMPFILE" + fi + fi fi fi - # Always inject teaching context after code changes - BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE" 2>/dev/null || echo "white") + # Also add to lifetime concepts_seen if new — and flag for micro-lesson + LIFETIME_SEEN=$(jq --arg tech "$TECH" '.concepts_seen | index($tech)' "$PROFILE_FILE" 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed checking concepts_seen for $TECH: $LIFETIME_SEEN" + LIFETIME_SEEN="0" + fi - if [ "$IS_FIRST_EVER" = "true" ]; then - # First-time encounter: micro-lesson about the technology - CONTEXT="🥋 CodeSensei micro-lesson trigger: The user just encountered '$TECH' for the FIRST TIME (file: $FILE_PATH). Their belt level is '$BELT'. Provide a brief 2-sentence explanation of what $TECH is and why it matters for their project. Adapt language to their belt level. Keep it concise and non-intrusive — weave it naturally into your response, don't stop everything for a lecture." - else - # Already-seen technology: inline insight about the specific change - CONTEXT="🥋 CodeSensei inline insight: Claude just used '$TOOL_NAME' on '$FILE_PATH' ($TECH). The user's belt level is '$BELT'. Provide a brief 1-2 sentence explanation of what this change does and why, adapted to their belt level. Keep it natural and non-intrusive — weave it into your response as a quick teaching moment." + if [ "$LIFETIME_SEEN" = "null" ]; then + UPDATED=$(jq --arg tech "$TECH" '.concepts_seen += [$tech]' "$PROFILE_FILE" 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed appending to concepts_seen: $UPDATED" + else + TMPFILE=$(mktemp "${PROFILE_FILE}.XXXXXX" 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "mktemp failed: $TMPFILE" + else + printf '%s\n' "$UPDATED" > "$TMPFILE" && mv "$TMPFILE" "$PROFILE_FILE" + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "Failed atomic profile write (concepts_seen)" + rm -f "$TMPFILE" + else + IS_FIRST_EVER="true" + fi + fi + fi fi +fi - echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"$CONTEXT\"}}" +# Always inject teaching context after code changes +BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading belt: $BELT" + BELT="white" fi +# Use json_escape for all dynamic strings embedded in the context JSON value +ESCAPED_TECH=$(json_escape "$TECH") +ESCAPED_FILE_PATH=$(json_escape "$FILE_PATH") +ESCAPED_TOOL_NAME=$(json_escape "$TOOL_NAME") +ESCAPED_BELT=$(json_escape "$BELT") + +if [ "$IS_FIRST_EVER" = "true" ]; then + CONTEXT="CodeSensei micro-lesson trigger: The user just encountered ${ESCAPED_TECH} for the FIRST TIME (file: ${ESCAPED_FILE_PATH}). Their belt level is ${ESCAPED_BELT}. Provide a brief 2-sentence explanation of what ${ESCAPED_TECH} is and why it matters for their project. Adapt language to their belt level. Keep it concise and non-intrusive — weave it naturally into your response, don't stop everything for a lecture." +else + CONTEXT="CodeSensei inline insight: Claude just used ${ESCAPED_TOOL_NAME} on ${ESCAPED_FILE_PATH} (${ESCAPED_TECH}). The user's belt level is ${ESCAPED_BELT}. Provide a brief 1-2 sentence explanation of what this change does and why, adapted to their belt level. Keep it natural and non-intrusive — weave it into your response as a quick teaching moment." +fi + +# Build final output — context is already a JSON string from json_escape (with quotes) +printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":%s}}\n' "$CONTEXT" + exit 0 diff --git a/scripts/track-command.sh b/scripts/track-command.sh index 57134a8..60ec655 100755 --- a/scripts/track-command.sh +++ b/scripts/track-command.sh @@ -3,84 +3,161 @@ # Records what shell commands Claude runs for contextual teaching # Helps /explain and /recap know what tools/packages were used +SCRIPT_NAME="track-command" PROFILE_DIR="$HOME/.code-sensei" PROFILE_FILE="$PROFILE_DIR/profile.json" COMMANDS_LOG="$PROFILE_DIR/session-commands.jsonl" +# Load shared error handling +LIB_DIR="$(dirname "$0")/lib" +if [ -f "$LIB_DIR/error-handling.sh" ]; then + source "$LIB_DIR/error-handling.sh" +else + LOG_FILE="${PROFILE_DIR}/error.log" + log_error() { printf '[%s] [%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date '+%Y-%m-%d')" "${1:-unknown}" "$2" >> "$LOG_FILE" 2>/dev/null; } + json_escape() { + local str="$1" + if command -v jq &>/dev/null; then + printf '%s' "$str" | jq -Rs '.' + else + printf '"%s"' "$(printf '%s' "$str" | sed 's/\\/\\\\/g; s/"/\\"/g')" + fi + } + check_jq() { command -v jq &>/dev/null; } +fi + # Read hook input from stdin INPUT=$(cat) if [ ! -d "$PROFILE_DIR" ]; then - mkdir -p "$PROFILE_DIR" + if ! mkdir -p "$PROFILE_DIR" 2>&1; then + log_error "$SCRIPT_NAME" "Failed to create profile directory: $PROFILE_DIR" + exit 0 + fi fi -if command -v jq &> /dev/null; then - COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // "unknown"') - TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) - - # Detect what kind of command this is for concept tracking - CONCEPT="" - case "$COMMAND" in - *"npm install"*|*"npm i "*|*"yarn add"*|*"pnpm add"*) - CONCEPT="package-management" - # Extract package name for tracking - PACKAGE=$(echo "$COMMAND" | sed -E 's/.*(npm install|npm i|yarn add|pnpm add)[[:space:]]+([^[:space:]]+).*/\2/' | head -1) - ;; - *"pip install"*|*"pip3 install"*) - CONCEPT="package-management" - ;; - *"git "*) CONCEPT="git" ;; - *"docker "*) CONCEPT="docker" ;; - *"curl "*|*"wget "*) CONCEPT="http-requests" ;; - *"mkdir "*|*"touch "*|*"cp "*|*"mv "*|*"rm "*) CONCEPT="file-system" ;; - *"node "*|*"npx "*) CONCEPT="nodejs-runtime" ;; - *"python "*|*"python3 "*) CONCEPT="python-runtime" ;; - *"psql "*|*"mysql "*|*"sqlite3 "*) CONCEPT="database-cli" ;; - *"cd "*|*"ls "*|*"pwd"*) CONCEPT="terminal-navigation" ;; - *"chmod "*|*"chown "*) CONCEPT="permissions" ;; - *"ssh "*|*"scp "*) CONCEPT="remote-access" ;; - *"env "*|*"export "*) CONCEPT="environment-variables" ;; - *"test "*|*"jest "*|*"vitest "*|*"pytest "*) CONCEPT="testing" ;; - *) CONCEPT="" ;; - esac - - # Log the command - echo "{\"timestamp\":\"$TIMESTAMP\",\"command\":\"$(echo "$COMMAND" | head -c 200)\",\"concept\":\"$CONCEPT\"}" >> "$COMMANDS_LOG" - - # Track concept in session and lifetime if new and meaningful - IS_FIRST_EVER="false" - if [ -n "$CONCEPT" ] && [ -f "$PROFILE_FILE" ]; then - ALREADY_SEEN=$(jq --arg c "$CONCEPT" '.session_concepts | index($c)' "$PROFILE_FILE") - if [ "$ALREADY_SEEN" = "null" ]; then - UPDATED=$(jq --arg c "$CONCEPT" '.session_concepts += [$c]' "$PROFILE_FILE") - echo "$UPDATED" > "$PROFILE_FILE" - fi +if ! check_jq "$SCRIPT_NAME"; then + exit 0 +fi + +COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // "unknown"' 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading tool_input.command: $COMMAND" + COMMAND="unknown" +fi + +TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +# Detect what kind of command this is for concept tracking +CONCEPT="" +case "$COMMAND" in + *"npm install"*|*"npm i "*|*"yarn add"*|*"pnpm add"*) + CONCEPT="package-management" + # Extract package name for tracking + PACKAGE=$(printf '%s' "$COMMAND" | sed -E 's/.*(npm install|npm i|yarn add|pnpm add)[[:space:]]+([^[:space:]]+).*/\2/' | head -1) + ;; + *"pip install"*|*"pip3 install"*) + CONCEPT="package-management" + ;; + *"git "*) CONCEPT="git" ;; + *"docker "*) CONCEPT="docker" ;; + *"curl "*|*"wget "*) CONCEPT="http-requests" ;; + *"mkdir "*|*"touch "*|*"cp "*|*"mv "*|*"rm "*) CONCEPT="file-system" ;; + *"node "*|*"npx "*) CONCEPT="nodejs-runtime" ;; + *"python "*|*"python3 "*) CONCEPT="python-runtime" ;; + *"psql "*|*"mysql "*|*"sqlite3 "*) CONCEPT="database-cli" ;; + *"cd "*|*"ls "*|*"pwd"*) CONCEPT="terminal-navigation" ;; + *"chmod "*|*"chown "*) CONCEPT="permissions" ;; + *"ssh "*|*"scp "*) CONCEPT="remote-access" ;; + *"env "*|*"export "*) CONCEPT="environment-variables" ;; + *"test "*|*"jest "*|*"vitest "*|*"pytest "*) CONCEPT="testing" ;; + *) CONCEPT="" ;; +esac + +# Log the command (truncate to 200 chars, escape for JSON) +CMD_TRUNCATED=$(printf '%s' "$COMMAND" | head -c 200) +SAFE_CMD=$(printf '%s' "$CMD_TRUNCATED" | sed 's/\\/\\\\/g; s/"/\\"/g') +SAFE_CONCEPT=$(printf '%s' "$CONCEPT" | sed 's/\\/\\\\/g; s/"/\\"/g') +if ! printf '{"timestamp":"%s","command":"%s","concept":"%s"}\n' \ + "$TIMESTAMP" "$SAFE_CMD" "$SAFE_CONCEPT" >> "$COMMANDS_LOG" 2>&1; then + log_error "$SCRIPT_NAME" "Failed to write to commands log: $COMMANDS_LOG" +fi + +# Track concept in session and lifetime if new and meaningful +IS_FIRST_EVER="false" +if [ -n "$CONCEPT" ] && [ -f "$PROFILE_FILE" ]; then + ALREADY_SEEN=$(jq --arg c "$CONCEPT" '.session_concepts | index($c)' "$PROFILE_FILE" 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed checking session_concepts for $CONCEPT: $ALREADY_SEEN" + ALREADY_SEEN="0" + fi - LIFETIME_SEEN=$(jq --arg c "$CONCEPT" '.concepts_seen | index($c)' "$PROFILE_FILE") - if [ "$LIFETIME_SEEN" = "null" ]; then - UPDATED=$(jq --arg c "$CONCEPT" '.concepts_seen += [$c]' "$PROFILE_FILE") - echo "$UPDATED" > "$PROFILE_FILE" - IS_FIRST_EVER="true" + if [ "$ALREADY_SEEN" = "null" ]; then + UPDATED=$(jq --arg c "$CONCEPT" '.session_concepts += [$c]' "$PROFILE_FILE" 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed appending to session_concepts: $UPDATED" + else + TMPFILE=$(mktemp "${PROFILE_FILE}.XXXXXX" 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "mktemp failed: $TMPFILE" + else + printf '%s\n' "$UPDATED" > "$TMPFILE" && mv "$TMPFILE" "$PROFILE_FILE" + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "Failed atomic profile write (session_concepts)" + rm -f "$TMPFILE" + fi + fi fi fi - # Always inject teaching context after commands - BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE" 2>/dev/null || echo "white") - # Sanitize command for JSON (remove quotes and special chars) - SAFE_CMD=$(echo "$COMMAND" | head -c 80 | tr '"' "'" | tr '\\' '/') - - if [ "$IS_FIRST_EVER" = "true" ] && [ -n "$CONCEPT" ]; then - # First-time encounter: micro-lesson about the concept - CONTEXT="🥋 CodeSensei micro-lesson trigger: The user just encountered '$CONCEPT' for the FIRST TIME (command: $SAFE_CMD). Their belt level is '$BELT'. Provide a brief 2-sentence explanation of what $CONCEPT means and why it matters. Adapt language to their belt level. Keep it concise and non-intrusive." - elif [ -n "$CONCEPT" ]; then - # Already-seen concept: brief inline insight about this specific command - CONTEXT="🥋 CodeSensei inline insight: Claude just ran a '$CONCEPT' command ($SAFE_CMD). The user's belt level is '$BELT'. Provide a brief 1-sentence explanation of what this command does, adapted to their belt level. Keep it natural and non-intrusive." - else - # Unknown command type: still provide a brief hint - CONTEXT="🥋 CodeSensei inline insight: Claude just ran a shell command ($SAFE_CMD). The user's belt level is '$BELT'. If this command is educational, briefly explain what it does in 1 sentence. If trivial, skip the explanation." + LIFETIME_SEEN=$(jq --arg c "$CONCEPT" '.concepts_seen | index($c)' "$PROFILE_FILE" 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed checking concepts_seen for $CONCEPT: $LIFETIME_SEEN" + LIFETIME_SEEN="0" fi - echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"$CONTEXT\"}}" + if [ "$LIFETIME_SEEN" = "null" ]; then + UPDATED=$(jq --arg c "$CONCEPT" '.concepts_seen += [$c]' "$PROFILE_FILE" 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed appending to concepts_seen: $UPDATED" + else + TMPFILE=$(mktemp "${PROFILE_FILE}.XXXXXX" 2>&1) + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "mktemp failed: $TMPFILE" + else + printf '%s\n' "$UPDATED" > "$TMPFILE" && mv "$TMPFILE" "$PROFILE_FILE" + if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "Failed atomic profile write (concepts_seen)" + rm -f "$TMPFILE" + else + IS_FIRST_EVER="true" + fi + fi + fi + fi fi +# Always inject teaching context after commands +BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE" 2>&1) +if [ $? -ne 0 ]; then + log_error "$SCRIPT_NAME" "jq failed reading belt: $BELT" + BELT="white" +fi + +# Use json_escape for all dynamic strings embedded in the context JSON value +ESCAPED_CONCEPT=$(json_escape "$CONCEPT") +ESCAPED_CMD=$(json_escape "$(printf '%s' "$COMMAND" | head -c 80)") +ESCAPED_BELT=$(json_escape "$BELT") + +if [ "$IS_FIRST_EVER" = "true" ] && [ -n "$CONCEPT" ]; then + CONTEXT="CodeSensei micro-lesson trigger: The user just encountered ${ESCAPED_CONCEPT} for the FIRST TIME (command: ${ESCAPED_CMD}). Their belt level is ${ESCAPED_BELT}. Provide a brief 2-sentence explanation of what ${ESCAPED_CONCEPT} means and why it matters. Adapt language to their belt level. Keep it concise and non-intrusive." +elif [ -n "$CONCEPT" ]; then + CONTEXT="CodeSensei inline insight: Claude just ran a ${ESCAPED_CONCEPT} command (${ESCAPED_CMD}). The user's belt level is ${ESCAPED_BELT}. Provide a brief 1-sentence explanation of what this command does, adapted to their belt level. Keep it natural and non-intrusive." +else + CONTEXT="CodeSensei inline insight: Claude just ran a shell command (${ESCAPED_CMD}). The user's belt level is ${ESCAPED_BELT}. If this command is educational, briefly explain what it does in 1 sentence. If trivial, skip the explanation." +fi + +# Build final output — context is already a JSON string from json_escape (with quotes) +printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":%s}}\n' "$CONTEXT" + exit 0