diff --git a/.claude/hooks/crosslink_config.py b/.claude/hooks/crosslink_config.py new file mode 100644 index 0000000..2f8350f --- /dev/null +++ b/.claude/hooks/crosslink_config.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +""" +Shared configuration and utility functions for crosslink Claude Code hooks. + +This module is deployed to .claude/hooks/crosslink_config.py by `crosslink init` +and imported by the other hook scripts (work-check.py, prompt-guard.py, etc.). +""" + +import json +import os +import subprocess + + +def project_root_from_script(): + """Derive project root from this module's location (.claude/hooks/ -> project root).""" + try: + return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + except (NameError, OSError): + return None + + +def get_project_root(): + """Get the project root directory. + + Prefers deriving from the hook script's own path (works even when cwd is a + subdirectory), falling back to cwd. + """ + root = project_root_from_script() + if root and os.path.isdir(root): + return root + return os.getcwd() + + +def _resolve_main_repo_root(start_dir): + """Resolve the main repository root when running inside a git worktree. + + Compares `git rev-parse --git-common-dir` with `--git-dir`. If they + differ, we're in a worktree and the main repo root is the parent of + git-common-dir. Returns None if not in a git repo. + """ + try: + common = subprocess.run( + ["git", "-C", start_dir, "rev-parse", "--git-common-dir"], + capture_output=True, text=True, timeout=3 + ) + git_dir = subprocess.run( + ["git", "-C", start_dir, "rev-parse", "--git-dir"], + capture_output=True, text=True, timeout=3 + ) + if common.returncode != 0 or git_dir.returncode != 0: + return None + + common_path = os.path.realpath( + common.stdout.strip() if os.path.isabs(common.stdout.strip()) + else os.path.join(start_dir, common.stdout.strip()) + ) + git_dir_path = os.path.realpath( + git_dir.stdout.strip() if os.path.isabs(git_dir.stdout.strip()) + else os.path.join(start_dir, git_dir.stdout.strip()) + ) + + if common_path != git_dir_path: + # In a worktree โ€” parent of git-common-dir is the main repo root + return os.path.dirname(common_path) + return start_dir + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return None + + +def find_crosslink_dir(): + """Find the .crosslink directory. + + Prefers the project root derived from the hook script's own path + (reliable even when cwd is a subdirectory), falling back to walking + up from cwd, then checking if we're in a git worktree and looking + in the main repo root. + """ + # Primary: resolve from script location + root = project_root_from_script() + if root: + candidate = os.path.join(root, '.crosslink') + if os.path.isdir(candidate): + return candidate + + # Fallback: walk up from cwd + current = os.getcwd() + start = current + for _ in range(10): + candidate = os.path.join(current, '.crosslink') + if os.path.isdir(candidate): + return candidate + parent = os.path.dirname(current) + if parent == current: + break + current = parent + + # Last resort: check if we're in a git worktree and look in the main repo + main_root = _resolve_main_repo_root(start) + if main_root: + candidate = os.path.join(main_root, '.crosslink') + if os.path.isdir(candidate): + return candidate + + return None + + +def _merge_with_extend(base, override): + """Merge *override* into *base* with array-extend support. + + Keys in *override* that start with ``+`` are treated as array-extend + directives: their values are appended to the corresponding base array + (with the ``+`` stripped from the key name). For example:: + + base: {"allowed_bash_prefixes": ["ls", "pwd"]} + override: {"+allowed_bash_prefixes": ["my-tool"]} + result: {"allowed_bash_prefixes": ["ls", "pwd", "my-tool"]} + + If the base has no matching key, the override value is used as-is. + If the ``+``-prefixed value is not a list, it replaces like a normal key. + Keys without a ``+`` prefix replace the base value (backward compatible). + """ + for key, value in override.items(): + if key.startswith("+"): + real_key = key[1:] + if isinstance(value, list) and isinstance(base.get(real_key), list): + base[real_key] = base[real_key] + value + else: + base[real_key] = value + else: + base[key] = value + return base + + +def load_config_merged(crosslink_dir): + """Load hook-config.json, then merge hook-config.local.json on top. + + Supports the ``+key`` convention for extending arrays rather than + replacing them. See ``_merge_with_extend`` for details. + + Returns the merged dict, or {} if neither file exists. + """ + if not crosslink_dir: + return {} + + config = {} + config_path = os.path.join(crosslink_dir, "hook-config.json") + if os.path.isfile(config_path): + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + except (json.JSONDecodeError, OSError): + pass + + local_path = os.path.join(crosslink_dir, "hook-config.local.json") + if os.path.isfile(local_path): + try: + with open(local_path, "r", encoding="utf-8") as f: + local = json.load(f) + _merge_with_extend(config, local) + except (json.JSONDecodeError, OSError): + pass + + return config + + +def load_tracking_mode(crosslink_dir): + """Read tracking_mode from merged config. Defaults to 'strict'.""" + config = load_config_merged(crosslink_dir) + mode = config.get("tracking_mode", "strict") + if mode in ("strict", "normal", "relaxed"): + return mode + return "strict" + + +def find_crosslink_binary(crosslink_dir): + """Find the crosslink binary, checking config, PATH, and common locations.""" + import shutil + + # 1. Check hook-config.json (+ local override) for explicit path + config = load_config_merged(crosslink_dir) + bin_path = config.get("crosslink_binary") + if bin_path and os.path.isfile(bin_path) and os.access(bin_path, os.X_OK): + return bin_path + + # 2. Check PATH + found = shutil.which("crosslink") + if found: + return found + + # 3. Check common cargo install location + home = os.path.expanduser("~") + candidate = os.path.join(home, ".cargo", "bin", "crosslink") + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + + # 4. Check relative to project root (dev builds) + root = project_root_from_script() + if root: + for profile in ("release", "debug"): + candidate = os.path.join(root, "crosslink", "target", profile, "crosslink") + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + + return "crosslink" # fallback to PATH lookup + + +def load_guard_state(crosslink_dir): + """Read drift tracking state from .crosslink/.cache/guard-state.json. + + Returns a dict with keys: + prompts_since_crosslink (int) + total_prompts (int) + last_crosslink_at (str ISO timestamp or None) + last_reminder_at (str ISO timestamp or None) + """ + if not crosslink_dir: + return {"prompts_since_crosslink": 0, "total_prompts": 0, + "last_crosslink_at": None, "last_reminder_at": None} + state_path = os.path.join(crosslink_dir, ".cache", "guard-state.json") + try: + with open(state_path, "r", encoding="utf-8") as f: + state = json.load(f) + # Ensure required keys exist + state.setdefault("prompts_since_crosslink", 0) + state.setdefault("total_prompts", 0) + state.setdefault("last_crosslink_at", None) + state.setdefault("last_reminder_at", None) + return state + except (OSError, json.JSONDecodeError): + return {"prompts_since_crosslink": 0, "total_prompts": 0, + "last_crosslink_at": None, "last_reminder_at": None} + + +def save_guard_state(crosslink_dir, state): + """Write drift tracking state to .crosslink/.cache/guard-state.json.""" + if not crosslink_dir: + return + cache_dir = os.path.join(crosslink_dir, ".cache") + try: + os.makedirs(cache_dir, exist_ok=True) + state_path = os.path.join(cache_dir, "guard-state.json") + with open(state_path, "w", encoding="utf-8") as f: + json.dump(state, f) + except OSError: + pass + + +def reset_drift_counter(crosslink_dir): + """Reset the drift counter (agent just used crosslink).""" + if not crosslink_dir: + return + from datetime import datetime + state = load_guard_state(crosslink_dir) + state["prompts_since_crosslink"] = 0 + state["last_crosslink_at"] = datetime.now().isoformat() + save_guard_state(crosslink_dir, state) + + +def is_agent_context(crosslink_dir): + """Check if we're running inside an agent worktree. + + Returns True if: + 1. .crosslink/agent.json exists (crosslink kickoff agent), OR + 2. CWD is inside a .claude/worktrees/ path (Claude Code sub-agent) + + Both types of agent get relaxed tracking mode so they can operate + autonomously without active crosslink issues or gated git commits. + """ + if not crosslink_dir: + return False + if os.path.isfile(os.path.join(crosslink_dir, "agent.json")): + return True + # Detect Claude Code sub-agent worktrees (Agent tool with isolation: "worktree") + try: + cwd = os.getcwd() + if "/.claude/worktrees/" in cwd: + return True + except OSError: + pass + return False + + +def normalize_git_command(command): + """Strip git global flags to extract the actual subcommand for matching. + + Git accepts flags like -C, --git-dir, --work-tree, -c before the + subcommand. This normalizes 'git -C /path push' to 'git push' so + that blocked/gated command matching can't be bypassed. + """ + import shlex + + try: + parts = shlex.split(command) + except ValueError: + return command + + if not parts or parts[0] != "git": + return command + + i = 1 + while i < len(parts): + # Flags that take a separate next argument + if parts[i] in ("-C", "--git-dir", "--work-tree", "-c") and i + 1 < len(parts): + i += 2 + # Flags with =value syntax + elif ( + parts[i].startswith("--git-dir=") + or parts[i].startswith("--work-tree=") + ): + i += 1 + else: + break + + if i < len(parts): + return "git " + " ".join(parts[i:]) + return command + + +_crosslink_bin = None + + +def run_crosslink(args, crosslink_dir=None): + """Run a crosslink command and return output.""" + global _crosslink_bin + if _crosslink_bin is None: + _crosslink_bin = find_crosslink_binary(crosslink_dir) + try: + result = subprocess.run( + [_crosslink_bin] + args, + capture_output=True, + text=True, + timeout=3 + ) + return result.stdout.strip() if result.returncode == 0 else None + except (subprocess.TimeoutExpired, FileNotFoundError, Exception): + return None diff --git a/.claude/hooks/heartbeat.py b/.claude/hooks/heartbeat.py new file mode 100644 index 0000000..2ff4d42 --- /dev/null +++ b/.claude/hooks/heartbeat.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +""" +PostToolUse hook that pushes agent heartbeats on a throttled interval. + +Fires on every tool call but only invokes `crosslink heartbeat` if at least +2 minutes have elapsed since the last push. This gives accurate liveness +detection: heartbeats flow when Claude is actively working, and stop when +it hangs โ€” which is exactly the staleness signal lock detection needs. +""" + +import json +import os +import subprocess +import sys +import time + +HEARTBEAT_INTERVAL_SECONDS = 120 # 2 minutes + + +def main(): + # Find .crosslink directory + cwd = os.getcwd() + crosslink_dir = None + current = cwd + for _ in range(10): + candidate = os.path.join(current, ".crosslink") + if os.path.isdir(candidate): + crosslink_dir = candidate + break + parent = os.path.dirname(current) + if parent == current: + break + current = parent + + if not crosslink_dir: + sys.exit(0) + + # Only push heartbeats if we're in an agent context (agent.json exists) + if not os.path.exists(os.path.join(crosslink_dir, "agent.json")): + sys.exit(0) + + # Throttle: check timestamp file + cache_dir = os.path.join(crosslink_dir, ".cache") + stamp_file = os.path.join(cache_dir, "last-heartbeat") + + now = time.time() + try: + if os.path.exists(stamp_file): + last = os.path.getmtime(stamp_file) + if now - last < HEARTBEAT_INTERVAL_SECONDS: + sys.exit(0) + except OSError: + pass + + # Update timestamp before pushing (avoid thundering herd on slow push) + try: + os.makedirs(cache_dir, exist_ok=True) + with open(stamp_file, "w") as f: + f.write(str(now)) + except OSError: + pass + + # Push heartbeat in background (don't block the tool call) + try: + subprocess.Popen( + ["crosslink", "heartbeat"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except OSError: + pass + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/post-edit-check.py b/.claude/hooks/post-edit-check.py new file mode 100644 index 0000000..62c127b --- /dev/null +++ b/.claude/hooks/post-edit-check.py @@ -0,0 +1,467 @@ +#!/usr/bin/env python3 +""" +Post-edit hook that detects stub patterns, runs linters, and reminds about tests. +Runs after Write/Edit tool usage. +""" + +import json +import sys +import os +import re +import subprocess +import glob +import time + +# Add hooks directory to path for shared module import +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from crosslink_config import find_crosslink_dir, is_agent_context + +# Stub patterns to detect (compiled regex for performance) +STUB_PATTERNS = [ + (r'\bTODO\b', 'TODO comment'), + (r'\bFIXME\b', 'FIXME comment'), + (r'\bXXX\b', 'XXX marker'), + (r'\bHACK\b', 'HACK marker'), + (r'^\s*pass\s*$', 'bare pass statement'), + (r'^\s*\.\.\.\s*$', 'ellipsis placeholder'), + (r'\bunimplemented!\s*\(\s*\)', 'unimplemented!() macro'), + (r'\btodo!\s*\(\s*\)', 'todo!() macro'), + (r'\bpanic!\s*\(\s*"not implemented', 'panic not implemented'), + (r'raise\s+NotImplementedError\s*\(\s*\)', 'bare NotImplementedError'), + (r'#\s*implement\s*(later|this|here)', 'implement later comment'), + (r'//\s*implement\s*(later|this|here)', 'implement later comment'), + (r'def\s+\w+\s*\([^)]*\)\s*:\s*(pass|\.\.\.)\s*$', 'empty function'), + (r'fn\s+\w+\s*\([^)]*\)\s*\{\s*\}', 'empty function body'), + (r'return\s+None\s*#.*stub', 'stub return'), +] + +COMPILED_PATTERNS = [(re.compile(p, re.IGNORECASE | re.MULTILINE), desc) for p, desc in STUB_PATTERNS] + + +def check_for_stubs(file_path): + """Check file for stub patterns. Returns list of (line_num, pattern_desc, line_content).""" + if not os.path.exists(file_path): + return [] + + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + lines = content.split('\n') + except (OSError, Exception): + return [] + + findings = [] + for line_num, line in enumerate(lines, 1): + for pattern, desc in COMPILED_PATTERNS: + if pattern.search(line): + if 'NotImplementedError' in line and re.search(r'NotImplementedError\s*\(\s*["\'][^"\']+["\']', line): + continue + findings.append((line_num, desc, line.strip()[:60])) + + return findings + + +def find_project_root(file_path, marker_files): + """Walk up from file_path looking for project root markers.""" + current = os.path.dirname(os.path.abspath(file_path)) + for _ in range(10): # Max 10 levels up + for marker in marker_files: + if os.path.exists(os.path.join(current, marker)): + return current + parent = os.path.dirname(current) + if parent == current: + break + current = parent + return None + + +def run_linter(file_path, max_errors=10): + """Run appropriate linter and return first N errors.""" + ext = os.path.splitext(file_path)[1].lower() + errors = [] + + try: + if ext == '.rs': + # Rust: run cargo clippy from project root + project_root = find_project_root(file_path, ['Cargo.toml']) + if project_root: + result = subprocess.run( + ['cargo', 'clippy', '--message-format=short', '--quiet'], + cwd=project_root, + capture_output=True, + text=True, + timeout=30 + ) + if result.stderr: + for line in result.stderr.split('\n'): + if line.strip() and ('error' in line.lower() or 'warning' in line.lower()): + errors.append(line.strip()[:100]) + if len(errors) >= max_errors: + break + + elif ext == '.py': + # Python: try flake8, fall back to py_compile + try: + result = subprocess.run( + ['flake8', '--max-line-length=120', file_path], + capture_output=True, + text=True, + timeout=10 + ) + for line in result.stdout.split('\n'): + if line.strip(): + errors.append(line.strip()[:100]) + if len(errors) >= max_errors: + break + except FileNotFoundError: + # flake8 not installed, try py_compile + result = subprocess.run( + ['python', '-m', 'py_compile', file_path], + capture_output=True, + text=True, + timeout=10 + ) + if result.stderr: + errors.append(result.stderr.strip()[:200]) + + elif ext in ('.js', '.ts', '.tsx', '.jsx'): + # JavaScript/TypeScript: try eslint + project_root = find_project_root(file_path, ['package.json', '.eslintrc', '.eslintrc.js', '.eslintrc.json']) + if project_root: + try: + result = subprocess.run( + ['npx', 'eslint', '--format=compact', file_path], + cwd=project_root, + capture_output=True, + text=True, + timeout=30 + ) + for line in result.stdout.split('\n'): + if line.strip() and (':' in line): + errors.append(line.strip()[:100]) + if len(errors) >= max_errors: + break + except FileNotFoundError: + pass + + elif ext == '.go': + # Go: run go vet + project_root = find_project_root(file_path, ['go.mod']) + if project_root: + result = subprocess.run( + ['go', 'vet', './...'], + cwd=project_root, + capture_output=True, + text=True, + timeout=30 + ) + if result.stderr: + for line in result.stderr.split('\n'): + if line.strip(): + errors.append(line.strip()[:100]) + if len(errors) >= max_errors: + break + + elif ext in ('.ex', '.exs', '.heex'): + # Elixir: run mix format --check-formatted, then mix credo --strict if available + project_root = find_project_root(file_path, ['mix.exs']) + if project_root: + # mix format --check-formatted on the specific file + result = subprocess.run( + ['mix', 'format', '--check-formatted', file_path], + cwd=project_root, + capture_output=True, + text=True, + timeout=30 + ) + if result.returncode != 0: + for line in result.stderr.split('\n'): + if line.strip(): + errors.append(line.strip()[:100]) + if len(errors) >= max_errors: + break + + # Run mix credo --strict only if credo is in deps + if len(errors) < max_errors: + mix_exs_path = os.path.join(project_root, 'mix.exs') + has_credo = False + try: + with open(mix_exs_path, 'r', encoding='utf-8', errors='ignore') as f: + if ':credo' in f.read(): + has_credo = True + except OSError: + pass + + if has_credo: + result = subprocess.run( + ['mix', 'credo', '--strict', '--format', 'oneline', file_path], + cwd=project_root, + capture_output=True, + text=True, + timeout=30 + ) + if result.stdout: + for line in result.stdout.split('\n'): + if line.strip() and ':' in line: + errors.append(line.strip()[:100]) + if len(errors) >= max_errors: + break + + except subprocess.TimeoutExpired: + errors.append("(linter timed out)") + except (OSError, Exception) as e: + pass # Linter not available, skip silently + + return errors + + +def is_test_file(file_path): + """Check if file is a test file.""" + basename = os.path.basename(file_path).lower() + dirname = os.path.dirname(file_path).lower() + + # Common test file patterns + test_patterns = [ + 'test_', '_test.', '.test.', 'spec.', '_spec.', + 'tests.', 'testing.', 'mock.', '_mock.', '_test.exs' + ] + # Common test directories + test_dirs = ['test', 'tests', '__tests__', 'spec', 'specs', 'testing'] + + for pattern in test_patterns: + if pattern in basename: + return True + + for test_dir in test_dirs: + if test_dir in dirname.split(os.sep): + return True + + return False + + +def find_test_files(file_path, project_root): + """Find test files related to source file.""" + if not project_root: + return [] + + ext = os.path.splitext(file_path)[1] + basename = os.path.basename(file_path) + name_without_ext = os.path.splitext(basename)[0] + + # Patterns to look for + test_patterns = [] + + if ext == '.rs': + # Rust: look for mod tests in same file, or tests/ directory + test_patterns = [ + os.path.join(project_root, 'tests', '**', f'*{name_without_ext}*'), + os.path.join(project_root, '**', 'tests', f'*{name_without_ext}*'), + ] + elif ext == '.py': + test_patterns = [ + os.path.join(project_root, '**', f'test_{name_without_ext}.py'), + os.path.join(project_root, '**', f'{name_without_ext}_test.py'), + os.path.join(project_root, 'tests', '**', f'*{name_without_ext}*.py'), + ] + elif ext in ('.js', '.ts', '.tsx', '.jsx'): + base = name_without_ext.replace('.test', '').replace('.spec', '') + test_patterns = [ + os.path.join(project_root, '**', f'{base}.test{ext}'), + os.path.join(project_root, '**', f'{base}.spec{ext}'), + os.path.join(project_root, '**', '__tests__', f'{base}*'), + ] + elif ext == '.go': + test_patterns = [ + os.path.join(os.path.dirname(file_path), f'{name_without_ext}_test.go'), + ] + elif ext in ('.ex', '.exs'): + test_patterns = [ + os.path.join(project_root, 'test', '**', f'{name_without_ext}_test.exs'), + os.path.join(project_root, 'test', '**', f'*{name_without_ext}*_test.exs'), + ] + + found = [] + for pattern in test_patterns: + found.extend(glob.glob(pattern, recursive=True)) + + return list(set(found))[:5] # Limit to 5 + + +def get_test_reminder(file_path, project_root): + """Check if tests should be run and return reminder message.""" + if is_test_file(file_path): + return None # Editing a test file, no reminder needed + + ext = os.path.splitext(file_path)[1] + code_extensions = ('.rs', '.py', '.js', '.ts', '.tsx', '.jsx', '.go', '.ex', '.exs', '.heex') + + if ext not in code_extensions: + return None + + # Check for marker file + marker_dir = project_root or os.path.dirname(file_path) + marker_file = os.path.join(marker_dir, '.crosslink', 'last_test_run') + + code_modified_after_tests = False + + if os.path.exists(marker_file): + try: + marker_mtime = os.path.getmtime(marker_file) + file_mtime = os.path.getmtime(file_path) + code_modified_after_tests = file_mtime > marker_mtime + except OSError: + code_modified_after_tests = True + else: + # No marker = tests haven't been run + code_modified_after_tests = True + + if not code_modified_after_tests: + return None + + # Find test files + test_files = find_test_files(file_path, project_root) + + # Generate test command based on project type + test_cmd = None + if ext == '.rs' and project_root: + if os.path.exists(os.path.join(project_root, 'Cargo.toml')): + test_cmd = 'cargo test' + elif ext == '.py': + if project_root and os.path.exists(os.path.join(project_root, 'pytest.ini')): + test_cmd = 'pytest' + elif project_root and os.path.exists(os.path.join(project_root, 'setup.py')): + test_cmd = 'python -m pytest' + elif ext in ('.js', '.ts', '.tsx', '.jsx') and project_root: + if os.path.exists(os.path.join(project_root, 'package.json')): + test_cmd = 'npm test' + elif ext == '.go' and project_root: + test_cmd = 'go test ./...' + elif ext in ('.ex', '.exs', '.heex') and project_root: + if os.path.exists(os.path.join(project_root, 'mix.exs')): + test_cmd = 'mix test' + + if test_files or test_cmd: + msg = "๐Ÿงช TEST REMINDER: Code modified since last test run." + if test_cmd: + msg += f"\n Run: {test_cmd}" + if test_files: + msg += f"\n Related tests: {', '.join(os.path.basename(t) for t in test_files[:3])}" + return msg + + return None + + +def main(): + try: + input_data = json.load(sys.stdin) + except (json.JSONDecodeError, Exception): + sys.exit(0) + + tool_name = input_data.get("tool_name", "") + tool_input = input_data.get("tool_input", {}) + + if tool_name not in ("Write", "Edit"): + sys.exit(0) + + file_path = tool_input.get("file_path", "") + + code_extensions = ( + '.rs', '.py', '.js', '.ts', '.tsx', '.jsx', '.go', '.java', + '.c', '.cpp', '.h', '.hpp', '.cs', '.rb', '.php', '.swift', + '.kt', '.scala', '.zig', '.odin', '.ex', '.exs', '.heex' + ) + + if not any(file_path.endswith(ext) for ext in code_extensions): + sys.exit(0) + + if '.claude' in file_path and 'hooks' in file_path: + sys.exit(0) + + # Find project root for linter and test detection + project_root = find_project_root(file_path, [ + 'Cargo.toml', 'package.json', 'go.mod', 'setup.py', + 'pyproject.toml', 'mix.exs', '.git' + ]) + + # Detect agent context โ€” agents skip linting and test reminders + # (they run their own CI checks), but stub detection stays active + crosslink_dir = find_crosslink_dir() + is_agent = is_agent_context(crosslink_dir) + + # Check for stubs (always - instant regex check, even for agents) + stub_findings = check_for_stubs(file_path) + + # Skip linting and test reminders for agents (too slow, agents have CI) + linter_errors = [] + test_reminder = None + + if not is_agent: + # Debounced linting: only run linter if no edits in last 10 seconds + lint_marker = None + if project_root: + crosslink_cache = os.path.join(project_root, '.crosslink', '.cache') + lint_marker = os.path.join(crosslink_cache, 'last-edit-time') + + should_lint = True + if lint_marker: + try: + os.makedirs(os.path.dirname(lint_marker), exist_ok=True) + if os.path.exists(lint_marker): + last_edit = os.path.getmtime(lint_marker) + elapsed = time.time() - last_edit + if elapsed < 10: + should_lint = False + with open(lint_marker, 'w') as f: + f.write(str(time.time())) + except OSError: + pass + + if should_lint: + linter_errors = run_linter(file_path) + + # Check for test reminder + test_reminder = get_test_reminder(file_path, project_root) + + # Build output + messages = [] + + if stub_findings: + stub_list = "\n".join([f" Line {ln}: {desc} - `{content}`" for ln, desc, content in stub_findings[:5]]) + if len(stub_findings) > 5: + stub_list += f"\n ... and {len(stub_findings) - 5} more" + messages.append(f"""โš ๏ธ STUB PATTERNS DETECTED in {file_path}: +{stub_list} + +Fix these NOW - replace with real implementation.""") + + if linter_errors: + error_list = "\n".join([f" {e}" for e in linter_errors[:10]]) + if len(linter_errors) > 10: + error_list += f"\n ... and more" + messages.append(f"""๐Ÿ” LINTER ISSUES: +{error_list}""") + + if test_reminder: + messages.append(test_reminder) + + if messages: + output = { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": "\n\n".join(messages) + } + } + else: + output = { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": f"โœ“ {os.path.basename(file_path)} - no issues detected" + } + } + + print(json.dumps(output)) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/pre-web-check.py b/.claude/hooks/pre-web-check.py new file mode 100644 index 0000000..c287c90 --- /dev/null +++ b/.claude/hooks/pre-web-check.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +Crosslink web security hook for Claude Code. +Injects RFIP (Recursive Framing Interdiction Protocol) before web tool calls. +Triggered by PreToolUse on WebFetch|WebSearch to defend against prompt injection. +""" + +import json +import sys +import os +import io + +# Fix Windows encoding issues with Unicode characters +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') + + +def _project_root_from_script(): + """Derive project root from this script's location (.claude/hooks/