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>
221 lines
7.8 KiB
Bash
Executable file
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
|