diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d35b782..4e03998 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.1", + "version": "3.0.2", "author": { "name": "Koen van der Heide", "url": "https://github.com/koenvdheide" diff --git a/CHANGELOG.md b/CHANGELOG.md index f18b3b3..eeeb800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ 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.2] - 2026-06-05 + +### Fixed + +- The Stop hook no longer records grep/glob result lines that carry shell code (e.g. a context line `SKILL="$DIR/x.sh"`) as file paths in the handoff. Only clean path tokens are captured. + ## [3.0.1] - 2026-06-05 ### Fixed diff --git a/hooks/update-handoff.sh b/hooks/update-handoff.sh index 7e7a05f..996ffcc 100644 --- a/hooks/update-handoff.sh +++ b/hooks/update-handoff.sh @@ -193,6 +193,12 @@ for entry in reversed(parsed): # shaped lines. Walk newest-first parity with Tier-A; extract path TOKEN (not # whole line) so "src/foo.ts:12:match" becomes "src/foo.ts"; handle Windows drive letters. +# A resolved path token never carries quote/$/backtick/redirect/glob punctuation. +# Without this, grep CONTEXT lines like '357-SKILL="$DIR/skills/x.sh"' slip through +# as "paths". '=' and "'" are intentionally NOT rejected -- they occur in real +# filenames (a=b.json, John's-note.md) and the set below still catches the junk. +_PATH_TOKEN_JUNK = set('"' + '`' + '$<>|*?') + def first_path_token(line): if not line: return None @@ -209,6 +215,8 @@ def first_path_token(line): last_sep = max(candidate.rfind('/'), candidate.rfind('\\')) tail = candidate[last_sep+1:] if '.' in tail and len(tail) > 1: + if any(ch in _PATH_TOKEN_JUNK for ch in candidate): + return None return candidate return None diff --git a/test/run-tests.sh b/test/run-tests.sh index abda18b..6001ad9 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=127 +EXPECTED_PASS=130 SKIPPED=0 run_hook() { @@ -731,8 +731,30 @@ HANDOFF=$(to_native "$CACHE/handoff-s55.json") assert_eq "T-55: prior real request kept" "yes" "$("$PY" -c "import json;d=json.load(open('$HANDOFF'));print('yes' if any('real prior intent' in q for q in d['recent_user_requests']) else 'no')")" assert_eq "T-55: prior injected purged" "no" "$("$PY" -c "import json;d=json.load(open('$HANDOFF'));print('yes' if any('task-notification' in q for q in d['recent_user_requests']) else 'no')")" +# T-56: Tier-B grep-result extraction rejects shell/code lines. Regression: a grep +# CONTEXT line "357-SKILL=\"\$DIR/../skills/prep-compact/SKILL.md\"" was captured as a path. +cleanup +JUNK_LINE='357-SKILL="$SCRIPT_DIR/../skills/prep-compact/SKILL.md"' \ +REAL_LINE='src/real/keep.ts:42:SKILL' \ +LEGIT_LINE='fixtures/a=b.json:1:match' \ +FIX_ENV="$FIX" "$PY" -c " +import json, os +content = os.environ['JUNK_LINE'] + '\n' + os.environ['REAL_LINE'] + '\n' + os.environ['LEGIT_LINE'] + '\n' +turns = [ + {'message':{'role':'assistant','content':[{'type':'tool_use','id':'gj1','name':'Grep','input':{'pattern':'SKILL','output_mode':'content'}}],'usage':{'input_tokens':100,'cache_creation_input_tokens':1000,'cache_read_input_tokens':0}}}, + {'message':{'role':'user','content':[{'type':'tool_result','tool_use_id':'gj1','content':content}]}}, +] +with open(os.environ['FIX_ENV']+'/t56.jsonl','w') as f: + for t in turns: f.write(json.dumps(t)+'\n') +" +run_stop_hook '{"session_id":"s56","transcript_path":"'"$FIX/t56.jsonl"'","cwd":"/sample","permission_mode":"default","hook_event_name":"Stop"}' >/dev/null +HANDOFF=$(to_native "$CACHE/handoff-s56.json") +assert_eq "T-56: real grep path kept" "yes" "$("$PY" -c "import json;d=json.load(open('$HANDOFF'));print('yes' if any('src/real/keep.ts' in p for p in d['recent_files']) else 'no')")" +assert_eq "T-56: legit =-char path kept" "yes" "$("$PY" -c "import json;d=json.load(open('$HANDOFF'));print('yes' if any('a=b.json' in p for p in d['recent_files']) else 'no')")" +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=56 + SKIPPED=59 fi # STOP_FIXTURE_OK # ===================================================================