template-jj/scripts/jj-hook.sh
sid 89c1400c83 feat(planctl): scripts/jj-hook.sh planctl session tracking (parent 3.0)
- planctl_start_ticks helper: Linux safe /proc/<pid>/stat parse (LastIndex ')'
  + awk field 20); macOS ps -o lstart + date epoch conversion (R6.2, R7.3)
- session-start: validate canonical-UUID session_id, append CLAUDE_SESSION_ID
  to $CLAUDE_ENV_FILE (if set), atomically write /tmp/planctl-sessions/<PPID>
  with UUID + start-stamp via mktemp + mv (R6.2, R7.1, R7.3)
- session-end: rm -f /tmp/planctl-sessions/<PPID> (idempotent) (R7.2, R7.3)
- test-jj-hooks.sh: PID file lifecycle test + non-UUID rejection test (R7.1,
  R7.2)
- Manual smoke: PID file + $CLAUDE_ENV_FILE updates verified

Parent 3.0 from dev/plans/26174-planctl-context-tokens/tasks.md
2026-04-23 18:52:52 -06:00

250 lines
9.2 KiB
Bash
Executable file

#!/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/<pid>/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" </dev/null >>/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/<PPID>
# 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