diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4e03998..052bfbb 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "prep-compact", "description": "When your Claude Code session's real token count crosses a configurable threshold (default 450K), an informational reminder names the warm handoff file. A Stop hook continuously maintains an on-disk handoff JSON (cumulative file paths, in-progress todos, active subagent launches, recent user-message quotes). The /prep-compact skill reads this warm handoff and adds an analytical layer (decisions, constraints, blockers, verb-anchored next-step) to emit a tailored /compact block.", - "version": "3.0.2", + "version": "3.0.3", "author": { "name": "Koen van der Heide", "url": "https://github.com/koenvdheide" diff --git a/CHANGELOG.md b/CHANGELOG.md index eeeb800..c7428c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to prep-compact will be documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.3] - 2026-06-26 + +### Changed + +- The Stop hook now runs asynchronously, so the handoff write no longer blocks the end of a turn. +- The UserPromptSubmit hook merges its stdin parse and transcript tail-scan into a single Python process, removing one interpreter cold-start per message (the dominant per-message cost on Windows). + +### Fixed + +- The UserPromptSubmit hook converts the transcript path with `cygpath` first on Git Bash, matching the pre-merge behaviour. A direct existence check could otherwise resolve a POSIX path such as `/tmp/x` to a drive-relative `C:\tmp\x` and scan the wrong file. +- With the Stop hook now asynchronous, it compares the on-disk handoff's `transcript_mtime_at_write` before replacing the handoff and skips the write when the existing file is newer, reducing the chance that a slow run overwrites a fresher handoff from a later turn. A narrow residual race remains (the check and the replace are not atomic, so a concurrent newer run can still interleave between them; closing it fully would need a per-session lock), but it is rare and self-corrects on the next successful Stop. + ## [3.0.2] - 2026-06-05 ### Fixed diff --git a/hooks/check-context-size.sh b/hooks/check-context-size.sh index 07d1f28..a3e44a1 100644 --- a/hooks/check-context-size.sh +++ b/hooks/check-context-size.sh @@ -10,6 +10,13 @@ # falls back to a shorter copy that just names the skill. Always exits 0 # (fail-open). # +# A single Python process does both the stdin-JSON parse (session_id, +# transcript_path) and the transcript tail-scan, printing two lines: +# line 1: safe session id (empty -> caller no-op) +# line 2: summed token count (absent -> below-threshold/no-usable-usage no-op) +# Collapsing what were two separate python invocations into one removes a +# per-message interpreter cold-start (the dominant cost on Windows). +# # Main-chain filter: role == 'assistant', isSidechain != true, # isApiErrorMessage != true. input_tokens required; cache fields default to 0. # No byte path, no baseline, no RESET. @@ -44,16 +51,23 @@ else exit 0 fi -# Extract session_id and transcript_path from stdin JSON. -EXTRACTED=$(printf '%s' "$STDIN_JSON" | "$PY" -c " -import sys, json, hashlib, re +# Single Python process: parse stdin JSON for session_id + transcript_path, +# derive the safe session id, resolve the transcript to a path the interpreter +# can open, tail-scan the last 256 KB for the newest main-chain assistant +# .message.usage, and print safe_sid (line 1) and the summed token count +# (line 2, omitted when there is no usable usage). Defensive at every layer. +RESULT=$(printf '%s' "$STDIN_JSON" | "$PY" -c " +import sys, json, hashlib, re, os, subprocess + +TAIL_BYTES = 262144 # 256 KB + try: d = json.load(sys.stdin) sid = d.get('session_id', '') or '' tp = d.get('transcript_path', '') or '' except Exception: - sid = '' - tp = '' + print('') + sys.exit(0) # session_id safety: regex-valid or SHA-1 hex fallback. if sid and re.fullmatch(r'[A-Za-z0-9_-]{1,64}', sid): @@ -61,47 +75,44 @@ if sid and re.fullmatch(r'[A-Za-z0-9_-]{1,64}', sid): elif sid: safe = hashlib.sha1(sid.encode('utf-8')).hexdigest() else: - safe = '' -print(safe) -print(tp) -" 2>/dev/null || printf '\n\n') -SAFE_SID=$(printf '%s' "$EXTRACTED" | sed -n '1p') -TRANSCRIPT_PATH=$(printf '%s' "$EXTRACTED" | sed -n '2p') - -if [[ -z "$SAFE_SID" ]]; then - exit 0 -fi - -FLAG="$CACHE_DIR/compact-warned-$SAFE_SID" - -if [[ -z "$TRANSCRIPT_PATH" || ! -r "$TRANSCRIPT_PATH" ]]; then - exit 0 -fi - -# Convert transcript_path to a format the native Python can open. Git Bash -# on Windows maps /tmp/ and /c/ to NTFS paths that are invisible to a -# Windows-native python.exe; cygpath -w bridges the gap. On Linux/macOS -# cygpath isn't present and the path is already native, so we fall through. -if command -v cygpath >/dev/null 2>&1; then - TRANSCRIPT_NATIVE=$(cygpath -w "$TRANSCRIPT_PATH" 2>/dev/null || printf '%s' "$TRANSCRIPT_PATH") -else - TRANSCRIPT_NATIVE="$TRANSCRIPT_PATH" -fi + print('') + sys.exit(0) -# Tail-scan the transcript for the newest main-chain assistant .message.usage. -# Prints the summed token count or nothing. Defensive at every layer. -TOKENS=$(printf '%s' "$TRANSCRIPT_NATIVE" | "$PY" -c " -import sys, json, os +print(safe) # line 1 — always emitted once sid is usable + +# Resolve transcript to a path the interpreter can open. Native paths +# (Windows/Linux/macOS) open directly; Git Bash maps /tmp/ and /c/ to NTFS +# paths invisible to a Windows-native python.exe, so fall back to cygpath -w. +# cygpath is only spawned when the direct open fails, so the common +# native-path case stays a single process with no extra spawn. +def resolve(path): + if not path: + return None + # Match the pre-merge behaviour: on Git Bash (cygpath present) convert the + # path FIRST. Native python resolves a POSIX path like /tmp/x to a + # drive-relative C:\tmp\x that can collide with an unrelated file, so a + # direct os.path.exists() pre-check could scan the wrong file. cygpath -w is + # one lightweight spawn (far cheaper than the second python it replaced). + # When cygpath is absent (Linux/macOS) or errors, fall back to the raw path; + # a wrong/missing path then fails the open() below and the hook stays silent. + try: + proc = subprocess.run(['cygpath', '-w', path], + capture_output=True, text=True, timeout=5) + if proc.returncode == 0: + c = proc.stdout.strip() + if c: + return c + except Exception: + pass + return path -TAIL_BYTES = 262144 # 256 KB +rp = resolve(tp) +if not rp: + sys.exit(0) # empty transcript_path -> no token line -path = sys.stdin.read().strip() -try: - size = os.path.getsize(path) -except OSError: - sys.exit(0) try: - with open(path, 'rb') as f: + size = os.path.getsize(rp) + with open(rp, 'rb') as f: f.seek(max(0, size - TAIL_BYTES)) tail = f.read().decode('utf-8', errors='replace') except OSError: @@ -109,16 +120,16 @@ except OSError: for line in reversed(tail.splitlines()): try: - d = json.loads(line) + e = json.loads(line) except Exception: continue - if not isinstance(d, dict): + if not isinstance(e, dict): continue - if d.get('isSidechain') is True: + if e.get('isSidechain') is True: continue - if d.get('isApiErrorMessage') is True: + if e.get('isApiErrorMessage') is True: continue - msg = d.get('message') + msg = e.get('message') if not isinstance(msg, dict): continue if msg.get('role') != 'assistant': @@ -133,13 +144,22 @@ for line in reversed(tail.splitlines()): cr = u.get('cache_read_input_tokens') or 0 if not isinstance(cc, int) or not isinstance(cr, int): continue - print(it + cc + cr) - sys.exit(0) + print(it + cc + cr) # line 2 — summed token count + break " 2>/dev/null) +SAFE_SID=$(printf '%s' "$RESULT" | sed -n '1p') +TOKENS=$(printf '%s' "$RESULT" | sed -n '2p') + +if [[ -z "$SAFE_SID" ]]; then + exit 0 +fi + +FLAG="$CACHE_DIR/compact-warned-$SAFE_SID" + if [[ -z "$TOKENS" || ! "$TOKENS" =~ ^[0-9]+$ ]]; then # No usable usage in tail — silent no-op (pre-first-turn, parse errors, - # schema drift, oversized-straddle, etc.). + # schema drift, oversized-straddle, missing transcript, etc.). exit 0 fi diff --git a/hooks/hooks.json b/hooks/hooks.json index b35c8cc..d1304d5 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -10,7 +10,7 @@ "Stop": [ { "hooks": [ - { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/update-handoff.sh\"" } + { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/update-handoff.sh\"", "async": true } ] } ] diff --git a/hooks/update-handoff.sh b/hooks/update-handoff.sh index 996ffcc..1ff071e 100644 --- a/hooks/update-handoff.sh +++ b/hooks/update-handoff.sh @@ -451,6 +451,22 @@ if os.environ.get('PREP_COMPACT_TEST_REPLACE_FAIL'): except OSError: pass sys.exit(0) +# Async-write ordering guard: with the Stop hook now async, a slower run for +# the same session can resume after a newer run already replaced the handoff. +# os.replace prevents partial reads but not last-writer-wins reordering, so skip +# the replace when the on-disk handoff's transcript_mtime_at_write is newer than +# this run's (a stale run must not clobber a fresher handoff). Fail-open. +try: + if os.path.exists(handoff_path): + with open(handoff_path, 'r', encoding='utf-8') as f: + _existing = json.load(f) + _emt = _existing.get('transcript_mtime_at_write') + if isinstance(_emt, (int, float)) and _emt > transcript_mtime: + os.unlink(tmp_path) + sys.exit(0) +except Exception: + pass + # Replace with one retry on PermissionError (Windows: target held open). for attempt in (1, 2): try: diff --git a/test/run-tests.sh b/test/run-tests.sh index 6001ad9..c7ec628 100644 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -91,7 +91,7 @@ fi # After T6: EXPECTED_PASS=87, SKIPPED=39 (T6 tests not Stop-dep) # After T7: EXPECTED_PASS=88, SKIPPED=39 (T7 test not Stop-dep) # PR-comment fix: +3 Stop-dep (T-32cap +1 short-still-captured, T-32prior +2) -EXPECTED_PASS=130 +EXPECTED_PASS=131 SKIPPED=0 run_hook() { @@ -667,6 +667,22 @@ PRIOR_INTACT=$("$PY" -c "import json; d=json.load(open('$HANDOFF')); print('yes' assert_eq "T-38p: simulated replace fail -> prior preserved (sentinel intact)" "yes" "$PRIOR_INTACT" assert_true "T-38p: stderr warning printed on simulated fail" '[[ "$ERR" == *"replace failed twice, prior preserved"* ]]' +# --- T-38g: async ordering guard — a newer on-disk handoff is NOT clobbered by an older run. +# Pre-write a handoff with a far-future transcript_mtime_at_write; the run (whose +# fixture transcript has a normal mtime) must SKIP the replace, leaving the +# sentinel written_at untouched. Distinguishes the guard from cumulative_files +# merge (which would preserve prior entries even on a write). +cleanup +"$PY" -c " +import json +prior = {'version':'3.0','session_id':'s38g','cwd':'/sample/cwd','transcript_path':'/x','transcript_mtime_at_write':9999999999.0,'written_at':'2099-01-01T00:00:00Z','cumulative_files':[],'recent_files':[],'in_progress_status':'unknown','in_progress':[],'recent_task_launches':[],'recent_user_requests':[]} +with open('$(to_native "$CACHE/handoff-s38g.json")','w') as f: json.dump(prior, f) +" +run_stop_hook '{"session_id":"s38g","transcript_path":"'"$FIX/transcript-handoff-multi-turn.jsonl"'","cwd":"/sample/cwd","permission_mode":"default","hook_event_name":"Stop"}' >/dev/null +HANDOFF=$(to_native "$CACHE/handoff-s38g.json") +WRITTEN_AT=$("$PY" -c "import json; d=json.load(open('$HANDOFF')); print(d.get('written_at',''))") +assert_eq "T-38g: newer on-disk handoff preserved (guard skips stale replace)" "2099-01-01T00:00:00Z" "$WRITTEN_AT" + # T-52: file noise filter — .git/temp/plugin-data excluded, real path kept cleanup FIX_ENV="$FIX" "$PY" -c " @@ -754,7 +770,7 @@ assert_eq "T-56: legit =-char path kept" "yes" "$("$PY" -c "import json;d=json assert_eq "T-56: shell-junk line rejected" "no" "$("$PY" -c "import json;d=json.load(open('$HANDOFF'));print('yes' if any(('SCRIPT_DIR' in p) or ('SKILL=' in p) or ('prep-compact/SKILL.md' in p) for p in d['recent_files']) else 'no')")" else - SKIPPED=59 + SKIPPED=60 fi # STOP_FIXTURE_OK # =================================================================== @@ -828,7 +844,11 @@ assert_true "T-46: valid lower-root candidate chosen" '[[ "$RPATH" == *"zzz-inli # T-46b semantic-malformed (cwd is a list, not a string) is skipped, not fatal reset_pdata mkdir -p "$PDATA/aaa-inline" -"$PY" -c "import json;json.dump({'cwd':['oops']},open(r'$PDATA/aaa-inline/handoff-sidA.json','w'))" +# to_native: the path is interpolated INTO the python -c code, so MSYS does not +# translate it (unlike an argv path); without this, Windows python resolves the +# /tmp/... MSYS path to a non-existent C:\tmp\... and the malformed fixture is +# never written, making this test pass for the wrong reason. +"$PY" -c "import json;json.dump({'cwd':['oops']},open(r'$(to_native "$PDATA/aaa-inline/handoff-sidA.json")','w'))" write_handoff "zzz-inline" "sidA" "C:/proj/one" run_resolve_f "sidA" "C:/proj/one" assert_eq "T-46b: non-string cwd skipped, scan continues -> HIT" "HIT" "$RSTATUS"