diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ca4fd3f..5e812ebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added ### Changed +- SessionStart hook now skips non-initial sessions (resume, compact/clear) by checking the `source` field in stdin JSON, reducing noise and redundant checks ### Fixed diff --git a/src/deepwork/hooks/check_version.sh b/src/deepwork/hooks/check_version.sh index f06d89a0..c02b052e 100755 --- a/src/deepwork/hooks/check_version.sh +++ b/src/deepwork/hooks/check_version.sh @@ -10,11 +10,54 @@ # # Uses hookSpecificOutput.additionalContext to pass messages to Claude's context. +# ============================================================================ +# READ STDIN INPUT +# ============================================================================ +# SessionStart hooks receive JSON input via stdin with session information. +# We need to read this to check the session source (startup, resume, clear). + +HOOK_INPUT="" +if [ ! -t 0 ]; then + HOOK_INPUT=$(cat) +fi + +# ============================================================================ +# SKIP NON-INITIAL SESSIONS +# ============================================================================ +# SessionStart hooks can be triggered for different reasons: +# - "startup": Initial session start (user ran `claude` or similar) +# - "resume": Session resumed (user ran `claude --resume`) +# - "clear": Context was cleared/compacted +# +# We only want to run the full check on initial startup. For resumed or +# compacted sessions, return immediately with empty JSON to avoid redundant +# checks and noise. + +get_session_source() { + # Extract the "source" field from the JSON input + # Returns empty string if not found or not valid JSON + if [ -n "$HOOK_INPUT" ]; then + # Use grep and sed for simple JSON parsing (avoid jq dependency) + echo "$HOOK_INPUT" | grep -o '"source"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:.*"\([^"]*\)"/\1/' | head -1 + fi +} + +SESSION_SOURCE=$(get_session_source) + +# If source is anything other than "startup" (or empty/missing for backwards compat), +# skip this hook entirely. Empty source means older Claude Code version that doesn't +# send the source field - we treat that as an initial session to maintain backwards compat. +if [ -n "$SESSION_SOURCE" ] && [ "$SESSION_SOURCE" != "startup" ]; then + # Non-initial session (resume, clear, etc.) - skip all checks + echo '{}' + exit 0 +fi + # ============================================================================ # DEEPWORK INSTALLATION CHECK (BLOCKING) # ============================================================================ -# This check runs on EVERY hook invocation (no re-entry guard) because if -# deepwork is not installed, nothing else will work. +# This check runs on initial session start because if deepwork is not installed, +# nothing else will work. check_deepwork_installed() { # Run 'deepwork rules clear_queue' instead of just '--version' for double utility: @@ -70,20 +113,10 @@ if ! check_deepwork_installed; then exit 2 # Blocking error - prevent session from continuing fi -# ============================================================================ -# RE-ENTRY GUARD (for version check only) -# ============================================================================ -# SessionStart hooks can be triggered multiple times in a session (on resume, -# clear, etc.). We only want to show the version warning once per session to -# avoid spamming the user. We use an environment variable to track whether -# we've already run. Note: This relies on the parent process preserving env -# vars across hook invocations within the same session. -if [ -n "$DEEPWORK_VERSION_CHECK_DONE" ]; then - # Already checked version this session, exit silently with empty JSON - echo '{}' - exit 0 -fi -export DEEPWORK_VERSION_CHECK_DONE=1 +# Note: We previously had a re-entry guard using DEEPWORK_VERSION_CHECK_DONE +# environment variable, but that was unreliable across session resumptions. +# Now we use the source field in the hook input JSON to detect initial sessions +# vs resumed/compacted sessions (see SKIP NON-INITIAL SESSIONS section above). # ============================================================================ # MINIMUM VERSION CONFIGURATION diff --git a/tests/shell_script_tests/test_check_version.py b/tests/shell_script_tests/test_check_version.py index 69fe8808..1cd4c20f 100644 --- a/tests/shell_script_tests/test_check_version.py +++ b/tests/shell_script_tests/test_check_version.py @@ -22,6 +22,7 @@ def run_check_version_with_mock_claude( mock_version: str | None, cwd: Path | None = None, mock_deepwork: bool = True, + stdin_json: str | None = None, ) -> tuple[str, str, int]: """ Run check_version.sh with a mocked claude command. @@ -32,6 +33,7 @@ def run_check_version_with_mock_claude( cwd: Working directory mock_deepwork: If True, create a mock deepwork command that succeeds. If False, do not create mock deepwork (simulates not installed). + stdin_json: Optional JSON string to pass via stdin (simulates hook input) Returns: Tuple of (stdout, stderr, return_code) @@ -66,6 +68,7 @@ def run_check_version_with_mock_claude( text=True, cwd=cwd or tmpdir, env=env, + input=stdin_json, ) return result.stdout, result.stderr, result.returncode @@ -290,3 +293,120 @@ def test_deepwork_check_happens_before_version_check(self, check_version_script: assert "DEEPWORK NOT INSTALLED" in stderr # Should NOT show version warning assert "CLAUDE CODE VERSION WARNING" not in stderr + + +class TestSessionSourceDetection: + """Tests for skipping non-initial sessions based on source field.""" + + def test_startup_source_runs_normally(self, check_version_script: Path) -> None: + """Test that source='startup' runs the full check.""" + import json + + stdin_json = json.dumps({"source": "startup", "session_id": "test123"}) + stdout, stderr, code = run_check_version_with_mock_claude( + check_version_script, "3.0.0", stdin_json=stdin_json + ) + + # Should run normally and output empty JSON (version OK) + assert code == 0 + assert stdout.strip() == "{}" + + def test_resume_source_skips_check(self, check_version_script: Path) -> None: + """Test that source='resume' skips all checks and returns empty JSON.""" + import json + + stdin_json = json.dumps({"source": "resume", "session_id": "test123"}) + stdout, stderr, code = run_check_version_with_mock_claude( + check_version_script, + "1.0.0", + stdin_json=stdin_json, # Low version that would trigger warning + ) + + # Should skip and return empty JSON without warnings + assert code == 0 + assert stdout.strip() == "{}" + assert "WARNING" not in stderr + assert "DEEPWORK" not in stderr + + def test_clear_source_skips_check(self, check_version_script: Path) -> None: + """Test that source='clear' (compact) skips all checks.""" + import json + + stdin_json = json.dumps({"source": "clear", "session_id": "test123"}) + stdout, stderr, code = run_check_version_with_mock_claude( + check_version_script, "1.0.0", stdin_json=stdin_json + ) + + # Should skip and return empty JSON + assert code == 0 + assert stdout.strip() == "{}" + assert "WARNING" not in stderr + + def test_no_source_field_runs_normally(self, check_version_script: Path) -> None: + """Test backwards compatibility: missing source field runs full check.""" + import json + + # JSON without source field (older Claude Code version) + stdin_json = json.dumps({"session_id": "test123"}) + stdout, stderr, code = run_check_version_with_mock_claude( + check_version_script, + "2.0.0", + stdin_json=stdin_json, # Low version + ) + + # Should run normally and show warning (backwards compat) + assert code == 0 + assert "WARNING" in stderr + + def test_empty_stdin_runs_normally(self, check_version_script: Path) -> None: + """Test that empty stdin runs full check (backwards compat).""" + stdout, stderr, code = run_check_version_with_mock_claude( + check_version_script, "2.0.0", stdin_json="" + ) + + # Should run normally and show warning + assert code == 0 + assert "WARNING" in stderr + + def test_resume_skips_even_with_missing_deepwork(self, check_version_script: Path) -> None: + """Test that resume sessions skip before deepwork check.""" + import json + + stdin_json = json.dumps({"source": "resume"}) + stdout, stderr, code = run_check_version_with_mock_claude( + check_version_script, "3.0.0", mock_deepwork=False, stdin_json=stdin_json + ) + + # Should skip immediately, NOT block on deepwork + assert code == 0 + assert stdout.strip() == "{}" + assert "DEEPWORK NOT INSTALLED" not in stderr + + def test_startup_with_low_version_shows_warning(self, check_version_script: Path) -> None: + """Test that startup sessions with low version show warning.""" + import json + + stdin_json = json.dumps({"source": "startup"}) + stdout, stderr, code = run_check_version_with_mock_claude( + check_version_script, "2.0.0", stdin_json=stdin_json + ) + + # Should run full check and show warning + assert code == 0 + assert "WARNING" in stderr + assert "hookSpecificOutput" in stdout + + def test_unknown_source_skips_check(self, check_version_script: Path) -> None: + """Test that unknown source values skip the check.""" + import json + + # Future-proofing: unknown source values should be treated as non-startup + stdin_json = json.dumps({"source": "unknown_future_value"}) + stdout, stderr, code = run_check_version_with_mock_claude( + check_version_script, "1.0.0", stdin_json=stdin_json + ) + + # Should skip and return empty JSON + assert code == 0 + assert stdout.strip() == "{}" + assert "WARNING" not in stderr