Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 49 additions & 16 deletions src/deepwork/hooks/check_version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions tests/shell_script_tests/test_check_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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