diff --git a/agents/sensei.md b/agents/sensei.md index 36bfbb8..8c80501 100644 --- a/agents/sensei.md +++ b/agents/sensei.md @@ -22,6 +22,21 @@ You live inside Claude Code and your mission is to teach people programming whil - **Concise** — you teach in small bites. One concept at a time. Never walls of text - **Fun** — learning should feel like leveling up in a game, not reading a textbook +## When Invoked via Delegation (Pending Lessons) + +If you are invoked by the main Claude instance via the Task tool after a hook delegation, read the pending lessons queue at `~/.code-sensei/pending-lessons/`. Each `.json` file contains a structured teaching moment: + +```json +{"timestamp":"...","type":"micro-lesson|inline-insight|command-hint","tech":"react","file":"src/App.jsx","belt":"white","firstEncounter":true} +``` + +Process the most recent entry (or batch if multiple are pending). Produce the appropriate teaching content based on the `type`: +- **micro-lesson**: First-time encounter — explain what the technology/concept is and why it matters (2-3 sentences) +- **inline-insight**: Already-seen technology — brief explanation of what this specific change/command does (1-2 sentences) +- **command-hint**: Unknown command type — explain only if educational, skip if trivial + +Always read the user's profile (`~/.code-sensei/profile.json`) to calibrate your belt-level language. + ## The Dojo Way (Teaching Philosophy) 1. **Learn by DOING** — you never explain something the user hasn't encountered. You explain what just happened in THEIR project diff --git a/commands/recap.md b/commands/recap.md index c5b8b33..4ffa602 100644 --- a/commands/recap.md +++ b/commands/recap.md @@ -10,10 +10,16 @@ You are CodeSensei 🥋 by Dojo Coding. The user wants a summary of what they le 1. Read the user's profile from `~/.code-sensei/profile.json` -2. Analyze the current session: +2. Drain pending lessons from `~/.code-sensei/pending-lessons/`: + - Read all `.json` files in the directory + - Each file contains a structured teaching moment: `{"timestamp","type","tech/concept","file/command","belt","firstEncounter"}` + - Use these to build a complete picture of what was learned this session + - After processing, you may reference these lessons in the recap + +3. Analyze the current session: - What files were created or modified? - What technologies/tools were used? - - What concepts were encountered? + - What concepts were encountered (from profile + pending lessons)? - How many quizzes were taken and results? - What was the user trying to build? diff --git a/scripts/session-stop.sh b/scripts/session-stop.sh index 2c217c2..1b31107 100644 --- a/scripts/session-stop.sh +++ b/scripts/session-stop.sh @@ -63,6 +63,23 @@ 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 +# Archive pending lessons from this session (DOJ-2436) +PENDING_DIR="${PROFILE_DIR}/pending-lessons" +ARCHIVE_DIR="${PROFILE_DIR}/lessons-archive" +if [ -d "$PENDING_DIR" ] && [ "$(ls -A "$PENDING_DIR" 2>/dev/null)" ]; then + mkdir -p "$ARCHIVE_DIR" + ARCHIVE_FILE="${ARCHIVE_DIR}/${TODAY}.jsonl" + # Concatenate all pending lesson files into the daily archive + for f in "$PENDING_DIR"/*.json; do + [ -f "$f" ] && cat "$f" >> "$ARCHIVE_FILE" + done + # Clear the pending queue + rm -f "$PENDING_DIR"/*.json + + # Cap archive size: keep only last 30 days of archives (~1MB) + find "$ARCHIVE_DIR" -name "*.jsonl" -type f | sort | head -n -30 | xargs -r rm -f +fi + rm -f "$SESSION_STATE" rm -f "$PROFILE_DIR/.jq-warned" diff --git a/scripts/track-code-change.sh b/scripts/track-code-change.sh index e649cb3..3a973a7 100644 --- a/scripts/track-code-change.sh +++ b/scripts/track-code-change.sh @@ -167,12 +167,30 @@ if [ $? -ne 0 ]; then BELT="white" fi +# --- Pending lessons queue (durable, per-lesson file to avoid append races) --- (DOJ-2436) +PENDING_DIR="${PROFILE_DIR}/pending-lessons" +mkdir -p "$PENDING_DIR" + if [ "$IS_FIRST_EVER" = "true" ]; then - 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." + LESSON_TYPE="micro-lesson" else - 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." + LESSON_TYPE="inline-insight" fi +# Write one JSON file per lesson (atomic, no race conditions) +LESSON_ID="${TIMESTAMP}-$(printf '%05d' $$)" +LESSON_FILE="${PENDING_DIR}/${LESSON_ID}.json" +SAFE_FILE_PATH_LESSON=$(printf '%s' "$FILE_PATH" | sed 's/\\/\\\\/g; s/"/\\"/g') +SAFE_TOOL_NAME_LESSON=$(printf '%s' "$TOOL_NAME" | sed 's/\\/\\\\/g; s/"/\\"/g') +if ! printf '{"timestamp":"%s","type":"%s","tech":"%s","file":"%s","tool":"%s","belt":"%s","firstEncounter":%s}\n' \ + "$TIMESTAMP" "$LESSON_TYPE" "$TECH" "$SAFE_FILE_PATH_LESSON" "$SAFE_TOOL_NAME_LESSON" "$BELT" "$IS_FIRST_EVER" > "$LESSON_FILE" +then + log_error "$SCRIPT_NAME" "Failed to write pending lesson: $LESSON_FILE" +fi + +# --- Delegation hint: delegate teaching to sensei subagent --- (DOJ-2436) +CONTEXT="CodeSensei: New teaching moment detected ($TECH, $FILE_PATH). If the user is not in the middle of a complex task, use the Task tool to invoke the 'sensei' agent. Pass it the latest pending lesson from ~/.code-sensei/pending-lessons/." + ESCAPED_CONTEXT=$(json_escape "$CONTEXT") printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":%s}}\n' "$ESCAPED_CONTEXT" diff --git a/scripts/track-command.sh b/scripts/track-command.sh index d89ad60..96c2313 100644 --- a/scripts/track-command.sh +++ b/scripts/track-command.sh @@ -234,16 +234,37 @@ if [ $? -ne 0 ]; then BELT="white" fi -if [ "$ERROR_IS_FIRST_EVER" = "true" ] && [ -n "$ERROR_CONCEPT" ]; then - CONTEXT="🥋 CodeSensei micro-lesson trigger: The user just encountered '$ERROR_CONCEPT' for the FIRST TIME while reading command output ($SAFE_CMD). Their belt level is '$BELT'. Provide a brief 2-sentence explanation of how to read this kind of error and why it matters. Adapt language to their belt level. Keep it supportive and practical." +# --- Pending lessons queue (durable, per-lesson file to avoid append races) --- (DOJ-2436) +PENDING_DIR="${PROFILE_DIR}/pending-lessons" +mkdir -p "$PENDING_DIR" + +if [ "$IS_FIRST_EVER" = "true" ] && [ -n "$CONCEPT" ]; then + LESSON_TYPE="micro-lesson" +elif [ "$ERROR_IS_FIRST_EVER" = "true" ] && [ -n "$ERROR_CONCEPT" ]; then + LESSON_TYPE="micro-lesson" +elif [ -n "$CONCEPT" ]; then + LESSON_TYPE="inline-insight" elif [ -n "$ERROR_CONCEPT" ]; then - CONTEXT="🥋 CodeSensei inline insight: An error appeared in the command output ($SAFE_CMD). The user's belt level is '$BELT'. This is a great moment to teach '$ERROR_CONCEPT' -- briefly explain how to read and interpret this type of error in 1-2 sentences, adapted to their belt level. Keep it supportive and practical." -elif [ "$IS_FIRST_EVER" = "true" ]; then - 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." + LESSON_TYPE="inline-insight" else - 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." + LESSON_TYPE="command-hint" fi +# Write one JSON file per lesson (atomic, no race conditions) +LESSON_ID="${TIMESTAMP}-$(printf '%05d' $$)" +LESSON_FILE="${PENDING_DIR}/${LESSON_ID}.json" +DISPLAY_TOPIC="${ERROR_CONCEPT:-${CONCEPT:-shell command}}" +SAFE_DISPLAY_TOPIC=$(printf '%s' "$DISPLAY_TOPIC" | sed 's/\\/\\\\/g; s/"/\\"/g') +if ! printf '{"timestamp":"%s","type":"%s","concept":"%s","command":"%s","belt":"%s","firstEncounter":%s}\n' \ + "$TIMESTAMP" "$LESSON_TYPE" "$SAFE_DISPLAY_TOPIC" "$SAFE_CMD" "$BELT" \ + "$([ "$IS_FIRST_EVER" = "true" ] || [ "$ERROR_IS_FIRST_EVER" = "true" ] && echo "true" || echo "false")" > "$LESSON_FILE" +then + log_error "$SCRIPT_NAME" "Failed to write pending lesson: $LESSON_FILE" +fi + +# --- Delegation hint: delegate teaching to sensei subagent --- (DOJ-2436) +CONTEXT="CodeSensei: New teaching moment detected ($DISPLAY_TOPIC, $SAFE_CMD). If the user is not in the middle of a complex task, use the Task tool to invoke the 'sensei' agent. Pass it the latest pending lesson from ~/.code-sensei/pending-lessons/." + ESCAPED_CONTEXT=$(json_escape "$CONTEXT") printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":%s}}\n' "$ESCAPED_CONTEXT" diff --git a/tests/test-hooks.sh b/tests/test-hooks.sh new file mode 100755 index 0000000..d46d15c --- /dev/null +++ b/tests/test-hooks.sh @@ -0,0 +1,256 @@ +#!/bin/bash +# CodeSensei — Hook Regression Tests +# Validates that hook scripts produce valid JSON output and write +# structured pending lessons to the queue directory. +# +# Usage: bash tests/test-hooks.sh +# Requirements: jq + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TEST_HOME=$(mktemp -d) +export HOME="$TEST_HOME" + +PASS=0 +FAIL=0 + +# Colors +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +pass() { PASS=$((PASS + 1)); echo -e " ${GREEN}✓${NC} $1"; } +fail() { FAIL=$((FAIL + 1)); echo -e " ${RED}✗${NC} $1: $2"; } + +cleanup() { + rm -rf "$TEST_HOME" +} +trap cleanup EXIT + +# --- Setup: create a minimal profile --- +setup_profile() { + mkdir -p "$TEST_HOME/.code-sensei" + cat > "$TEST_HOME/.code-sensei/profile.json" <<'PROFILE' +{ + "belt": "yellow", + "xp": 100, + "session_concepts": [], + "concepts_seen": ["html"], + "streak": {"current": 3} +} +PROFILE +} + +echo "" +echo "━━━ CodeSensei Hook Regression Tests ━━━" +echo "" + +# ============================================================ +# TEST GROUP 1: track-code-change.sh +# ============================================================ +echo "▸ track-code-change.sh" + +# Test 1.1: Output is valid JSON +setup_profile +OUTPUT=$(echo '{"tool_name":"Write","tool_input":{"file_path":"src/App.tsx"}}' \ + | bash "$SCRIPT_DIR/scripts/track-code-change.sh" 2>/dev/null) + +if echo "$OUTPUT" | jq . > /dev/null 2>&1; then + pass "stdout is valid JSON" +else + fail "stdout is valid JSON" "got: $OUTPUT" +fi + +# Test 1.2: Output contains hookSpecificOutput with PostToolUse event +EVENT=$(echo "$OUTPUT" | jq -r '.hookSpecificOutput.hookEventName') +if [ "$EVENT" = "PostToolUse" ]; then + pass "hookEventName is PostToolUse" +else + fail "hookEventName is PostToolUse" "got: $EVENT" +fi + +# Test 1.3: additionalContext is a delegation hint (not verbose teaching) +CONTEXT=$(echo "$OUTPUT" | jq -r '.hookSpecificOutput.additionalContext') +if echo "$CONTEXT" | grep -q "Task tool" && echo "$CONTEXT" | grep -q "sensei"; then + pass "additionalContext is a delegation hint (mentions Task tool + sensei)" +else + fail "additionalContext is a delegation hint" "got: $CONTEXT" +fi + +# Test 1.4: additionalContext does NOT contain old verbose teaching patterns +if echo "$CONTEXT" | grep -q "Provide a brief"; then + fail "additionalContext has no verbose teaching" "still contains 'Provide a brief'" +else + pass "additionalContext has no verbose teaching content" +fi + +# Test 1.5: Pending lesson file was created +LESSON_COUNT=$(find "$TEST_HOME/.code-sensei/pending-lessons" -name "*.json" 2>/dev/null | wc -l) +if [ "$LESSON_COUNT" -ge 1 ]; then + pass "pending lesson file created ($LESSON_COUNT file(s))" +else + fail "pending lesson file created" "found $LESSON_COUNT files" +fi + +# Test 1.6: Pending lesson file is valid JSON +LESSON_FILE=$(find "$TEST_HOME/.code-sensei/pending-lessons" -name "*.json" | head -1) +if jq . "$LESSON_FILE" > /dev/null 2>&1; then + pass "pending lesson file is valid JSON" +else + fail "pending lesson file is valid JSON" "file: $LESSON_FILE" +fi + +# Test 1.7: Pending lesson has required fields +LESSON_TYPE=$(jq -r '.type' "$LESSON_FILE") +LESSON_TECH=$(jq -r '.tech' "$LESSON_FILE") +LESSON_BELT=$(jq -r '.belt' "$LESSON_FILE") +if [ "$LESSON_TYPE" != "null" ] && [ "$LESSON_TECH" != "null" ] && [ "$LESSON_BELT" != "null" ]; then + pass "pending lesson has type=$LESSON_TYPE, tech=$LESSON_TECH, belt=$LESSON_BELT" +else + fail "pending lesson has required fields" "type=$LESSON_TYPE tech=$LESSON_TECH belt=$LESSON_BELT" +fi + +# Test 1.8: First encounter for new tech creates micro-lesson +setup_profile +rm -rf "$TEST_HOME/.code-sensei/pending-lessons" +echo '{"tool_name":"Write","tool_input":{"file_path":"main.py"}}' \ + | bash "$SCRIPT_DIR/scripts/track-code-change.sh" > /dev/null 2>&1 +LESSON_FILE=$(find "$TEST_HOME/.code-sensei/pending-lessons" -name "*.json" | head -1) +LESSON_TYPE=$(jq -r '.type' "$LESSON_FILE") +FIRST=$(jq -r '.firstEncounter' "$LESSON_FILE") +if [ "$LESSON_TYPE" = "micro-lesson" ] && [ "$FIRST" = "true" ]; then + pass "first encounter creates micro-lesson with firstEncounter=true" +else + fail "first encounter creates micro-lesson" "type=$LESSON_TYPE firstEncounter=$FIRST" +fi + +# Test 1.9: Already-seen tech creates inline-insight +setup_profile +rm -rf "$TEST_HOME/.code-sensei/pending-lessons" +echo '{"tool_name":"Edit","tool_input":{"file_path":"index.html"}}' \ + | bash "$SCRIPT_DIR/scripts/track-code-change.sh" > /dev/null 2>&1 +LESSON_FILE=$(find "$TEST_HOME/.code-sensei/pending-lessons" -name "*.json" | head -1) +LESSON_TYPE=$(jq -r '.type' "$LESSON_FILE") +FIRST=$(jq -r '.firstEncounter' "$LESSON_FILE") +if [ "$LESSON_TYPE" = "inline-insight" ] && [ "$FIRST" = "false" ]; then + pass "already-seen tech creates inline-insight with firstEncounter=false" +else + fail "already-seen tech creates inline-insight" "type=$LESSON_TYPE firstEncounter=$FIRST" +fi + +echo "" + +# ============================================================ +# TEST GROUP 2: track-command.sh +# ============================================================ +echo "▸ track-command.sh" + +setup_profile +rm -rf "$TEST_HOME/.code-sensei/pending-lessons" + +# Test 2.1: Output is valid JSON +OUTPUT=$(echo '{"tool_input":{"command":"npm install express"}}' \ + | bash "$SCRIPT_DIR/scripts/track-command.sh" 2>/dev/null) + +if echo "$OUTPUT" | jq . > /dev/null 2>&1; then + pass "stdout is valid JSON" +else + fail "stdout is valid JSON" "got: $OUTPUT" +fi + +# Test 2.2: additionalContext is delegation hint +CONTEXT=$(echo "$OUTPUT" | jq -r '.hookSpecificOutput.additionalContext') +if echo "$CONTEXT" | grep -q "Task tool" && echo "$CONTEXT" | grep -q "sensei"; then + pass "additionalContext is a delegation hint" +else + fail "additionalContext is a delegation hint" "got: $CONTEXT" +fi + +# Test 2.3: Pending lesson file for command +LESSON_FILE=$(find "$TEST_HOME/.code-sensei/pending-lessons" -name "*.json" | head -1) +if jq . "$LESSON_FILE" > /dev/null 2>&1; then + pass "pending lesson file is valid JSON" +else + fail "pending lesson file is valid JSON" "file: $LESSON_FILE" +fi + +# Test 2.4: Command lesson has concept field +CONCEPT=$(jq -r '.concept' "$LESSON_FILE") +if [ "$CONCEPT" = "package-management" ]; then + pass "command lesson detected concept=package-management" +else + fail "command lesson detected concept" "got: $CONCEPT" +fi + +echo "" + +# ============================================================ +# TEST GROUP 3: session-stop.sh (cleanup) +# ============================================================ +echo "▸ session-stop.sh (pending lessons cleanup)" + +setup_profile +rm -rf "$TEST_HOME/.code-sensei/pending-lessons" "$TEST_HOME/.code-sensei/lessons-archive" + +# Create some pending lessons +mkdir -p "$TEST_HOME/.code-sensei/pending-lessons" +echo '{"timestamp":"2026-03-09T12:00:00Z","type":"micro-lesson","tech":"react"}' \ + > "$TEST_HOME/.code-sensei/pending-lessons/test1.json" +echo '{"timestamp":"2026-03-09T12:01:00Z","type":"inline-insight","tech":"css"}' \ + > "$TEST_HOME/.code-sensei/pending-lessons/test2.json" + +# Add a session concept so we can verify the full flow +jq '.session_concepts = ["react","css"]' "$TEST_HOME/.code-sensei/profile.json" \ + | tee "$TEST_HOME/.code-sensei/profile.json.tmp" > /dev/null \ + && mv "$TEST_HOME/.code-sensei/profile.json.tmp" "$TEST_HOME/.code-sensei/profile.json" + +# Run session-stop +bash "$SCRIPT_DIR/scripts/session-stop.sh" > /dev/null 2>&1 + +# Test 3.1: Pending lessons directory was cleaned +REMAINING=$(find "$TEST_HOME/.code-sensei/pending-lessons" -name "*.json" 2>/dev/null | wc -l) +if [ "$REMAINING" -eq 0 ]; then + pass "pending lessons cleaned after session stop" +else + fail "pending lessons cleaned" "$REMAINING files remaining" +fi + +# Test 3.2: Archive file was created +TODAY=$(date -u +%Y-%m-%d) +ARCHIVE_FILE="$TEST_HOME/.code-sensei/lessons-archive/${TODAY}.jsonl" +if [ -f "$ARCHIVE_FILE" ]; then + pass "archive file created at lessons-archive/${TODAY}.jsonl" +else + fail "archive file created" "file not found: $ARCHIVE_FILE" +fi + +# Test 3.3: Archive contains the lessons (each line is valid JSON) +ARCHIVE_LINES=$(wc -l < "$ARCHIVE_FILE") +VALID_JSON=0 +while IFS= read -r line; do + if echo "$line" | jq . > /dev/null 2>&1; then + VALID_JSON=$((VALID_JSON + 1)) + fi +done < "$ARCHIVE_FILE" +if [ "$VALID_JSON" -eq "$ARCHIVE_LINES" ] && [ "$ARCHIVE_LINES" -ge 2 ]; then + pass "archive has $ARCHIVE_LINES valid JSON lines" +else + fail "archive has valid JSON lines" "total=$ARCHIVE_LINES valid=$VALID_JSON" +fi + +echo "" + +# ============================================================ +# SUMMARY +# ============================================================ +TOTAL=$((PASS + FAIL)) +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +if [ "$FAIL" -eq 0 ]; then + echo -e "${GREEN}All $TOTAL tests passed!${NC}" +else + echo -e "${RED}$FAIL/$TOTAL tests failed${NC}" +fi +echo "" + +exit "$FAIL"