Skip to content

Commit 026302f

Browse files
authored
Merge pull request #5 from DojoCodingLabs/daniel/doj-2428-atomic-writes
feat: atomic profile writes via shared lib (DOJ-2428)
2 parents e22d2a9 + 1cc9ae9 commit 026302f

File tree

5 files changed

+117
-64
lines changed

5 files changed

+117
-64
lines changed

scripts/lib/profile-io.sh

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env bash
2+
# Shared profile I/O library for CodeSensei
3+
# Source this file in hook scripts to get atomic read/write helpers.
4+
# Usage: source "${CLAUDE_PLUGIN_ROOT}/scripts/lib/profile-io.sh"
5+
6+
PROFILE_DIR="${HOME}/.code-sensei"
7+
PROFILE_FILE="${PROFILE_DIR}/profile.json"
8+
9+
# Ensure profile directory exists
10+
ensure_profile_dir() {
11+
mkdir -p "$PROFILE_DIR"
12+
}
13+
14+
# Read profile, output to stdout. Returns empty object if missing.
15+
read_profile() {
16+
if [ -f "$PROFILE_FILE" ]; then
17+
cat "$PROFILE_FILE"
18+
else
19+
echo '{}'
20+
fi
21+
}
22+
23+
# Atomic write: takes JSON from stdin, writes to profile via temp+mv.
24+
# Returns 1 if the temp file is empty (guards against writing a blank profile).
25+
write_profile() {
26+
ensure_profile_dir
27+
local tmp_file
28+
tmp_file=$(mktemp "${PROFILE_FILE}.tmp.XXXXXX")
29+
if cat > "$tmp_file" && [ -s "$tmp_file" ]; then
30+
mv "$tmp_file" "$PROFILE_FILE"
31+
else
32+
rm -f "$tmp_file"
33+
return 1
34+
fi
35+
}
36+
37+
# Update profile atomically: forwards jq args and applies them in one pass.
38+
# Usage: update_profile '.xp += 10'
39+
# update_profile --arg tech "$TECH" '.concepts_seen += [$tech]'
40+
# Requires jq to be installed; callers should guard with `command -v jq`.
41+
update_profile() {
42+
local current
43+
current=$(read_profile)
44+
printf '%s\n' "$current" | jq "$@" | write_profile
45+
}

scripts/session-start.sh

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@
22
# CodeSensei — Session Start Hook
33
# Loads user profile and updates streak on each Claude Code session start
44

5-
PROFILE_DIR="$HOME/.code-sensei"
6-
PROFILE_FILE="$PROFILE_DIR/profile.json"
7-
SESSION_LOG="$PROFILE_DIR/sessions.log"
5+
# Resolve lib path relative to this script's location (portable, no CLAUDE_PLUGIN_ROOT needed at source time)
6+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7+
# shellcheck source=lib/profile-io.sh
8+
source "${SCRIPT_DIR}/lib/profile-io.sh"
9+
10+
SESSION_LOG="${PROFILE_DIR}/sessions.log"
811
TODAY=$(date -u +%Y-%m-%d)
912

10-
# Create profile directory if it doesn't exist
11-
mkdir -p "$PROFILE_DIR"
13+
# Ensure profile directory exists
14+
ensure_profile_dir
1215

1316
# Create default profile if none exists
1417
if [ ! -f "$PROFILE_FILE" ]; then
15-
cat > "$PROFILE_FILE" << PROFILE
18+
cat <<PROFILE | write_profile
1619
{
1720
"version": "1.0.0",
1821
"plugin": "code-sensei",
@@ -56,7 +59,7 @@ if [ ! -f "$PROFILE_FILE" ]; then
5659
"session_concepts": []
5760
}
5861
PROFILE
59-
echo "🥋 Welcome to CodeSensei by Dojo Coding! Use /code-sensei:progress to get started."
62+
echo "Welcome to CodeSensei by Dojo Coding! Use /code-sensei:progress to get started."
6063
exit 0
6164
fi
6265

@@ -90,8 +93,8 @@ if command -v jq &> /dev/null; then
9093
NEW_LONGEST=$LONGEST_STREAK
9194
fi
9295

93-
# Update profile
94-
UPDATED=$(jq \
96+
# Update profile atomically in a single jq pass
97+
update_profile \
9598
--arg today "$TODAY" \
9699
--argjson streak "$NEW_STREAK" \
97100
--argjson longest "$NEW_LONGEST" \
@@ -101,20 +104,14 @@ if command -v jq &> /dev/null; then
101104
.streak.last_session_date = $today |
102105
.sessions.total = $sessions |
103106
.sessions.last_session = $today |
104-
.session_concepts = []' \
105-
"$PROFILE_FILE")
106-
107-
echo "$UPDATED" > "$PROFILE_FILE"
107+
.session_concepts = []'
108108

109109
# Log session
110110
echo "$TODAY $(date -u +%H:%M:%S) session_start" >> "$SESSION_LOG"
111111

112112
# Show streak info if notable
113-
BELT=$(jq -r '.belt // "white"' "$PROFILE_FILE")
114-
XP=$(jq -r '.xp // 0' "$PROFILE_FILE")
115-
116113
if [ "$NEW_STREAK" -ge 7 ] && [ "$NEW_STREAK" != "$CURRENT_STREAK" ]; then
117-
echo "🔥 $NEW_STREAK-day streak! Consistency is the Dojo Way."
114+
echo "$NEW_STREAK-day streak! Consistency is the Dojo Way."
118115
fi
119116
fi
120117

scripts/session-stop.sh

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
# CodeSensei — Session Stop Hook
33
# Saves session data and shows a mini-recap prompt
44

5-
PROFILE_DIR="$HOME/.code-sensei"
6-
PROFILE_FILE="$PROFILE_DIR/profile.json"
7-
SESSION_LOG="$PROFILE_DIR/sessions.log"
5+
# Resolve lib path relative to this script's location
6+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7+
# shellcheck source=lib/profile-io.sh
8+
source "${SCRIPT_DIR}/lib/profile-io.sh"
9+
10+
SESSION_LOG="${PROFILE_DIR}/sessions.log"
811
TODAY=$(date -u +%Y-%m-%d)
912

1013
if [ ! -f "$PROFILE_FILE" ]; then
@@ -21,13 +24,12 @@ if command -v jq &> /dev/null; then
2124
# Log session end
2225
echo "$TODAY $(date -u +%H:%M:%S) session_stop concepts=$SESSION_CONCEPTS xp=$XP belt=$BELT" >> "$SESSION_LOG"
2326

24-
# Clear session-specific data
25-
UPDATED=$(jq '.session_concepts = []' "$PROFILE_FILE")
26-
echo "$UPDATED" > "$PROFILE_FILE"
27+
# Clear session-specific data atomically
28+
update_profile '.session_concepts = []'
2729

2830
# Show gentle reminder if they learned things but didn't recap
2931
if [ "$SESSION_CONCEPTS" -gt 0 ]; then
30-
echo "🥋 You encountered $SESSION_CONCEPTS new concepts this session! Use /code-sensei:recap next time for a full summary."
32+
echo "You encountered $SESSION_CONCEPTS new concepts this session! Use /code-sensei:recap next time for a full summary."
3133
fi
3234
fi
3335

scripts/track-code-change.sh

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@
33
# Records what files Claude creates or modifies for contextual teaching
44
# This data is used by /explain and /recap to know what happened
55

6-
PROFILE_DIR="$HOME/.code-sensei"
7-
PROFILE_FILE="$PROFILE_DIR/profile.json"
8-
CHANGES_LOG="$PROFILE_DIR/session-changes.jsonl"
6+
# Resolve lib path relative to this script's location
7+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8+
# shellcheck source=lib/profile-io.sh
9+
source "${SCRIPT_DIR}/lib/profile-io.sh"
10+
11+
CHANGES_LOG="${PROFILE_DIR}/session-changes.jsonl"
912

1013
# Read hook input from stdin
1114
INPUT=$(cat)
1215

13-
if [ ! -d "$PROFILE_DIR" ]; then
14-
mkdir -p "$PROFILE_DIR"
15-
fi
16+
ensure_profile_dir
1617

1718
if command -v jq &> /dev/null; then
1819
# Extract file path and tool info from hook input
@@ -44,21 +45,25 @@ if command -v jq &> /dev/null; then
4445
# Log the change
4546
echo "{\"timestamp\":\"$TIMESTAMP\",\"tool\":\"$TOOL_NAME\",\"file\":\"$FILE_PATH\",\"extension\":\"$EXTENSION\",\"tech\":\"$TECH\"}" >> "$CHANGES_LOG"
4647

47-
# Track technology in session concepts if it's new
48+
# Track technology in session and lifetime concepts — single atomic jq pass
4849
IS_FIRST_EVER="false"
4950
if [ -f "$PROFILE_FILE" ] && [ "$TECH" != "other" ]; then
50-
ALREADY_SEEN=$(jq --arg tech "$TECH" '.session_concepts | index($tech)' "$PROFILE_FILE")
51-
if [ "$ALREADY_SEEN" = "null" ]; then
52-
UPDATED=$(jq --arg tech "$TECH" '.session_concepts += [$tech]' "$PROFILE_FILE")
53-
echo "$UPDATED" > "$PROFILE_FILE"
54-
fi
51+
# Read current state once to determine what flags to set
52+
ALREADY_IN_SESSION=$(jq --arg tech "$TECH" '.session_concepts | index($tech)' "$PROFILE_FILE")
53+
ALREADY_IN_LIFETIME=$(jq --arg tech "$TECH" '.concepts_seen | index($tech)' "$PROFILE_FILE")
5554

56-
# Also add to lifetime concepts_seen if new — and flag for micro-lesson
57-
LIFETIME_SEEN=$(jq --arg tech "$TECH" '.concepts_seen | index($tech)' "$PROFILE_FILE")
58-
if [ "$LIFETIME_SEEN" = "null" ]; then
59-
UPDATED=$(jq --arg tech "$TECH" '.concepts_seen += [$tech]' "$PROFILE_FILE")
60-
echo "$UPDATED" > "$PROFILE_FILE"
55+
if [ "$ALREADY_IN_LIFETIME" = "null" ]; then
6156
IS_FIRST_EVER="true"
57+
# Add to both session_concepts and concepts_seen in one pass
58+
update_profile --arg tech "$TECH" '
59+
.session_concepts += (if (.session_concepts | index($tech)) == null then [$tech] else [] end) |
60+
.concepts_seen += (if (.concepts_seen | index($tech)) == null then [$tech] else [] end)
61+
'
62+
elif [ "$ALREADY_IN_SESSION" = "null" ]; then
63+
# New to session only — single pass
64+
update_profile --arg tech "$TECH" '
65+
.session_concepts += [$tech]
66+
'
6267
fi
6368
fi
6469

@@ -67,10 +72,10 @@ if command -v jq &> /dev/null; then
6772

6873
if [ "$IS_FIRST_EVER" = "true" ]; then
6974
# First-time encounter: micro-lesson about the technology
70-
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."
75+
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."
7176
else
7277
# Already-seen technology: inline insight about the specific change
73-
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."
78+
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."
7479
fi
7580

7681
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"$CONTEXT\"}}"

scripts/track-command.sh

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@
33
# Records what shell commands Claude runs for contextual teaching
44
# Helps /explain and /recap know what tools/packages were used
55

6-
PROFILE_DIR="$HOME/.code-sensei"
7-
PROFILE_FILE="$PROFILE_DIR/profile.json"
8-
COMMANDS_LOG="$PROFILE_DIR/session-commands.jsonl"
6+
# Resolve lib path relative to this script's location
7+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8+
# shellcheck source=lib/profile-io.sh
9+
source "${SCRIPT_DIR}/lib/profile-io.sh"
10+
11+
COMMANDS_LOG="${PROFILE_DIR}/session-commands.jsonl"
912

1013
# Read hook input from stdin
1114
INPUT=$(cat)
1215

13-
if [ ! -d "$PROFILE_DIR" ]; then
14-
mkdir -p "$PROFILE_DIR"
15-
fi
16+
ensure_profile_dir
1617

1718
if command -v jq &> /dev/null; then
1819
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // "unknown"')
@@ -23,8 +24,6 @@ if command -v jq &> /dev/null; then
2324
case "$COMMAND" in
2425
*"npm install"*|*"npm i "*|*"yarn add"*|*"pnpm add"*)
2526
CONCEPT="package-management"
26-
# Extract package name for tracking
27-
PACKAGE=$(echo "$COMMAND" | sed -E 's/.*(npm install|npm i|yarn add|pnpm add)[[:space:]]+([^[:space:]]+).*/\2/' | head -1)
2827
;;
2928
*"pip install"*|*"pip3 install"*)
3029
CONCEPT="package-management"
@@ -47,20 +46,25 @@ if command -v jq &> /dev/null; then
4746
# Log the command
4847
echo "{\"timestamp\":\"$TIMESTAMP\",\"command\":\"$(echo "$COMMAND" | head -c 200)\",\"concept\":\"$CONCEPT\"}" >> "$COMMANDS_LOG"
4948

50-
# Track concept in session and lifetime if new and meaningful
49+
# Track concept in session and lifetime — single atomic jq pass
5150
IS_FIRST_EVER="false"
5251
if [ -n "$CONCEPT" ] && [ -f "$PROFILE_FILE" ]; then
53-
ALREADY_SEEN=$(jq --arg c "$CONCEPT" '.session_concepts | index($c)' "$PROFILE_FILE")
54-
if [ "$ALREADY_SEEN" = "null" ]; then
55-
UPDATED=$(jq --arg c "$CONCEPT" '.session_concepts += [$c]' "$PROFILE_FILE")
56-
echo "$UPDATED" > "$PROFILE_FILE"
57-
fi
52+
# Read current state once to determine what flags to set
53+
ALREADY_IN_SESSION=$(jq --arg c "$CONCEPT" '.session_concepts | index($c)' "$PROFILE_FILE")
54+
ALREADY_IN_LIFETIME=$(jq --arg c "$CONCEPT" '.concepts_seen | index($c)' "$PROFILE_FILE")
5855

59-
LIFETIME_SEEN=$(jq --arg c "$CONCEPT" '.concepts_seen | index($c)' "$PROFILE_FILE")
60-
if [ "$LIFETIME_SEEN" = "null" ]; then
61-
UPDATED=$(jq --arg c "$CONCEPT" '.concepts_seen += [$c]' "$PROFILE_FILE")
62-
echo "$UPDATED" > "$PROFILE_FILE"
56+
if [ "$ALREADY_IN_LIFETIME" = "null" ]; then
6357
IS_FIRST_EVER="true"
58+
# Add to both session_concepts and concepts_seen in one atomic pass
59+
update_profile --arg c "$CONCEPT" '
60+
.session_concepts += (if (.session_concepts | index($c)) == null then [$c] else [] end) |
61+
.concepts_seen += (if (.concepts_seen | index($c)) == null then [$c] else [] end)
62+
'
63+
elif [ "$ALREADY_IN_SESSION" = "null" ]; then
64+
# New to session only — single atomic pass
65+
update_profile --arg c "$CONCEPT" '
66+
.session_concepts += [$c]
67+
'
6468
fi
6569
fi
6670

@@ -71,13 +75,13 @@ if command -v jq &> /dev/null; then
7175

7276
if [ "$IS_FIRST_EVER" = "true" ] && [ -n "$CONCEPT" ]; then
7377
# First-time encounter: micro-lesson about the concept
74-
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."
78+
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."
7579
elif [ -n "$CONCEPT" ]; then
7680
# Already-seen concept: brief inline insight about this specific command
77-
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."
81+
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."
7882
else
7983
# Unknown command type: still provide a brief hint
80-
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."
84+
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."
8185
fi
8286

8387
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"$CONTEXT\"}}"

0 commit comments

Comments
 (0)