#!/bin/bash # PreCompact Hook - Extract and preserve critical context before conversation compaction # Ensures important state survives context window compression # # Input: JSON (unused, but read for consistency) # Output: JSON with systemMessage containing preserved context # # Timeout: 10 seconds - keep operations lightweight set -euo pipefail # Read input from stdin (for consistency with other hooks) input=$(cat) # Get project directory PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" # Quick exit if not a valid directory if [[ ! -d "$PROJECT_DIR" ]]; then echo '{"continue": true}' exit 0 fi # ============================================================================= # GATHER CONTEXT TO PRESERVE # ============================================================================= PRESERVED_PARTS=() # --- Current Task Detection --- CURRENT_TASK="" # Check Claude's task tracking first (most authoritative) if [[ -f "$PROJECT_DIR/.claude/tasks.json" ]]; then # Get in-progress tasks (highest priority) IN_PROGRESS=$(jq -r '[.[] | select(.status == "in_progress")] | .[0].subject // empty' "$PROJECT_DIR/.claude/tasks.json" 2>/dev/null || echo "") if [[ -n "$IN_PROGRESS" ]]; then CURRENT_TASK="$IN_PROGRESS" else # Fall back to first pending task PENDING=$(jq -r '[.[] | select(.status == "pending")] | .[0].subject // empty' "$PROJECT_DIR/.claude/tasks.json" 2>/dev/null || echo "") if [[ -n "$PENDING" ]]; then CURRENT_TASK="$PENDING" fi fi fi # Check for TODO files if no Claude task found if [[ -z "$CURRENT_TASK" ]]; then for todofile in TODO.md TODO.txt todo.md todo.txt; do if [[ -f "$PROJECT_DIR/$todofile" ]]; then # Get first unchecked item FIRST_TODO=$(grep -m1 '^\s*-\s*\[ \]' "$PROJECT_DIR/$todofile" 2>/dev/null | sed 's/^\s*-\s*\[ \]\s*//' || echo "") if [[ -n "$FIRST_TODO" ]]; then CURRENT_TASK="$FIRST_TODO" break fi fi done fi if [[ -n "$CURRENT_TASK" ]]; then PRESERVED_PARTS+=("**Current Task:** $CURRENT_TASK") fi # --- Files Being Modified (from git status) --- MODIFIED_FILES="" if [[ -d "$PROJECT_DIR/.git" ]]; then # Get modified files (staged and unstaged) - limit to 10 for brevity MODIFIED=$(git -C "$PROJECT_DIR" status --porcelain 2>/dev/null | head -10 | awk '{print $2}' | tr '\n' ', ' | sed 's/,$//') if [[ -n "$MODIFIED" ]]; then MODIFIED_FILES="$MODIFIED" # Count total if more than 10 TOTAL_MODIFIED=$(git -C "$PROJECT_DIR" status --porcelain 2>/dev/null | wc -l | tr -d ' ') if [[ "$TOTAL_MODIFIED" -gt 10 ]]; then MODIFIED_FILES="$MODIFIED_FILES (and $((TOTAL_MODIFIED - 10)) more)" fi fi fi if [[ -n "$MODIFIED_FILES" ]]; then PRESERVED_PARTS+=("**Files Modified:** $MODIFIED_FILES") fi # --- Git Branch Context --- if [[ -d "$PROJECT_DIR/.git" ]]; then GIT_BRANCH=$(git -C "$PROJECT_DIR" branch --show-current 2>/dev/null || echo "") if [[ -n "$GIT_BRANCH" ]]; then PRESERVED_PARTS+=("**Branch:** \`$GIT_BRANCH\`") fi fi # --- Recent Commits (to understand context of current work) --- if [[ -d "$PROJECT_DIR/.git" ]]; then # Get last 3 commit messages (one-line each) RECENT_COMMITS=$(git -C "$PROJECT_DIR" log -3 --oneline 2>/dev/null | awk '{$1=""; print substr($0,2)}' | tr '\n' '; ' | sed 's/; $//') if [[ -n "$RECENT_COMMITS" ]]; then PRESERVED_PARTS+=("**Recent Commits:** $RECENT_COMMITS") fi fi # --- Check for Blockers (common patterns in task files) --- BLOCKERS="" # Check .claude/tasks.json for blocked tasks if [[ -f "$PROJECT_DIR/.claude/tasks.json" ]]; then BLOCKED_TASKS=$(jq -r '[.[] | select(.blockedBy != null and (.blockedBy | length > 0))] | .[0:3] | .[].subject // empty' "$PROJECT_DIR/.claude/tasks.json" 2>/dev/null | tr '\n' '; ' | sed 's/; $//' || echo "") if [[ -n "$BLOCKED_TASKS" ]]; then BLOCKERS="Blocked tasks: $BLOCKED_TASKS" fi fi # Check TODO files for blocked items if [[ -z "$BLOCKERS" ]]; then for todofile in TODO.md TODO.txt todo.md todo.txt; do if [[ -f "$PROJECT_DIR/$todofile" ]]; then # Look for items marked blocked, waiting, or with BLOCKED/WAIT tags BLOCKED_ITEM=$(grep -im1 '\(blocked\|waiting\|BLOCKED\|WAIT\)' "$PROJECT_DIR/$todofile" 2>/dev/null | head -1 || echo "") if [[ -n "$BLOCKED_ITEM" ]]; then BLOCKERS="$BLOCKED_ITEM" break fi fi done fi if [[ -n "$BLOCKERS" ]]; then PRESERVED_PARTS+=("**Blockers:** $BLOCKERS") fi # --- Key Decisions --- DECISIONS="" # Check for dedicated decision files for decfile in DECISIONS.md .claude/decisions.md decisions.md .decisions.md; do if [[ -f "$PROJECT_DIR/$decfile" ]]; then # Get last 5 non-empty lines that look like decisions (exclude headers) RECENT_DECISIONS=$(grep -v '^#\|^$\|^---' "$PROJECT_DIR/$decfile" 2>/dev/null | tail -5 | tr '\n' '; ' | sed 's/; $//') if [[ -n "$RECENT_DECISIONS" ]]; then DECISIONS="From $decfile: $RECENT_DECISIONS" break fi fi done # If no decision file, scan recent commit messages for decision keywords if [[ -z "$DECISIONS" ]] && [[ -d "$PROJECT_DIR/.git" ]]; then # Look for commits with decision-related keywords in last 10 commits DECISION_COMMITS=$(git -C "$PROJECT_DIR" log -10 --oneline --grep="decided\|chose\|selected\|switched to\|went with\|picking\|opting for" -i 2>/dev/null | head -3 | awk '{$1=""; print substr($0,2)}' | tr '\n' '; ' | sed 's/; $//') if [[ -n "$DECISION_COMMITS" ]]; then DECISIONS="From commits: $DECISION_COMMITS" fi fi # Check TODO files for decided/done items (recently resolved decisions) if [[ -z "$DECISIONS" ]]; then for todofile in TODO.md TODO.txt todo.md todo.txt; do if [[ -f "$PROJECT_DIR/$todofile" ]]; then # Look for checked/done items that might indicate decisions DECIDED_ITEMS=$(grep -E '^\s*-\s*\[x\]|^DONE:|^DECIDED:' "$PROJECT_DIR/$todofile" 2>/dev/null | tail -3 | sed 's/^\s*-\s*\[x\]\s*//' | tr '\n' '; ' | sed 's/; $//' || echo "") if [[ -n "$DECIDED_ITEMS" ]]; then DECISIONS="Resolved: $DECIDED_ITEMS" break fi fi done fi if [[ -n "$DECISIONS" ]]; then PRESERVED_PARTS+=("**Key Decisions:** $DECISIONS") fi # --- Project Type (for context) --- PROJECT_TYPE="" if [[ -f "$PROJECT_DIR/go.mod" ]]; then PROJECT_TYPE="Go" elif [[ -f "$PROJECT_DIR/package.json" ]]; then PROJECT_TYPE="Node.js" elif [[ -f "$PROJECT_DIR/composer.json" ]]; then PROJECT_TYPE="PHP" elif [[ -f "$PROJECT_DIR/Cargo.toml" ]]; then PROJECT_TYPE="Rust" elif [[ -f "$PROJECT_DIR/pyproject.toml" ]] || [[ -f "$PROJECT_DIR/requirements.txt" ]]; then PROJECT_TYPE="Python" fi if [[ -n "$PROJECT_TYPE" ]]; then PRESERVED_PARTS+=("**Project Type:** $PROJECT_TYPE") fi # ============================================================================= # BUILD OUTPUT # ============================================================================= # If nothing to preserve, allow silently if [[ ${#PRESERVED_PARTS[@]} -eq 0 ]]; then echo '{"continue": true}' exit 0 fi # Build the preserved context message CONTEXT_MSG="## Preserved Context (Pre-Compaction)\\n\\n" CONTEXT_MSG+="The following context was preserved before conversation compaction:\\n\\n" for part in "${PRESERVED_PARTS[@]}"; do CONTEXT_MSG+="$part\\n" done CONTEXT_MSG+="\\n---\\n" CONTEXT_MSG+="*This context was automatically preserved by the PreCompact hook.*" # Use jq for proper JSON string escaping ESCAPED_MSG=$(printf '%s' "$CONTEXT_MSG" | jq -sRr @json) # Output JSON response (ESCAPED_MSG already includes quotes from jq) cat << EOF { "continue": true, "systemMessage": $ESCAPED_MSG } EOF