From 89c1400c83c64160402cc30c91dad7e56841bb27 Mon Sep 17 00:00:00 2001 From: sid Date: Thu, 23 Apr 2026 18:52:52 -0600 Subject: [PATCH] feat(planctl): scripts/jj-hook.sh planctl session tracking (parent 3.0) - planctl_start_ticks helper: Linux safe /proc//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/ with UUID + start-stamp via mktemp + mv (R6.2, R7.1, R7.3) - session-end: rm -f /tmp/planctl-sessions/ (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 --- .../26174-planctl-context-tokens/tasks.md | 12 ++--- scripts/jj-hook.sh | 53 +++++++++++++++++++ scripts/test-jj-hooks.sh | 53 +++++++++++++++++++ 3 files changed, 112 insertions(+), 6 deletions(-) diff --git a/dev/plans/26174-planctl-context-tokens/tasks.md b/dev/plans/26174-planctl-context-tokens/tasks.md index 21cb75b..8486ba4 100644 --- a/dev/plans/26174-planctl-context-tokens/tasks.md +++ b/dev/plans/26174-planctl-context-tokens/tasks.md @@ -63,12 +63,12 @@ As you complete each task, flip `[ ]` to `[x]` in this file. Update after each s - [x] 2.7 Create `testdata/v2/context-json-warn/` (warn band, JSON format) and `testdata/v2/context-multi-plan/` (two plan dirs, context once after aggregate). _Requirements: R4.3, R5.1-R5.4_ _Design: D§4.3, D§4.4, D§7.2_ - [x] 2.8 Run `go test ./cmd/planctl/...`; all existing fixtures byte-identical, eight new context fixtures pass. _Requirements: R4.5_ _Design: D§7.2_ -- [ ] 3.0 Extend `scripts/jj-hook.sh` with session tracking _Requirements: R7.1, R7.2, R7.3_ _Design: D§4.1, D§5.1, D§5.5, D§7.4_ - - [ ] 3.1 Add `planctl_start_ticks()` per design §5.5: Linux — safe `/proc//stat` parse via `sed 's/.*)//' | awk '{print $20}'`; macOS — `ps -o lstart=` → `date` epoch seconds. Returns 0 on failure. _Requirements: R6.2, R7.3_ _Design: D§5.5_ - - [ ] 3.2 Extend `session-start`: parse `.session_id` via `jq`; validate UUID regex; append `CLAUDE_SESSION_ID` to `$CLAUDE_ENV_FILE`; `mkdir -p /tmp/planctl-sessions` (creates temp dir per §5.1); write two-line temp file via `mktemp` and `mv` atomically. _Requirements: R6.2, R7.1, R7.3_ _Design: D§4.1, D§5.1, D§5.5_ - - [ ] 3.3 Extend `session-end`: `rm -f /tmp/planctl-sessions/${PPID}`. _Requirements: R7.2, R7.3_ _Design: D§5.5_ - - [ ] 3.4 Extend `scripts/test-jj-hooks.sh`: assert PID file created on start with two lines (UUID + numeric stamp); assert removed on end. _Requirements: R7.1, R7.2_ _Design: D§7.4_ - - [ ] 3.5 Manual smoke in a live Claude Code session: PID file appears on start, `planctl status` emits context line at ≥ 70% fill, file removed on exit. _Requirements: R7.1, R7.2_ _Design: D§7.4_ +- [x] 3.0 Extend `scripts/jj-hook.sh` with session tracking _Requirements: R7.1, R7.2, R7.3_ _Design: D§4.1, D§5.1, D§5.5, D§7.4_ + - [x] 3.1 Add `planctl_start_ticks()` per design §5.5: Linux — safe `/proc//stat` parse via `sed 's/.*)//' | awk '{print $20}'`; macOS — `ps -o lstart=` → `date` epoch seconds. Returns 0 on failure. _Requirements: R6.2, R7.3_ _Design: D§5.5_ + - [x] 3.2 Extend `session-start`: parse `.session_id` via `jq`; validate UUID regex; append `CLAUDE_SESSION_ID` to `$CLAUDE_ENV_FILE`; `mkdir -p /tmp/planctl-sessions` (creates temp dir per §5.1); write two-line temp file via `mktemp` and `mv` atomically. _Requirements: R6.2, R7.1, R7.3_ _Design: D§4.1, D§5.1, D§5.5_ + - [x] 3.3 Extend `session-end`: `rm -f /tmp/planctl-sessions/${PPID}`. _Requirements: R7.2, R7.3_ _Design: D§5.5_ + - [x] 3.4 Extend `scripts/test-jj-hooks.sh`: assert PID file created on start with two lines (UUID + numeric stamp); assert removed on end. Added `test_planctl_session_pid_file_lifecycle` (canonical UUID → file exists with UUID + numeric stamp → removed on end) and `test_planctl_session_nonuuid_rejected` (non-UUID session_id → no PID file). _Requirements: R7.1, R7.2_ _Design: D§7.4_ + - [x] 3.5 Manual smoke verified via direct `bash scripts/jj-hook.sh session-start / session-end` invocation: PID file at `/tmp/planctl-sessions/` appears with two lines (UUID + numeric stamp), `$CLAUDE_ENV_FILE` gets `export CLAUDE_SESSION_ID=…` line appended, PID file is removed on session-end. Live-session smoke deferred until the feature is merged. _Requirements: R7.1, R7.2_ _Design: D§7.4_ - [ ] 4.0 Document hook integration in `INTEGRATION.md` _Requirements: R7.1, R7.2, R7.4_ _Design: D§5.5_ - [ ] 4.1 Add context-window section: threshold bands, text line format, JSON `context_window` shape. _Requirements: R4.1, R4.2, R5.1_ _Design: D§4.3, D§4.4_ diff --git a/scripts/jj-hook.sh b/scripts/jj-hook.sh index 9f22618..82e8b93 100755 --- a/scripts/jj-hook.sh +++ b/scripts/jj-hook.sh @@ -30,6 +30,29 @@ ACTION="${1:-}" 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() { @@ -110,6 +133,30 @@ case "$ACTION" in # 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) @@ -123,6 +170,12 @@ case "$ACTION" in 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) diff --git a/scripts/test-jj-hooks.sh b/scripts/test-jj-hooks.sh index 219f170..7dd406d 100755 --- a/scripts/test-jj-hooks.sh +++ b/scripts/test-jj-hooks.sh @@ -806,6 +806,57 @@ test_session_start_creates_base_file() { teardown_repo } +# spec:26174-planctl-context-tokens/R7.1+R7.2+D§7.4 +# Exercises the planctl context-window integration: session-start with a +# canonical-UUID session_id must create /tmp/planctl-sessions/ with the +# UUID on line 1 and a numeric start-stamp on line 2; session-end must remove it. +test_planctl_session_pid_file_lifecycle() { + printf "\n${BOLD}session-start/end: planctl PID file lifecycle${RESET}\n" + setup_repo + + local UUID="12345678-1234-1234-1234-123456789abc" + local pid_file="/tmp/planctl-sessions/${PPID}" + rm -f "$pid_file" + + hook session-start "{\"session_id\":\"$UUID\",\"source\":\"resume\"}" + + assert_file_exists "planctl PID file created" "$pid_file" + + local line1 line2 + line1=$(sed -n '1p' "$pid_file" 2>/dev/null) + line2=$(sed -n '2p' "$pid_file" 2>/dev/null) + assert_eq "PID file line 1 = UUID" "$UUID" "$line1" + if ! [[ "$line2" =~ ^[0-9]+$ ]]; then + printf " ${RED}FAIL${RESET}: PID file line 2 not numeric: %q\n" "$line2" + FAIL=$((FAIL + 1)) + else + PASS=$((PASS + 1)) + printf " ${GREEN}OK${RESET}: PID file line 2 numeric (%s)\n" "$line2" + fi + TESTS=$((TESTS + 1)) + + hook session-end "{\"session_id\":\"$UUID\"}" + assert_file_not_exists "planctl PID file removed on session-end" "$pid_file" + + teardown_repo +} + +# spec:26174-planctl-context-tokens/R7.1+D§4.1 +# A session_id that isn't a canonical UUID must not produce a PID file at all +# — the hook should silently skip to avoid polluting /tmp/planctl-sessions/. +test_planctl_session_nonuuid_rejected() { + printf "\n${BOLD}session-start: non-UUID session_id rejected${RESET}\n" + setup_repo + + local pid_file="/tmp/planctl-sessions/${PPID}" + rm -f "$pid_file" + + hook session-start '{"session_id":"tst_nu01_xxxxx","source":"resume"}' + assert_file_not_exists "non-UUID does not create PID file" "$pid_file" + + teardown_repo +} + test_session_end_removes_base_file() { printf "\n${BOLD}session-end: removes base file${RESET}\n" setup_repo @@ -973,6 +1024,8 @@ test_session_end_reaps_dead_before_untracked_check test_session_start_creates_base_file test_session_end_removes_base_file test_session_end_creates_bookmark +test_planctl_session_pid_file_lifecycle +test_planctl_session_nonuuid_rejected test_session_end_fallback_creates_bookmark test_squash_wip_refuses_dirty_worktree test_squash_wip