Skip to content
Open
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
45 changes: 45 additions & 0 deletions scripts/lib/profile-io.sh
Original file line number Diff line number Diff line change
@@ -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: takes a jq filter, applies it in one pass.
# Usage: update_profile '.xp += 10'
# Requires jq to be installed; callers should guard with `command -v jq`.
update_profile() {
local filter="$1"
local current
current=$(read_profile)
echo "$current" | jq "$filter" | write_profile
}
31 changes: 14 additions & 17 deletions scripts/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<PROFILE | write_profile
{
"version": "1.0.0",
"plugin": "code-sensei",
Expand Down Expand Up @@ -56,7 +59,7 @@ if [ ! -f "$PROFILE_FILE" ]; then
"session_concepts": []
}
PROFILE
echo "🥋 Welcome to CodeSensei by Dojo Coding! Use /code-sensei:progress to get started."
echo "Welcome to CodeSensei by Dojo Coding! Use /code-sensei:progress to get started."
exit 0
fi

Expand Down Expand Up @@ -90,8 +93,8 @@ if command -v jq &> /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" \
Expand All @@ -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

Expand Down
16 changes: 9 additions & 7 deletions scripts/session-stop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
43 changes: 24 additions & 19 deletions scripts/track-code-change.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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\"}}"
Expand Down
46 changes: 25 additions & 21 deletions scripts/track-command.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"')
Expand All @@ -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"
Expand All @@ -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

Expand All @@ -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\"}}"
Expand Down