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
This commit is contained in:
sid 2026-04-23 18:52:52 -06:00
parent 35ee6de657
commit 89c1400c83
3 changed files with 112 additions and 6 deletions

View file

@ -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/<pid>/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/<pid>/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/<pid>` 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_

View file

@ -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/<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() {
@ -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/<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)
@ -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)

View file

@ -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/<PPID> 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