#!/usr/bin/env bash # jj-hook.sh — Claude Code hook client for jj-commitd # # Thin client that sends events to the jj-commitd daemon over a Unix socket. # The daemon handles all jj operations (debounced commits, session tracking, # reaping, bookmarks). # # If the daemon isn't running, session-start launches it automatically. # If the daemon binary isn't found, falls back to simple file tracking # with commit-at-session-end (the original behavior). # # Usage from Claude Code hooks: # scripts/jj-hook.sh session-start # scripts/jj-hook.sh session-end # scripts/jj-hook.sh post-edit # Manual: # scripts/jj-hook.sh squash-wip # scripts/jj-hook.sh status # scripts/jj-hook.sh reap set -uo pipefail warn() { echo "jj-hook.sh: $*" >&2; } # shellcheck source=_lib.sh source "$(dirname "${BASH_SOURCE[0]}")/_lib.sh" ACTION="${1:-}" # SHA256-based repo ID — must match Go daemon's repoID() function REPO_ID=$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12) SOCK="/tmp/jj-commitd-${REPO_ID}.sock" # spec:26174-planctl-context-tokens/R6.2+R7.3+D§5.5 # planctl_start_ticks prints a platform-appropriate start-time stamp for pid, # matching the unit the Go `processStartTimeSec` helper reads for that PID: # Linux — raw clock ticks since boot (/proc//stat field 22). The stat # file's `comm` field can contain ')' and spaces, so we strip everything # up to and including the LAST ')' before tokenising (matches the safe # parse in cmd/planctl/proc_linux.go). # macOS — epoch seconds from `ps -o lstart=` converted with `date -j`. # Prints 0 on failure so the caller can still atomically write a PID file # whose start-time mismatches the live process — resolveSessionUUID will # (correctly) skip it. planctl_start_ticks() { local pid="$1" if [ -f "/proc/$pid/stat" ]; then local tail tail=$(sed 's/.*)//' "/proc/$pid/stat" 2>/dev/null) || { echo 0; return; } # After the last ')': state ppid ... starttime (starttime is field 20 of tail). echo "$tail" | awk '{print $20}' | grep -E '^[0-9]+$' || echo 0 else ps -o lstart= -p "$pid" 2>/dev/null | xargs -I{} date -j -f '%a %b %d %T %Y' '{}' '+%s' 2>/dev/null || echo 0 fi } # ── Daemon management ──────────────────────────────────────────── find_daemon() { local script_dir script_dir="$(dirname "${BASH_SOURCE[0]}")" for candidate in \ "${script_dir}/../bin/jj-commitd" \ "${script_dir}/../cmd/jj-commitd/jj-commitd" \ "$(command -v jj-commitd 2>/dev/null || true)" \ "${HOME}/go/bin/jj-commitd" \ "${GOPATH:-${HOME}/go}/bin/jj-commitd"; do [ -n "$candidate" ] && [ -x "$candidate" ] && echo "$candidate" && return 0 done return 1 } ensure_daemon() { # Already running? if [ -S "$SOCK" ]; then if echo '{"event":"status"}' | nc -U -w1 "$SOCK" >/dev/null 2>&1; then return 0 fi rm -f "$SOCK" fi local daemon daemon=$(find_daemon) || return 1 REPO_ROOT="$REPO_ROOT" nohup "$daemon" >/tmp/jj-commitd-"${REPO_ID}".log 2>&1 & # Wait for socket (up to 2s) local attempts=0 while [ ! -S "$SOCK" ] && [ "$attempts" -lt 20 ]; do sleep 0.1 attempts=$((attempts + 1)) done [ -S "$SOCK" ] } send_event() { echo "$1" | nc -U -w2 "$SOCK" 2>/dev/null || true } # ── Fallback (no daemon) ──────────────────────────────────────── fallback_post_edit() { local file="$1" session="$2" local tracking="/tmp/jj-claude-${REPO_ID}-${session}-files" if [ -f "$tracking" ]; then grep -qxF "$file" "$tracking" 2>/dev/null || echo "$file" >> "$tracking" else echo "$file" >> "$tracking" fi } fallback_session_end() { local session="$1" local tracking="/tmp/jj-claude-${REPO_ID}-${session}-files" if [ -f "$tracking" ] && [ -s "$tracking" ]; then if jj diff --summary 2>/dev/null | grep -q .; then jj commit -m "wip(claude:${session}): session end" 2>/dev/null || true fi rm -f "$tracking" fi } # ── Main dispatch ──────────────────────────────────────────────── case "$ACTION" in session-start) command -v jq >/dev/null 2>&1 || { cat >/dev/null; exit 0; } INPUT=$(cat) SID=$(printf '%s\n' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null | head -c 8) [ -z "$SID" ] && SID="unknown" if ensure_daemon; then # Pass PPID (Claude Code process), not $$ (this short-lived script). send_event "{\"event\":\"session-start\",\"session_id\":\"${SID}\",\"repo_root\":\"${REPO_ROOT}\",\"pid\":${PPID}}" fi # spec:26174-planctl-context-tokens/R6.2+R7.1+R7.3+D§4.1+D§5.1+D§5.5 # planctl context-window tracking: # (1) Propagate the FULL session UUID via $CLAUDE_ENV_FILE (if set by # Claude Code) so `planctl` can locate the transcript without PID # walking. # (2) Write a PID-keyed fallback file at /tmp/planctl-sessions/ # containing UUID + process-start-stamp, so a planctl invoked from # a descendant process whose env was lost can still resolve the # session. Atomic mktemp+mv prevents partial reads. # Validates the UUID with a canonical-form regex — anything looking other # than hex-dash-hex is rejected to keep hostile/garbage input out of the # env file and PID file. FULL_SID=$(printf '%s\n' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) if [[ "$FULL_SID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then if [ -n "${CLAUDE_ENV_FILE:-}" ]; then echo "export CLAUDE_SESSION_ID=${FULL_SID}" >> "$CLAUDE_ENV_FILE" fi START_TICKS=$(planctl_start_ticks "$PPID") mkdir -p /tmp/planctl-sessions TMPF=$(mktemp /tmp/planctl-sessions/.tmp.XXXXXX) printf '%s\n%s\n' "$FULL_SID" "$START_TICKS" > "$TMPF" mv -f "$TMPF" "/tmp/planctl-sessions/${PPID}" fi ;; session-end) command -v jq >/dev/null 2>&1 || { cat >/dev/null; exit 0; } INPUT=$(cat) SID=$(printf '%s\n' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null | head -c 8) [ -z "$SID" ] && SID="unknown" if [ -S "$SOCK" ]; then send_event "{\"event\":\"session-end\",\"session_id\":\"${SID}\"}" else fallback_session_end "$SID" fi # spec:26174-planctl-context-tokens/R7.2+R7.3+D§5.5 # Clean up planctl PID-keyed session file. rm -f is idempotent; if the # file was never written (UUID validation failed on start, or no session # info in the payload), the remove is a no-op. rm -f "/tmp/planctl-sessions/${PPID}" ;; post-edit) command -v jq >/dev/null 2>&1 || { cat >/dev/null; exit 0; } INPUT=$(cat) SID=$(printf '%s\n' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null | head -c 8) [ -z "$SID" ] && SID="unknown" FILE_PATH=$(printf '%s\n' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null) if [ -n "$FILE_PATH" ]; then if [ -S "$SOCK" ]; then send_event "{\"event\":\"post-edit\",\"session_id\":\"${SID}\",\"file\":\"${FILE_PATH}\",\"repo_root\":\"${REPO_ROOT}\"}" else REL_PATH="${FILE_PATH#"${REPO_ROOT}"/}" fallback_post_edit "$REL_PATH" "$SID" fi fi ;; squash-wip) set -e command -v jj >/dev/null 2>&1 || { warn "jj not found"; exit 1; } cd "$REPO_ROOT" if jj diff --summary 2>/dev/null | grep -q .; then warn "uncommitted changes — commit or stash first"; exit 1 fi MAIN_REV=$(jj log --no-graph -r 'main' -T 'commit_id.short(12)' 2>/dev/null) [ -z "$MAIN_REV" ] && { warn "no 'main' bookmark found"; exit 1; } WIP_REVS=$(jj log --no-graph -r 'main..@-' -T 'change_id.short(8) ++ "\n"' 2>/dev/null | grep -c . || true) [ "$WIP_REVS" -eq 0 ] && { echo "nothing to squash"; exit 0; } WIP_MESSAGES=$(jj log --no-graph -r 'main..@-' -T 'description.first_line() ++ "\n"' 2>/dev/null) SESSION_IDS=$(echo "$WIP_MESSAGES" | grep -oE 'claude:[a-z0-9_]+' | sort -u | sed 's/claude://' | paste -sd, -) SQUASH_MSG="wip(squashed): ${WIP_REVS} commits${SESSION_IDS:+ from sessions $SESSION_IDS}" LAST_WIP=$(jj log --no-graph -r '@-' -T 'change_id.short(12)' 2>/dev/null) jj new main -m "$SQUASH_MSG" 2>/dev/null || { warn "failed to create squash base"; exit 1; } jj restore --from "$LAST_WIP" 2>/dev/null || { warn "restore failed"; jj undo 2>/dev/null; exit 1; } jj commit -m "$SQUASH_MSG" 2>/dev/null || { warn "commit failed"; jj undo 2>/dev/null; exit 1; } jj abandon "all:main..${LAST_WIP}" 2>/dev/null || true for bm in $(jj bookmark list 2>/dev/null | grep -oE 'wip/claude-[^:]+' || true); do jj bookmark delete "$bm" 2>/dev/null || true done echo "squashed ${WIP_REVS} wip commits into one: ${SQUASH_MSG}" ;; status) if [ -S "$SOCK" ]; then send_event '{"event":"status"}' else echo "daemon not running (no socket at $SOCK)" fi ;; reap) if [ -S "$SOCK" ]; then send_event '{"event":"shutdown"}' echo "shutdown signal sent" else echo "daemon not running" fi ;; *) echo "Usage: jj-hook.sh {session-start|session-end|post-edit|squash-wip|status|reap}" >&2 exit 1 ;; esac