diff --git a/.deepwork/jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh b/.deepwork/jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh deleted file mode 100755 index c9cedd82..00000000 --- a/.deepwork/jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash -# capture_prompt_work_tree.sh - Captures the git work tree state at prompt submission -# -# This script creates a snapshot of ALL tracked files at the time the prompt -# is submitted. This baseline is used for rules with compare_to: prompt and -# created: mode to detect truly NEW files (not modifications to existing ones). -# -# The baseline contains ALL tracked files (not just changed files) so that -# the rules_check hook can determine which files are genuinely new vs which -# files existed before and were just modified. -# -# It also captures the HEAD commit ref so that committed changes can be detected -# by comparing HEAD at Stop time to the captured ref. - -set -e - -# Ensure .deepwork directory exists -mkdir -p .deepwork - -# Save the current HEAD commit ref for detecting committed changes -# This is used by get_changed_files_prompt() to detect files changed since prompt, -# even if those changes were committed during the agent response. -git rev-parse HEAD > .deepwork/.last_head_ref 2>/dev/null || echo "" > .deepwork/.last_head_ref - -# Save ALL tracked files (not just changed files) -# This is critical for created: mode rules to distinguish between: -# - Newly created files (not in baseline) -> should trigger created: rules -# - Modified existing files (in baseline) -> should NOT trigger created: rules -git ls-files > .deepwork/.last_work_tree 2>/dev/null || true - -# Also include untracked files that exist at prompt time -# These are files the user may have created before submitting the prompt -git ls-files --others --exclude-standard >> .deepwork/.last_work_tree 2>/dev/null || true - -# Sort and deduplicate -if [ -f .deepwork/.last_work_tree ]; then - sort -u .deepwork/.last_work_tree -o .deepwork/.last_work_tree -fi diff --git a/.deepwork/jobs/deepwork_rules/hooks/global_hooks.yml b/.deepwork/jobs/deepwork_rules/hooks/global_hooks.yml index a310d31a..ee280632 100644 --- a/.deepwork/jobs/deepwork_rules/hooks/global_hooks.yml +++ b/.deepwork/jobs/deepwork_rules/hooks/global_hooks.yml @@ -1,8 +1,11 @@ # DeepWork Rules Hooks Configuration # Maps lifecycle events to hook scripts or Python modules +# +# All hooks use Python modules for cross-platform compatibility (Windows, macOS, Linux). +# The module syntax ensures hooks work regardless of how DeepWork was installed. UserPromptSubmit: - - user_prompt_submit.sh + - module: deepwork.hooks.user_prompt_submit Stop: - module: deepwork.hooks.rules_check diff --git a/.deepwork/jobs/deepwork_rules/hooks/user_prompt_submit.sh b/.deepwork/jobs/deepwork_rules/hooks/user_prompt_submit.sh deleted file mode 100755 index 486ad836..00000000 --- a/.deepwork/jobs/deepwork_rules/hooks/user_prompt_submit.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash -# user_prompt_submit.sh - Runs on every user prompt submission -# -# This script captures the work tree state at each prompt submission. -# This baseline is used for policies with compare_to: prompt to detect -# what changed during an agent response. - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Capture work tree state at each prompt for compare_to: prompt policies -"${SCRIPT_DIR}/capture_prompt_work_tree.sh" - -# Exit successfully - don't block the prompt -exit 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fbcd172..cde1c9db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Meta-skill template reorganized to show "Workflows" and "Standalone Skills" sections separately - Updated `deepwork_jobs` standard job to v1.0.0 with explicit `new_job` workflow - SessionStart hook now skips non-initial sessions (resume, compact/clear) by checking the `source` field in stdin JSON, reducing noise and redundant checks +- Converted bash hook scripts to Python modules for cross-platform compatibility + - `user_prompt_submit.sh` replaced by `deepwork.hooks.user_prompt_submit` Python module + - `capture_prompt_work_tree.sh` replaced by `deepwork.hooks.capture_prompt` Python module + - Added `hook_entry.py` for cross-platform hook invocation + - Updated `global_hooks.yml` to use module references instead of shell scripts + - Hooks now work on Windows, macOS, and Linux without requiring bash ### Fixed - Fixed skill template generating malformed YAML frontmatter with fields concatenated on single lines @@ -28,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Affects `src/deepwork/templates/claude/skill-job-step.md.jinja` ### Removed +- Removed deprecated bash hook scripts (`user_prompt_submit.sh`, `capture_prompt_work_tree.sh`) +- Removed `make_new_job.sh` permission from Claude adapter (no longer needed) ## [0.5.1] - 2026-01-24 diff --git a/doc/architecture.md b/doc/architecture.md index 879481e3..05a9a8c3 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -52,12 +52,13 @@ deepwork/ # DeepWork tool repository │ │ ├── rules_queue.py # Rule state queue system │ │ ├── command_executor.py # Command action execution │ │ └── hooks_syncer.py # Hook syncing to platforms -│ ├── hooks/ # Hook system and cross-platform wrappers +│ ├── hooks/ # Hook system (cross-platform Python modules) │ │ ├── __init__.py │ │ ├── wrapper.py # Cross-platform input/output normalization -│ │ ├── claude_hook.sh # Shell wrapper for Claude Code -│ │ ├── gemini_hook.sh # Shell wrapper for Gemini CLI -│ │ └── rules_check.py # Cross-platform rule evaluation hook +│ │ ├── rules_check.py # Cross-platform rule evaluation hook +│ │ ├── user_prompt_submit.py # Captures work tree on prompt submission +│ │ ├── capture_prompt.py # Git work tree state capture utility +│ │ └── hook_entry.py # Cross-platform hook entry point │ ├── templates/ # Skill templates for each platform │ │ ├── claude/ │ │ │ └── skill-job-step.md.jinja @@ -73,10 +74,8 @@ deepwork/ # DeepWork tool repository │ │ ├── job.yml │ │ ├── steps/ │ │ │ └── define.md -│ │ └── hooks/ # Hook scripts -│ │ ├── global_hooks.yml -│ │ ├── user_prompt_submit.sh -│ │ └── capture_prompt_work_tree.sh +│ │ └── hooks/ # Hook configuration +│ │ └── global_hooks.yml # Maps events to Python modules │ ├── schemas/ # Definition schemas │ │ ├── job_schema.py │ │ ├── doc_spec_schema.py # Doc spec schema definition @@ -310,10 +309,8 @@ my-project/ # User's project (target) │ │ ├── job.yml │ │ ├── steps/ │ │ │ └── define.md -│ │ └── hooks/ # Hook scripts (installed from standard_jobs) -│ │ ├── global_hooks.yml -│ │ ├── user_prompt_submit.sh -│ │ └── capture_prompt_work_tree.sh +│ │ └── hooks/ # Hook configuration (installed from standard_jobs) +│ │ └── global_hooks.yml # Maps events to Python modules │ ├── competitive_research/ │ │ ├── job.yml # Job metadata │ │ └── steps/ @@ -1142,19 +1139,20 @@ This prevents re-prompting for the same rule violation within a session. ### Hook Integration -The v2 rules system uses the cross-platform hook wrapper: +The v2 rules system uses cross-platform Python hooks: ``` src/deepwork/hooks/ -├── wrapper.py # Cross-platform input/output normalization -├── rules_check.py # Rule evaluation hook (v2) -├── claude_hook.sh # Claude Code shell wrapper -└── gemini_hook.sh # Gemini CLI shell wrapper +├── wrapper.py # Cross-platform input/output normalization +├── rules_check.py # Rule evaluation hook (v2) +├── user_prompt_submit.py # Captures work tree on prompt submission +├── capture_prompt.py # Git work tree state capture utility +└── hook_entry.py # Cross-platform hook entry point ``` -Hooks are called via the shell wrappers: +Hooks are invoked via the `deepwork hook` CLI command: ```bash -claude_hook.sh deepwork.hooks.rules_check +deepwork hook rules_check ``` The hooks are installed to `.claude/settings.json` during `deepwork sync`: @@ -1169,26 +1167,25 @@ The hooks are installed to `.claude/settings.json` during `deepwork sync`: } ``` -### Cross-Platform Hook Wrapper System +### Cross-Platform Hook System -The `hooks/` module provides a wrapper system that allows writing hooks once in Python and running them on multiple platforms. This normalizes the differences between Claude Code and Gemini CLI hook systems. +The `hooks/` module provides a cross-platform hook system that works on Windows, macOS, and Linux without requiring bash. Hooks are written in Python and invoked via the `deepwork hook` CLI command. **Architecture:** ``` ┌─────────────────┐ ┌─────────────────┐ │ Claude Code │ │ Gemini CLI │ │ (Stop event) │ │ (AfterAgent) │ -└────────┬────────┘ └────────┬────────┘ - │ │ - ▼ ▼ -┌─────────────────┐ ┌─────────────────┐ -│ claude_hook.sh │ │ gemini_hook.sh │ -│ (shell wrapper) │ │ (shell wrapper) │ └────────┬────────┘ └────────┬────────┘ │ │ └───────────┬───────────┘ ▼ ┌─────────────────┐ + │ deepwork hook │ + │ (CLI entry) │ + └────────┬────────┘ + ▼ + ┌─────────────────┐ │ wrapper.py │ │ (normalization) │ └────────┬────────┘ @@ -1214,7 +1211,7 @@ def my_hook(input: HookInput) -> HookOutput: return HookOutput(decision="block", reason="Complete X first") return HookOutput() -# Called via: claude_hook.sh mymodule or gemini_hook.sh mymodule +# Called via: deepwork hook mymodule ``` See `doc/platforms/` for detailed platform-specific hook documentation. diff --git a/src/deepwork/core/hooks_syncer.py b/src/deepwork/core/hooks_syncer.py index 35a01036..5bd54421 100644 --- a/src/deepwork/core/hooks_syncer.py +++ b/src/deepwork/core/hooks_syncer.py @@ -28,6 +28,10 @@ def get_command(self, project_path: Path) -> str: """ Get the command to run this hook. + This generates a cross-platform command that works on Windows, macOS, and Linux. + For module-based hooks, uses `deepwork hook ` which works everywhere. + For script-based hooks, uses forward slashes (works in bash on all platforms). + Args: project_path: Path to project root @@ -43,10 +47,15 @@ def get_command(self, project_path: Path) -> str: # Script path is: .deepwork/jobs/{job_name}/hooks/{script} script_path = self.job_dir / "hooks" / self.script try: - return str(script_path.relative_to(project_path)) + rel_path = script_path.relative_to(project_path) except ValueError: - # If not relative, return the full path - return str(script_path) + # If not relative, use the full path + rel_path = script_path + + # Always use forward slashes for cross-platform compatibility + # Claude Code runs hooks via bash (even on Windows via Git Bash/WSL) + # and bash expects forward slashes + return str(rel_path).replace("\\", "/") else: raise ValueError("HookEntry must have either script or module") diff --git a/src/deepwork/hooks/__init__.py b/src/deepwork/hooks/__init__.py index 5e9d8d43..81334419 100644 --- a/src/deepwork/hooks/__init__.py +++ b/src/deepwork/hooks/__init__.py @@ -2,41 +2,36 @@ This package provides: -1. Cross-platform hook wrapper system: +1. Cross-platform hook system (Windows, macOS, Linux): - wrapper.py: Normalizes input/output between Claude Code and Gemini CLI - - claude_hook.sh: Shell wrapper for Claude Code hooks - - gemini_hook.sh: Shell wrapper for Gemini CLI hooks + - All hooks use Python modules for cross-platform compatibility 2. Hook implementations: - rules_check.py: Evaluates rules on after_agent events + - user_prompt_submit.py: Captures work tree state on prompt submission + - capture_prompt.py: Git work tree state capture utility -Usage with wrapper system: - # Register hook in .claude/settings.json: +Usage: + # Hooks are registered in .claude/settings.json by `deepwork sync`: { "hooks": { "Stop": [{ "hooks": [{ "type": "command", - "command": ".deepwork/hooks/claude_hook.sh rules_check" + "command": "deepwork hook rules_check" }] - }] - } - } - - # Register hook in .gemini/settings.json: - { - "hooks": { - "AfterAgent": [{ + }], + "UserPromptSubmit": [{ "hooks": [{ "type": "command", - "command": ".gemini/hooks/gemini_hook.sh rules_check" + "command": "deepwork hook user_prompt_submit" }] }] } } -The shell wrappers call `deepwork hook ` which works regardless -of how deepwork was installed (pipx, uv, nix flake, etc.). +The `deepwork hook ` command works on all platforms regardless +of how deepwork was installed (pip, pipx, uv, Windows EXE, etc.). Writing custom hooks: from deepwork.hooks.wrapper import ( diff --git a/src/deepwork/hooks/capture_prompt.py b/src/deepwork/hooks/capture_prompt.py new file mode 100644 index 00000000..1062907c --- /dev/null +++ b/src/deepwork/hooks/capture_prompt.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +capture_prompt.py - Captures the git work tree state at prompt submission. + +This is the cross-platform Python equivalent of capture_prompt_work_tree.sh. + +This script creates a snapshot of ALL tracked files at the time the prompt +is submitted. This baseline is used for rules with compare_to: prompt and +created: mode to detect truly NEW files (not modifications to existing ones). + +The baseline contains ALL tracked files (not just changed files) so that +the rules_check hook can determine which files are genuinely new vs which +files existed before and were just modified. + +It also captures the HEAD commit ref so that committed changes can be detected +by comparing HEAD at Stop time to the captured ref. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def capture_work_tree(project_dir: Path | None = None) -> int: + """ + Capture the current git work tree state. + + Args: + project_dir: Project directory (default: current directory) + + Returns: + 0 on success, non-zero on error + """ + if project_dir is None: + project_dir = Path.cwd() + else: + project_dir = Path(project_dir) + + deepwork_dir = project_dir / ".deepwork" + + # Ensure .deepwork directory exists + deepwork_dir.mkdir(parents=True, exist_ok=True) + + # Save the current HEAD commit ref for detecting committed changes + head_ref_file = deepwork_dir / ".last_head_ref" + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + text=True, + cwd=str(project_dir), + check=False, + ) + head_ref = result.stdout.strip() if result.returncode == 0 else "" + except Exception: + head_ref = "" + + head_ref_file.write_text(head_ref + "\n", encoding="utf-8") + + # Get all tracked files + tracked_files: set[str] = set() + try: + result = subprocess.run( + ["git", "ls-files"], + capture_output=True, + text=True, + cwd=str(project_dir), + check=False, + ) + if result.returncode == 0: + tracked_files.update(line for line in result.stdout.strip().split("\n") if line) + except Exception: + pass + + # Also include untracked files that exist at prompt time + # These are files the user may have created before submitting the prompt + try: + result = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard"], + capture_output=True, + text=True, + cwd=str(project_dir), + check=False, + ) + if result.returncode == 0: + tracked_files.update(line for line in result.stdout.strip().split("\n") if line) + except Exception: + pass + + # Sort and write to file + work_tree_file = deepwork_dir / ".last_work_tree" + sorted_files = sorted(tracked_files) + work_tree_file.write_text("\n".join(sorted_files) + "\n" if sorted_files else "", encoding="utf-8") + + return 0 + + +def main() -> int: + """Main entry point for the capture_prompt hook.""" + import json + import os + + # Try to get project directory from environment or stdin + project_dir = os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("GEMINI_PROJECT_DIR") + + # Also try to read from stdin (hook input JSON) + if not sys.stdin.isatty(): + try: + input_data = json.load(sys.stdin) + if not project_dir: + project_dir = input_data.get("cwd") + except (json.JSONDecodeError, EOFError): + pass + + if project_dir: + return capture_work_tree(Path(project_dir)) + else: + return capture_work_tree() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/deepwork/hooks/hook_entry.py b/src/deepwork/hooks/hook_entry.py new file mode 100644 index 00000000..6cf434c2 --- /dev/null +++ b/src/deepwork/hooks/hook_entry.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +""" +Cross-platform hook entry point for DeepWork. + +This module provides a cross-platform way to invoke DeepWork hooks +that works on Windows, macOS, and Linux without requiring bash. + +Can be invoked directly: + python -m deepwork.hooks.hook_entry rules_check + +Or via the CLI: + deepwork hook rules_check +""" + +from __future__ import annotations + +import sys + + +def main() -> int: + """Main entry point for hook invocation.""" + # Import here to avoid circular imports and speed up module load + from deepwork.cli.hook import hook + + # Get hook name from command line + if len(sys.argv) < 2: + print("Usage: python -m deepwork.hooks.hook_entry ", file=sys.stderr) + print("Example: python -m deepwork.hooks.hook_entry rules_check", file=sys.stderr) + return 1 + + hook_name = sys.argv[1] + + # Click expects sys.argv[0] to be the command name + # We simulate: deepwork hook + sys.argv = ["deepwork", hook_name] + + try: + # Invoke the hook command with standalone_mode=False to get the return value + result = hook.main(args=[hook_name], standalone_mode=False) + return result if isinstance(result, int) else 0 + except SystemExit as e: + return e.code if isinstance(e.code, int) else 0 + except Exception as e: + print(f"Hook execution failed: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/deepwork/hooks/user_prompt_submit.py b/src/deepwork/hooks/user_prompt_submit.py new file mode 100644 index 00000000..dbfb3500 --- /dev/null +++ b/src/deepwork/hooks/user_prompt_submit.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +""" +user_prompt_submit.py - Runs on every user prompt submission. + +This is the cross-platform Python equivalent of user_prompt_submit.sh. + +This hook captures the work tree state at each prompt submission. +This baseline is used for policies with compare_to: prompt to detect +what changed during an agent response. +""" + +from __future__ import annotations + +import sys + +from deepwork.hooks.capture_prompt import main as capture_prompt_main + + +def main() -> int: + """Main entry point for user_prompt_submit hook.""" + # Capture work tree state at each prompt for compare_to: prompt policies + result = capture_prompt_main() + + # Exit successfully - don't block the prompt + # Even if capture fails, we don't want to block the user + return 0 if result == 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh b/src/deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh deleted file mode 100755 index c9cedd82..00000000 --- a/src/deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash -# capture_prompt_work_tree.sh - Captures the git work tree state at prompt submission -# -# This script creates a snapshot of ALL tracked files at the time the prompt -# is submitted. This baseline is used for rules with compare_to: prompt and -# created: mode to detect truly NEW files (not modifications to existing ones). -# -# The baseline contains ALL tracked files (not just changed files) so that -# the rules_check hook can determine which files are genuinely new vs which -# files existed before and were just modified. -# -# It also captures the HEAD commit ref so that committed changes can be detected -# by comparing HEAD at Stop time to the captured ref. - -set -e - -# Ensure .deepwork directory exists -mkdir -p .deepwork - -# Save the current HEAD commit ref for detecting committed changes -# This is used by get_changed_files_prompt() to detect files changed since prompt, -# even if those changes were committed during the agent response. -git rev-parse HEAD > .deepwork/.last_head_ref 2>/dev/null || echo "" > .deepwork/.last_head_ref - -# Save ALL tracked files (not just changed files) -# This is critical for created: mode rules to distinguish between: -# - Newly created files (not in baseline) -> should trigger created: rules -# - Modified existing files (in baseline) -> should NOT trigger created: rules -git ls-files > .deepwork/.last_work_tree 2>/dev/null || true - -# Also include untracked files that exist at prompt time -# These are files the user may have created before submitting the prompt -git ls-files --others --exclude-standard >> .deepwork/.last_work_tree 2>/dev/null || true - -# Sort and deduplicate -if [ -f .deepwork/.last_work_tree ]; then - sort -u .deepwork/.last_work_tree -o .deepwork/.last_work_tree -fi diff --git a/src/deepwork/standard_jobs/deepwork_rules/hooks/global_hooks.yml b/src/deepwork/standard_jobs/deepwork_rules/hooks/global_hooks.yml index a310d31a..ee280632 100644 --- a/src/deepwork/standard_jobs/deepwork_rules/hooks/global_hooks.yml +++ b/src/deepwork/standard_jobs/deepwork_rules/hooks/global_hooks.yml @@ -1,8 +1,11 @@ # DeepWork Rules Hooks Configuration # Maps lifecycle events to hook scripts or Python modules +# +# All hooks use Python modules for cross-platform compatibility (Windows, macOS, Linux). +# The module syntax ensures hooks work regardless of how DeepWork was installed. UserPromptSubmit: - - user_prompt_submit.sh + - module: deepwork.hooks.user_prompt_submit Stop: - module: deepwork.hooks.rules_check diff --git a/src/deepwork/standard_jobs/deepwork_rules/hooks/user_prompt_submit.sh b/src/deepwork/standard_jobs/deepwork_rules/hooks/user_prompt_submit.sh deleted file mode 100755 index 486ad836..00000000 --- a/src/deepwork/standard_jobs/deepwork_rules/hooks/user_prompt_submit.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash -# user_prompt_submit.sh - Runs on every user prompt submission -# -# This script captures the work tree state at each prompt submission. -# This baseline is used for policies with compare_to: prompt to detect -# what changed during an agent response. - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Capture work tree state at each prompt for compare_to: prompt policies -"${SCRIPT_DIR}/capture_prompt_work_tree.sh" - -# Exit successfully - don't block the prompt -exit 0 diff --git a/src/deepwork/templates/claude/settings.json b/src/deepwork/templates/claude/settings.json index 97d5d1be..6bca7b9e 100644 --- a/src/deepwork/templates/claude/settings.json +++ b/src/deepwork/templates/claude/settings.json @@ -5,7 +5,6 @@ "Edit(./.deepwork/**)", "Write(./.deepwork/**)", "Bash(deepwork:*)", - "Bash(./.deepwork/jobs/deepwork_jobs/make_new_job.sh:*)", "WebSearch" ] } diff --git a/tests/unit/test_cross_platform_hooks.py b/tests/unit/test_cross_platform_hooks.py new file mode 100644 index 00000000..4f8b879c --- /dev/null +++ b/tests/unit/test_cross_platform_hooks.py @@ -0,0 +1,119 @@ +"""Tests for cross-platform hook implementations.""" + +import json +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + + +class TestCapturePromptHook: + """Tests for the capture_prompt hook.""" + + def test_capture_work_tree(self, tmp_path: Path) -> None: + """Test capture_work_tree creates expected files.""" + from deepwork.hooks.capture_prompt import capture_work_tree + + # Initialize a git repo with signing disabled + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=tmp_path, + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=tmp_path, + capture_output=True, + check=True, + ) + # Disable commit signing for this test + subprocess.run( + ["git", "config", "commit.gpgsign", "false"], + cwd=tmp_path, + capture_output=True, + check=True, + ) + + # Create a tracked file + (tmp_path / "test.txt").write_text("hello") + subprocess.run(["git", "add", "test.txt"], cwd=tmp_path, capture_output=True, check=True) + subprocess.run( + ["git", "commit", "-m", "initial", "--no-gpg-sign"], + cwd=tmp_path, + capture_output=True, + check=True, + ) + + # Run capture + result = capture_work_tree(tmp_path) + + # Verify files were created + assert result == 0 + assert (tmp_path / ".deepwork" / ".last_head_ref").exists() + assert (tmp_path / ".deepwork" / ".last_work_tree").exists() + + # Verify content + work_tree = (tmp_path / ".deepwork" / ".last_work_tree").read_text() + assert "test.txt" in work_tree + + +class TestUserPromptSubmitHook: + """Tests for the user_prompt_submit hook.""" + + def test_main_returns_zero(self, tmp_path: Path) -> None: + """Test main returns 0 even if capture fails.""" + from deepwork.hooks.user_prompt_submit import main + + # Set up environment to use tmp_path + with patch.dict("os.environ", {"CLAUDE_PROJECT_DIR": str(tmp_path)}): + # Provide empty JSON input + with patch("sys.stdin.isatty", return_value=True): + result = main() + + # Should succeed (returns 0 even on errors to not block prompt) + assert result == 0 + + +class TestHookCommandFormat: + """Tests for hook command generation.""" + + def test_module_hook_command_is_cross_platform(self, tmp_path: Path) -> None: + """Test that module hooks generate cross-platform commands.""" + from deepwork.core.hooks_syncer import HookEntry + + entry = HookEntry( + job_name="test_job", + job_dir=tmp_path / ".deepwork" / "jobs" / "test_job", + module="deepwork.hooks.rules_check", + ) + + command = entry.get_command(tmp_path) + + # Should use deepwork CLI, not bash scripts + assert command == "deepwork hook rules_check" + assert ".sh" not in command + + def test_script_hook_uses_forward_slashes(self, tmp_path: Path) -> None: + """Test that script hooks use forward slashes for cross-platform compatibility.""" + from deepwork.core.hooks_syncer import HookEntry + + job_dir = tmp_path / ".deepwork" / "jobs" / "test_job" + job_dir.mkdir(parents=True) + (job_dir / "hooks").mkdir() + (job_dir / "hooks" / "test.sh").write_text("#!/bin/bash\nexit 0") + + entry = HookEntry( + job_name="test_job", + job_dir=job_dir, + script="test.sh", + ) + + command = entry.get_command(tmp_path) + + # Should use forward slashes, even on Windows-style paths + assert "\\" not in command + assert "/" in command