go-agent/claude/agentic/scripts/pre-compact.sh
Snider 61e01bfdf1 feat: initial go-agent — agentci + jobrunner + plugins marketplace
Consolidates three codebases into a single agent orchestration repo:

- agentci (from go-scm): Clotho dual-run verification, agent config,
  SSH security (sanitisation, secure commands, token masking)
- jobrunner (from go-scm): Poll-dispatch-report pipeline with 7 handlers
  (dispatch, completion, auto-merge, publish draft, dismiss reviews,
  send fix command, tick parent epic)
- plugins marketplace (from agentic/plugins): 27 Claude/Codex/Gemini
  plugins with shared MCP server

All 150+ tests passing across 6 packages.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 15:47:19 +00:00

221 lines
7.8 KiB
Bash
Executable file

#!/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