diff --git a/scripts/lib/profile-io.sh b/scripts/lib/profile-io.sh new file mode 100644 index 0000000..caff479 --- /dev/null +++ b/scripts/lib/profile-io.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Shared profile I/O library for CodeSensei +# Source this file in hook scripts to get atomic read/write helpers. +# Usage: source "${CLAUDE_PLUGIN_ROOT}/scripts/lib/profile-io.sh" + +PROFILE_DIR="${HOME}/.code-sensei" +PROFILE_FILE="${PROFILE_DIR}/profile.json" + +# Ensure profile directory exists +ensure_profile_dir() { + mkdir -p "$PROFILE_DIR" +} + +# Read profile, output to stdout. Returns empty object if missing. +read_profile() { + if [ -f "$PROFILE_FILE" ]; then + cat "$PROFILE_FILE" + else + echo '{}' + fi +} + +# Atomic write: takes JSON from stdin, writes to profile via temp+mv. +# Returns 1 if the temp file is empty (guards against writing a blank profile). +write_profile() { + ensure_profile_dir + local tmp_file + tmp_file=$(mktemp "${PROFILE_FILE}.tmp.XXXXXX") + if cat > "$tmp_file" && [ -s "$tmp_file" ]; then + mv "$tmp_file" "$PROFILE_FILE" + else + rm -f "$tmp_file" + return 1 + fi +} + +# Update profile atomically: forwards jq args and applies them in one pass. +# Usage: update_profile '.xp += 10' +# update_profile --arg tech "$TECH" '.concepts_seen += [$tech]' +# Requires jq to be installed; callers should guard with `command -v jq`. +update_profile() { + local current + current=$(read_profile) + printf '%s\n' "$current" | jq "$@" | write_profile +} diff --git a/scripts/session-start.sh b/scripts/session-start.sh index 5506f5a..07e1d2e 100755 --- a/scripts/session-start.sh +++ b/scripts/session-start.sh @@ -2,17 +2,20 @@ # CodeSensei — Session Start Hook # Loads user profile and updates streak on each Claude Code session start -PROFILE_DIR="$HOME/.code-sensei" -PROFILE_FILE="$PROFILE_DIR/profile.json" -SESSION_LOG="$PROFILE_DIR/sessions.log" +# Resolve lib path relative to this script's location (portable, no CLAUDE_PLUGIN_ROOT needed at source time) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib/profile-io.sh +source "${SCRIPT_DIR}/lib/profile-io.sh" + +SESSION_LOG="${PROFILE_DIR}/sessions.log" TODAY=$(date -u +%Y-%m-%d) -# Create profile directory if it doesn't exist -mkdir -p "$PROFILE_DIR" +# Ensure profile directory exists +ensure_profile_dir # Create default profile if none exists if [ ! -f "$PROFILE_FILE" ]; then - cat > "$PROFILE_FILE" << PROFILE + cat < /dev/null; then NEW_LONGEST=$LONGEST_STREAK fi - # Update profile - UPDATED=$(jq \ + # Update profile atomically in a single jq pass + update_profile \ --arg today "$TODAY" \ --argjson streak "$NEW_STREAK" \ --argjson longest "$NEW_LONGEST" \ @@ -101,20 +104,14 @@ if command -v jq &> /dev/null; then .streak.last_session_date = $today | .sessions.total = $sessions | .sessions.last_session = $today | - .session_concepts = []' \ - "$PROFILE_FILE") - - echo "$UPDATED" > "$PROFILE_FILE" + .session_concepts = []' # 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." + echo "$NEW_STREAK-day streak! Consistency is the Dojo Way." fi fi diff --git a/scripts/session-stop.sh b/scripts/session-stop.sh index 7a20e9e..42fa573 100755 --- a/scripts/session-stop.sh +++ b/scripts/session-stop.sh @@ -2,9 +2,12 @@ # CodeSensei — Session Stop Hook # Saves session data and shows a mini-recap prompt -PROFILE_DIR="$HOME/.code-sensei" -PROFILE_FILE="$PROFILE_DIR/profile.json" -SESSION_LOG="$PROFILE_DIR/sessions.log" +# Resolve lib path relative to this script's location +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib/profile-io.sh +source "${SCRIPT_DIR}/lib/profile-io.sh" + +SESSION_LOG="${PROFILE_DIR}/sessions.log" TODAY=$(date -u +%Y-%m-%d) if [ ! -f "$PROFILE_FILE" ]; then @@ -21,13 +24,12 @@ if command -v jq &> /dev/null; then # Log session end echo "$TODAY $(date -u +%H:%M:%S) session_stop concepts=$SESSION_CONCEPTS xp=$XP belt=$BELT" >> "$SESSION_LOG" - # Clear session-specific data - UPDATED=$(jq '.session_concepts = []' "$PROFILE_FILE") - echo "$UPDATED" > "$PROFILE_FILE" + # Clear session-specific data atomically + update_profile '.session_concepts = []' # 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." + echo "You encountered $SESSION_CONCEPTS new concepts this session! Use /code-sensei:recap next time for a full summary." fi fi diff --git a/scripts/track-code-change.sh b/scripts/track-code-change.sh index 90d9f80..90605e5 100755 --- a/scripts/track-code-change.sh +++ b/scripts/track-code-change.sh @@ -3,16 +3,17 @@ # Records what files Claude creates or modifies for contextual teaching # This data is used by /explain and /recap to know what happened -PROFILE_DIR="$HOME/.code-sensei" -PROFILE_FILE="$PROFILE_DIR/profile.json" -CHANGES_LOG="$PROFILE_DIR/session-changes.jsonl" +# Resolve lib path relative to this script's location +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib/profile-io.sh +source "${SCRIPT_DIR}/lib/profile-io.sh" + +CHANGES_LOG="${PROFILE_DIR}/session-changes.jsonl" # Read hook input from stdin INPUT=$(cat) -if [ ! -d "$PROFILE_DIR" ]; then - mkdir -p "$PROFILE_DIR" -fi +ensure_profile_dir if command -v jq &> /dev/null; then # Extract file path and tool info from hook input @@ -44,21 +45,25 @@ if command -v jq &> /dev/null; then # 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 + # Track technology in session and lifetime concepts — single atomic jq pass 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 + # Read current state once to determine what flags to set + ALREADY_IN_SESSION=$(jq --arg tech "$TECH" '.session_concepts | index($tech)' "$PROFILE_FILE") + ALREADY_IN_LIFETIME=$(jq --arg tech "$TECH" '.concepts_seen | index($tech)' "$PROFILE_FILE") - # 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" + if [ "$ALREADY_IN_LIFETIME" = "null" ]; then IS_FIRST_EVER="true" + # Add to both session_concepts and concepts_seen in one pass + update_profile --arg tech "$TECH" ' + .session_concepts += (if (.session_concepts | index($tech)) == null then [$tech] else [] end) | + .concepts_seen += (if (.concepts_seen | index($tech)) == null then [$tech] else [] end) + ' + elif [ "$ALREADY_IN_SESSION" = "null" ]; then + # New to session only — single pass + update_profile --arg tech "$TECH" ' + .session_concepts += [$tech] + ' fi fi @@ -67,10 +72,10 @@ if command -v jq &> /dev/null; then 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." + 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." + 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." fi echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"$CONTEXT\"}}" diff --git a/scripts/track-command.sh b/scripts/track-command.sh index 57134a8..5d7249c 100755 --- a/scripts/track-command.sh +++ b/scripts/track-command.sh @@ -3,16 +3,17 @@ # Records what shell commands Claude runs for contextual teaching # Helps /explain and /recap know what tools/packages were used -PROFILE_DIR="$HOME/.code-sensei" -PROFILE_FILE="$PROFILE_DIR/profile.json" -COMMANDS_LOG="$PROFILE_DIR/session-commands.jsonl" +# Resolve lib path relative to this script's location +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib/profile-io.sh +source "${SCRIPT_DIR}/lib/profile-io.sh" + +COMMANDS_LOG="${PROFILE_DIR}/session-commands.jsonl" # Read hook input from stdin INPUT=$(cat) -if [ ! -d "$PROFILE_DIR" ]; then - mkdir -p "$PROFILE_DIR" -fi +ensure_profile_dir if command -v jq &> /dev/null; then COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // "unknown"') @@ -23,8 +24,6 @@ if command -v jq &> /dev/null; then 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" @@ -47,20 +46,25 @@ if command -v jq &> /dev/null; then # 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 + # Track concept in session and lifetime — single atomic jq pass 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 + # Read current state once to determine what flags to set + ALREADY_IN_SESSION=$(jq --arg c "$CONCEPT" '.session_concepts | index($c)' "$PROFILE_FILE") + ALREADY_IN_LIFETIME=$(jq --arg c "$CONCEPT" '.concepts_seen | index($c)' "$PROFILE_FILE") - 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" + if [ "$ALREADY_IN_LIFETIME" = "null" ]; then IS_FIRST_EVER="true" + # Add to both session_concepts and concepts_seen in one atomic pass + update_profile --arg c "$CONCEPT" ' + .session_concepts += (if (.session_concepts | index($c)) == null then [$c] else [] end) | + .concepts_seen += (if (.concepts_seen | index($c)) == null then [$c] else [] end) + ' + elif [ "$ALREADY_IN_SESSION" = "null" ]; then + # New to session only — single atomic pass + update_profile --arg c "$CONCEPT" ' + .session_concepts += [$c] + ' fi fi @@ -71,13 +75,13 @@ if command -v jq &> /dev/null; then 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." + 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." + 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." + 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." fi echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"$CONTEXT\"}}"