From fe3e07f791606424988edf6a8513f8d7274ee9c9 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Tue, 21 Apr 2026 00:06:40 -0700 Subject: [PATCH] =?UTF-8?q?feat(seedgo+hooks):=20DPLAN-0139=20Track=20A=20?= =?UTF-8?q?=E2=80=94=20hook=20consolidation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auto_fix_diagnostics.py v5.3.0: [SILENT-FIX] label, IDE fallback with loud stderr announce when pyright unavailable, ruff lint hard-block - pre_edit_gate.py v1.2.0: added to AIPass/.claude/hooks/ + wired to PreToolUse in ~/.claude/settings.json (first time gate runs in production) - Delete .claude/global_hooks/ (v4.3.0 stale, nothing references it) - MANIFEST.in + pyproject.toml force-include: .claude/hooks/ in pip wheel at aipass/_hooks/ — verified via pip wheel build - 20 new tests (test_hooks_track_a.py): auto_fix, pre_edit_gate, subagent_stop_gate — all pass (422 total) Note: drone @git pr scope bug (DPLAN-0140) blocked staging .claude/hooks/ changes — raw git used. ~/.claude/settings.json PreToolUse update is out-of-repo (not in this commit). --- .claude/global_hooks/auto_fix_diagnostics.py | 310 -------------- .claude/global_hooks/hook_logger.sh | 5 - .claude/global_hooks/notification_sound.py | 37 -- .claude/global_hooks/statusline.sh | 125 ------ .claude/global_hooks/stop_sound.py | 38 -- .claude/global_hooks/subagent_stop_gate.py | 100 ----- .claude/global_hooks/tool_use_sound.py | 40 -- .claude/hooks/auto_fix_diagnostics.py | 83 +++- .claude/hooks/pre_edit_gate.py | 106 +++++ .claude/worktrees/agent-aa2b7d43 | 1 - MANIFEST.in | 2 + pyproject.toml | 3 + src/aipass/seedgo/tests/test_hooks_track_a.py | 384 ++++++++++++++++++ 13 files changed, 556 insertions(+), 678 deletions(-) delete mode 100644 .claude/global_hooks/auto_fix_diagnostics.py delete mode 100755 .claude/global_hooks/hook_logger.sh delete mode 100644 .claude/global_hooks/notification_sound.py delete mode 100755 .claude/global_hooks/statusline.sh delete mode 100644 .claude/global_hooks/stop_sound.py delete mode 100644 .claude/global_hooks/subagent_stop_gate.py delete mode 100644 .claude/global_hooks/tool_use_sound.py create mode 100644 .claude/hooks/pre_edit_gate.py delete mode 160000 .claude/worktrees/agent-aa2b7d43 create mode 100644 MANIFEST.in create mode 100644 src/aipass/seedgo/tests/test_hooks_track_a.py diff --git a/.claude/global_hooks/auto_fix_diagnostics.py b/.claude/global_hooks/auto_fix_diagnostics.py deleted file mode 100644 index 7e0eb4ab..00000000 --- a/.claude/global_hooks/auto_fix_diagnostics.py +++ /dev/null @@ -1,310 +0,0 @@ -#!/usr/bin/env python3 -""" -Silent Auto-fix Hook - Runs ACTUAL validation and tells Claude to fix silently. - -Key behaviors: -- Runs real linters (ruff, py_compile) not just pattern lists -- Validates JSON with json.load() -- Outputs via additionalContext so Claude sees errors -- Claude fixes silently without announcing -- Simple indicator for user console -- Smart batching per-file - -Version: 4.3.0 -""" - -import json -import sys -import subprocess -from pathlib import Path - -EDIT_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit"] -LAST_FILE_PATH = Path(__file__).parent / ".last_diagnostics_file" -SKIP_EXTENSIONS = {".md", ".txt", ".log", ".csv", ".html"} - -# AIPass-specific Python patterns to check -PYTHON_PATTERNS = { - "bad_optional": { - "pattern": ": str = None", - "message": "Optional param should use 'str | None = None' pattern" - }, - "logger_debug": { - "pattern": "logger.debug(", - "message": "Use logger.info for SystemLogger (logger.debug not supported)" - }, - "return_error_msg": { - "pattern": "return error_msg", - "message": "Return None for error states, not error_msg string" - }, - "open_no_encoding": { - "pattern": "open(", - "requires_missing": "encoding=", - "message": "open() without encoding='utf-8'" - }, - "log_not_log_operation": { - "pattern": ".log(", - "message": "Use log_operation() with success/error params, not .log()" - }, - "dict_none_no_check": { - "pattern": "Dict | None", - "message": "Dict | None return: Add None check before using (if result is None: return)" - } -} - -# JSON-specific patterns for emoji corruption -JSON_CORRUPTION_CHARS = ['\ufffd', '\x00'] - - -def run_python_checks(file_path: str) -> list[str]: - """Run actual Python validation - returns list of errors.""" - errors = [] - - # 1. Syntax check with py_compile - try: - result = subprocess.run( - [sys.executable, "-m", "py_compile", file_path], - capture_output=True, - text=True, - timeout=5 - ) - if result.returncode != 0: - errors.append(f"SYNTAX: {result.stderr.strip()}") - except Exception: - pass - - # 2. Ruff check (if available) - fast linter - try: - result = subprocess.run( - ["ruff", "check", "--select=E,F,W", "--output-format=text", file_path], - capture_output=True, - text=True, - timeout=10 - ) - if result.stdout.strip(): - for line in result.stdout.strip().split("\n")[:5]: # Max 5 errors - errors.append(f"LINT: {line}") - except FileNotFoundError: - pass # ruff not installed - except Exception: - pass - - # 3. AIPass-specific pattern checks - try: - content = Path(file_path).read_text(encoding="utf-8") - lines = content.split("\n") - - for check in PYTHON_PATTERNS.values(): - pattern = check["pattern"] - message = check["message"] - requires_missing = check.get("requires_missing") - - # For patterns that require something to be missing - if requires_missing: - if pattern in content and requires_missing not in content: - errors.append(f"PATTERN: {message}") - continue - - # Standard pattern check - scan lines - for line in lines: - stripped = line.strip() - # Skip comments and strings - if stripped.startswith(("#", '"', "'")): - continue - # Skip if pattern appears in a string on this line - if f'"{pattern}' in line or f"'{pattern}" in line: - continue - if pattern in line: - errors.append(f"PATTERN: {message}") - break # One error per pattern type - except Exception: - pass - - return errors - - -def run_json_checks(file_path: str) -> list[str]: - """Run actual JSON validation - returns list of errors.""" - errors = [] - - try: - content = Path(file_path).read_text(encoding="utf-8") - - # Check for emoji corruption before parsing - for char in JSON_CORRUPTION_CHARS: - if char in content: - errors.append(f"EMOJI CORRUPTION: Found corrupted character '{repr(char)}' - check allowed_emojis arrays") - break - - # Try to parse JSON - try: - data = json.loads(content) - - # Check for corruption in emoji arrays specifically - if isinstance(data, dict): - for key in ['allowed_emojis', 'emojis', 'emoji_list']: - if key in data and isinstance(data[key], list): - for item in data[key]: - if isinstance(item, str) and len(item) == 1: - if ord(item) < 128 and item not in '\u2713\u2717': - errors.append(f"EMOJI CORRUPTION: Suspicious char '{item}' in {key} - may be corrupted emoji") - break - - except json.JSONDecodeError as e: - errors.append(f"JSON SYNTAX: {e.msg} at line {e.lineno}") - - except Exception as e: - errors.append(f"READ ERROR: {e!s}") - - return errors - - -def run_seedgo_checklist(file_path: str) -> list[str]: - """Run seedgo standards checklist — returns violations only.""" - # Skip Claude hooks - they don't follow project standards - if '/.claude/hooks/' in file_path: - return [] - - try: - result = subprocess.run( - ["drone", "@seedgo", "checklist", file_path], - capture_output=True, - text=True, - timeout=15, - cwd=str(Path.home() / "Projects" / "AIPass") - ) - - if result.returncode != 0: - return [] # Command failed, skip - - violations = [] - current_standard = None - - for line in result.stdout.split("\n"): - line = line.strip() - - # Checklist output format: "✗ standard_name: detail" - if line.startswith("\u2717"): - violation = line[1:].strip() - if violation: - violations.append(violation) - - return violations[:5] # Top 5 only - - except FileNotFoundError: - return [] # drone not available - except Exception: - return [] - - -def should_skip_file(file_path: str) -> bool: - """Check if file should be skipped.""" - if not file_path: - return True - ext = Path(file_path).suffix.lower() - return ext in SKIP_EXTENSIONS - - -def is_same_file_as_last(file_path: str) -> bool: - """Smart batching - skip if same file as last check.""" - try: - if LAST_FILE_PATH.exists(): - last = LAST_FILE_PATH.read_text(encoding="utf-8").strip() - if last == file_path: - return True - LAST_FILE_PATH.write_text(file_path, encoding="utf-8") - except Exception: - pass - return False - - -def main(): - """Main hook entry point.""" - try: - input_data = json.load(sys.stdin) - tool_name = input_data.get("tool_name", "") - tool_input = input_data.get("tool_input", {}) - file_path = tool_input.get("file_path", "") - - # Only process edit tools - if tool_name not in EDIT_TOOLS: - return - - # Skip non-code files - if should_skip_file(file_path): - return - - # Smart batching - don't re-check same file - if is_same_file_as_last(file_path): - return - - # Determine file type and run checks - errors = [] - file_type = "" - - if file_path.endswith(".py"): - file_type = "Python" - errors = run_python_checks(file_path) - # Seedgo standards checklist (Python files only) - seedgo_violations = run_seedgo_checklist(file_path) - for v in seedgo_violations: - errors.append(f"SEEDGO: {v}") - - elif file_path.endswith(".json"): - file_type = "JSON" - errors = run_json_checks(file_path) - else: - return # No validation for other types yet - - # Build context message - context = "" - ide_instruction = f"\n\nAlso run mcp__ide__getDiagnostics on {file_path} to check for type errors. Fix any errors found silently." - - if errors: - error_text = "\n".join(f" - {e}" for e in errors) - context = f"""[AUTO-FIX REQUIRED] {len(errors)} validation error(s) in {Path(file_path).name}: -{error_text} - -MANDATORY: You MUST fix every error listed above in {Path(file_path).name} before proceeding with any other work. Edit the file now to resolve these issues. Do not skip, defer, or ignore — fix them immediately, then continue with your task.""" - - # For Python files, also check IDE diagnostics for type errors - if file_path.endswith(".py"): - if errors: - context += ide_instruction - else: - # No other errors, but still check IDE diagnostics - context = f"[TYPE-CHECK] Run mcp__ide__getDiagnostics on {file_path} and fix any type errors silently." - errors = ["IDE diagnostics check requested"] - - output = { - "hookSpecificOutput": { - "hookEventName": "PostToolUse", - "additionalContext": context - }, - "systemMessage": f"[AUTO-FIX] {len(errors)} issue(s) — fix before continuing" - } - print(json.dumps(output)) - # Exit 0 so JSON additionalContext is delivered to the model - elif errors: - # JSON or other file with errors - output = { - "hookSpecificOutput": { - "hookEventName": "PostToolUse", - "additionalContext": context - }, - "systemMessage": f"[AUTO-FIX] {len(errors)} issue(s) — fix before continuing" - } - print(json.dumps(output)) - # Exit 0 so JSON additionalContext is delivered to the model - else: - # No errors - just tiny indicator - output = { - "systemMessage": "[diagnostics] ok" - } - print(json.dumps(output)) - - except Exception: - pass # Silent fail - - -if __name__ == "__main__": - main() diff --git a/.claude/global_hooks/hook_logger.sh b/.claude/global_hooks/hook_logger.sh deleted file mode 100755 index aa7598b1..00000000 --- a/.claude/global_hooks/hook_logger.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -# Log hook activity for statusline display -# Usage: source from hook commands or call directly -# hook_logger.sh -echo "$(date +%s) $1" > /tmp/aipass-hook-last diff --git a/.claude/global_hooks/notification_sound.py b/.claude/global_hooks/notification_sound.py deleted file mode 100644 index 3492e707..00000000 --- a/.claude/global_hooks/notification_sound.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -"""Notification Hook — Plays sound when AI needs permission.""" - -import json -import sys -import subprocess -from pathlib import Path - -SOUNDS_DIR = Path(__file__).parent.parent / "sounds" -SOUND_FILE = SOUNDS_DIR / "mixkit-clear-announce-tones-2861.wav" - - -def play_sound() -> None: - if not SOUND_FILE.exists(): - return - try: - subprocess.Popen( - ["aplay", "-q", str(SOUND_FILE)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception: - pass - - -def main(): - try: - hook_data = json.loads(sys.stdin.read()) - if hook_data.get("hook_event_name") == "Notification": - play_sound() - except Exception: - pass - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/.claude/global_hooks/statusline.sh b/.claude/global_hooks/statusline.sh deleted file mode 100755 index ebd0530b..00000000 --- a/.claude/global_hooks/statusline.sh +++ /dev/null @@ -1,125 +0,0 @@ -#!/bin/bash -# AIPass statusline — pretty, context-aware -input=$(cat) - -# Extract fields -dir=$(echo "$input" | jq -r '.workspace.current_dir // ""') -model=$(echo "$input" | jq -r '.model.display_name // "?"') -ctx=$(echo "$input" | jq -r '.context_window.remaining_percentage // empty') -cost=$(echo "$input" | jq -r '.cost.total_cost_usd // empty') -lines_add=$(echo "$input" | jq -r '.cost.total_lines_added // 0') -lines_rm=$(echo "$input" | jq -r '.cost.total_lines_removed // 0') -session=$(echo "$input" | jq -r '.session_name // empty') - -# Colors -RST='\033[0m' -DIM='\033[2m' -BOLD='\033[1m' -GREEN='\033[32m' -YELLOW='\033[33m' -RED='\033[31m' -CYAN='\033[36m' -MAGENTA='\033[35m' -WHITE='\033[97m' - -# Shorten directory — extract branch name if inside AIPass -branch="" -if [[ "$dir" == *"/src/aipass/"* ]]; then - branch=$(echo "$dir" | sed 's|.*/src/aipass/||' | cut -d/ -f1) -elif [[ "$dir" == *"/src/commons"* ]]; then - branch="commons" -elif [[ "$dir" == *"/src/skills"* ]]; then - branch="skills" -elif [[ "$dir" == *"/AIPass"* ]]; then - branch="root" -fi - -# Context color — green > 50%, yellow 20-50%, red < 20% -ctx_color="$GREEN" -if [ -n "$ctx" ]; then - if [ "$ctx" -lt 20 ] 2>/dev/null; then - ctx_color="$RED" - elif [ "$ctx" -lt 50 ] 2>/dev/null; then - ctx_color="$YELLOW" - fi -fi - -# Build context bar (10 chars wide) -bar="" -if [ -n "$ctx" ]; then - used=$((100 - ctx)) - filled=$((used / 10)) - empty=$((10 - filled)) - bar="${DIM}[" - for ((i=0; i/dev/null) - -# Build output -out="" - -# Branch or directory -if [ -n "$branch" ]; then - out+="${CYAN}@${branch}${RST}" -else - short_dir=$(echo "$dir" | sed "s|$HOME|~|" | awk -F/ '{if(NF>=2) print $(NF-1)"/"$NF; else print $NF}') - out+="${DIM}${short_dir}${RST}" -fi - -# Git branch -if [ -n "$git_branch" ]; then - out+=" ${DIM}(${RST}${YELLOW}${git_branch}${RST}${DIM})${RST}" -fi - -# Separator -out+=" ${DIM}│${RST} " - -# Model -out+="${MAGENTA}${short_model}${RST}" - -# Separator -out+=" ${DIM}│${RST} " - -# Context -if [ -n "$ctx" ]; then - out+="${ctx_color}${ctx}%${RST} ${bar}" -else - out+="${DIM}...${RST}" -fi - -# Cost (if any) -if [ -n "$cost" ] && [ "$cost" != "0" ]; then - cost_fmt=$(printf "%.2f" "$cost" 2>/dev/null || echo "$cost") - out+=" ${DIM}│${RST} ${DIM}\$${cost_fmt}${RST}" -fi - -# Lines changed -if [ "$lines_add" -gt 0 ] 2>/dev/null || [ "$lines_rm" -gt 0 ] 2>/dev/null; then - out+=" ${DIM}│${RST} ${GREEN}+${lines_add}${RST}${DIM}/${RST}${RED}-${lines_rm}${RST}" -fi - -# Session name if set -if [ -n "$session" ]; then - out=" ${DIM}[${RST}${WHITE}${session}${RST}${DIM}]${RST} ${out}" -fi - -# Hook activity — show last hook if fired within 3 seconds -HOOK_FILE="/tmp/aipass-hook-last" -if [ -f "$HOOK_FILE" ]; then - hook_data=$(cat "$HOOK_FILE" 2>/dev/null) - hook_ts=$(echo "$hook_data" | cut -d' ' -f1) - hook_name=$(echo "$hook_data" | cut -d' ' -f2-) - now=$(date +%s) - if [ -n "$hook_ts" ] && [ $((now - hook_ts)) -le 3 ] 2>/dev/null; then - out+=" ${DIM}│${RST} ${DIM}hook:${RST}${YELLOW}${hook_name}${RST}" - fi -fi - -printf '%b' "$out" diff --git a/.claude/global_hooks/stop_sound.py b/.claude/global_hooks/stop_sound.py deleted file mode 100644 index 59236ff6..00000000 --- a/.claude/global_hooks/stop_sound.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 -"""Stop Hook — Plays achievement bell when AI finishes responding.""" - -import json -import sys -import subprocess -from pathlib import Path - -SOUNDS_DIR = Path(__file__).parent.parent / "sounds" -SOUND_FILE = SOUNDS_DIR / "mixkit-achievement-bell-600.wav" - - -def play_sound() -> None: - if not SOUND_FILE.exists(): - return - try: - subprocess.Popen( - ["aplay", "-q", str(SOUND_FILE)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception: - pass - - -def main(): - try: - hook_data = json.loads(sys.stdin.read()) - if hook_data.get("hook_event_name") == "Stop": - if not hook_data.get("stop_hook_active", False): - play_sound() - except Exception: - pass - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/.claude/global_hooks/subagent_stop_gate.py b/.claude/global_hooks/subagent_stop_gate.py deleted file mode 100644 index 2fd640df..00000000 --- a/.claude/global_hooks/subagent_stop_gate.py +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env python3 -""" -SubagentStop Gate — Checks files modified by subagents before allowing them to finish. - -Runs seedgo checklist + basic validation on any .py files the subagent touched. -If violations found, blocks the stop and tells the subagent to fix them. - -Version: 1.0.0 -""" - -import json -import sys -import subprocess -from pathlib import Path - -AIPASS_ROOT = Path.home() / "Projects" / "AIPass" - - -def get_modified_py_files() -> list[str]: - """Get Python files modified in the working tree (unstaged + staged).""" - try: - result = subprocess.run( - ["git", "diff", "--name-only", "HEAD"], - capture_output=True, text=True, timeout=5, - cwd=str(AIPASS_ROOT) - ) - files = [] - for line in result.stdout.strip().split("\n"): - line = line.strip() - if line.endswith(".py") and not line.startswith(".claude/"): - full = AIPASS_ROOT / line - if full.exists(): - files.append(str(full)) - return files - except Exception: - return [] - - -def run_seedgo_checklist(file_path: str) -> list[str]: - """Run seedgo checklist on a single file.""" - if "/.claude/" in file_path: - return [] - try: - result = subprocess.run( - ["drone", "@seedgo", "checklist", file_path], - capture_output=True, text=True, timeout=15, - cwd=str(AIPASS_ROOT) - ) - if result.returncode != 0: - return [] - violations = [] - for line in result.stdout.split("\n"): - line = line.strip() - if line.startswith("\u2717"): - v = line[1:].strip() - if v: - violations.append(v) - return violations[:5] - except Exception: - return [] - - -def main(): - try: - input_data = json.load(sys.stdin) - - modified = get_modified_py_files() - if not modified: - return # Nothing to check - - all_violations = {} - for f in modified: - vs = run_seedgo_checklist(f) - if vs: - name = Path(f).name - all_violations[name] = vs - - if not all_violations: - return # All clear - - # Build the block reason - lines = ["Standards violations found in files you modified:\n"] - for fname, vs in all_violations.items(): - lines.append(f" {fname}:") - for v in vs: - lines.append(f" - {v}") - lines.append("\nFix these violations before finishing.") - - output = { - "decision": "block", - "reason": "\n".join(lines) - } - print(json.dumps(output)) - - except Exception: - pass # Silent fail — don't block on errors - - -if __name__ == "__main__": - main() diff --git a/.claude/global_hooks/tool_use_sound.py b/.claude/global_hooks/tool_use_sound.py deleted file mode 100644 index fe09c5b3..00000000 --- a/.claude/global_hooks/tool_use_sound.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -"""Tool Use Hook — Plays key press sound when AI uses tools.""" - -import json -import sys -import subprocess -from pathlib import Path - -SOUNDS_DIR = Path(__file__).parent.parent / "sounds" -SOUND_FILE = SOUNDS_DIR / "mixkit-atm-cash-machine-key-press-2841.wav" - -SOUND_TOOLS = ["Bash", "Edit", "MultiEdit", "Write", "Read", "Grep", "Glob"] - - -def play_sound() -> None: - if not SOUND_FILE.exists(): - return - try: - subprocess.Popen( - ["aplay", "-q", str(SOUND_FILE)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception: - pass - - -def main(): - try: - hook_data = json.loads(sys.stdin.read()) - if hook_data.get("hook_event_name") == "PreToolUse": - if hook_data.get("tool_name", "") in SOUND_TOOLS: - play_sound() - except Exception: - pass - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/.claude/hooks/auto_fix_diagnostics.py b/.claude/hooks/auto_fix_diagnostics.py index 306fabc5..6f0a2c61 100644 --- a/.claude/hooks/auto_fix_diagnostics.py +++ b/.claude/hooks/auto_fix_diagnostics.py @@ -1,21 +1,23 @@ #!/usr/bin/env python3 """ -PostToolUse Auto-fix Hook — Detects type errors and surfaces them for fixing. +PostToolUse Auto-fix Hook — Detects errors and surfaces them for fixing. Two-hook system: - PostToolUse (this file) → runs pyright on edited file, saves errors to state + PostToolUse (this file) → runs pyright + ruff on edited file, saves errors to state PreToolUse (pre_edit_gate.py) → blocks edits to OTHER files until errors fixed Key behaviors: -- Runs py_compile (syntax), ruff (lint), pyright (type errors) on edited file +- Runs py_compile (syntax), ruff lint+format, pyright (type errors) on edited file - Runs seedgo checklist for AIPass standards -- Saves type errors to state file for PreToolUse gate +- Saves ruff lint AND pyright errors to state file for PreToolUse gate (hard block) - Surfaces ALL errors in additionalContext so Claude sees them -- Smart batching per-file -Version: 5.1.0 +Version: 5.3.0 CHANGELOG: + - v5.3.0 (2026-04-20): [SILENT-FIX] label + IDE fallback with loud announce. + - v5.2.0 (2026-04-20): Save ruff lint errors to state file for hard-block enforcement. + Pre-edit gate now blocks on F401/lint just like type errors. - v5.1.0 (2026-04-19): Added ruff format --check to surface format drift. - v5.0.0 (2026-03-17): Replaced mcp__ide__getDiagnostics with direct pyright. Added state file for PreToolUse gate integration. @@ -145,11 +147,45 @@ def run_python_checks(file_path: str) -> list[str]: return errors -def run_pyright_check(file_path: str) -> list[dict]: - """Run pyright on a single file. Returns list of error dicts.""" - # Skip hook files - they don't follow project standards + +def run_ruff_lint_structured(file_path: str) -> list[dict]: + """Run ruff check and return structured violations for the state file. + + Returns list of {line, message} dicts — same format as pyright errors. + Only non-empty when ruff finds real violations (not format drift). + """ if '/.claude/hooks/' in file_path: return [] + try: + result = subprocess.run( + ["ruff", "check", "--select=E,F,W", "--output-format=json", file_path], + capture_output=True, text=True, timeout=10 + ) + if not result.stdout.strip(): + return [] + violations = json.loads(result.stdout) + if not isinstance(violations, list): + return [] + errors = [] + for v in violations[:10]: + line = v.get("location", {}).get("row", 0) + code = v.get("code", "?") + message = v.get("message", "unknown")[:100] + errors.append({"line": line, "message": f"{code}: {message}"}) + return errors + except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired, Exception): + return [] + + +def run_pyright_check(file_path: str) -> tuple[list[dict], bool]: + """Run pyright on a single file. Returns (errors, fallback_needed). + + fallback_needed is True when pyright is unavailable (FileNotFoundError). + Timeout and generic exceptions return ([], False) — silent. + """ + # Skip hook files - they don't follow project standards + if '/.claude/hooks/' in file_path: + return [], False try: result = subprocess.run( @@ -162,7 +198,7 @@ def run_pyright_check(file_path: str) -> list[dict]: try: data = json.loads(result.stdout) except (json.JSONDecodeError, ValueError): - return [] + return [], False errors = [] for diag in data.get("generalDiagnostics", []): @@ -175,14 +211,14 @@ def run_pyright_check(file_path: str) -> list[dict]: "message": message[:100] }) - return errors[:10] # Max 10 errors + return errors[:10], False # Max 10 errors except FileNotFoundError: - return [] # pyright not installed + return [], True # pyright not installed — signal fallback needed except subprocess.TimeoutExpired: - return [] # Timeout — don't block + return [], False # Timeout — don't block except Exception: - return [] + return [], False def save_diagnostics_state(file_path: str, errors: list[dict]): @@ -307,6 +343,7 @@ def main(): # Collect all errors errors = [] file_type = "" + fallback_needed = False if file_path.endswith(".py"): file_type = "Python" @@ -318,12 +355,15 @@ def main(): errors.append(f"SEEDGO: {v}") # Pyright type errors (single file) - type_errors = run_pyright_check(file_path) + type_errors, fallback_needed = run_pyright_check(file_path) + if fallback_needed: + print("[FALLBACK ENGAGED: IDE diagnostics — VS Code only]", file=sys.stderr, flush=True) for te in type_errors: errors.append(f"TYPE: L{te['line']}: {te['message']}") - # Save type errors to state file for PreToolUse gate - save_diagnostics_state(file_path, type_errors) + # Save ruff lint + type errors to state file for PreToolUse gate (hard block) + ruff_lint_errors = run_ruff_lint_structured(file_path) + save_diagnostics_state(file_path, ruff_lint_errors + type_errors) elif file_path.endswith(".json"): file_type = "JSON" @@ -334,17 +374,16 @@ def main(): # Build output if errors: error_text = "\n".join(f" - {e}" for e in errors) - context = f"""[AUTO-FIX] {len(errors)} error(s) in {Path(file_path).name}: -{error_text} - -Fix these errors in {Path(file_path).name} now. Do not skip or defer.""" + context = f"[SILENT-FIX] {len(errors)} error(s) in {Path(file_path).name}:\n{error_text}\n\nFix these silently — do not announce or explain, just fix and continue." + if fallback_needed: + context += "\n\n[FALLBACK] pyright unavailable — run mcp__ide__getDiagnostics to check type errors. VS Code only. Non-VS-Code users: this check is a no-op." output = { "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": context }, - "systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing" + "systemMessage": f"[SILENT-FIX] {len(errors)} error(s) — fix before continuing" } print(json.dumps(output)) else: diff --git a/.claude/hooks/pre_edit_gate.py b/.claude/hooks/pre_edit_gate.py new file mode 100644 index 00000000..503b370b --- /dev/null +++ b/.claude/hooks/pre_edit_gate.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +PreToolUse Gate — Blocks edits when unresolved type errors exist. + +Two-hook system: + PostToolUse (auto_fix_diagnostics.py) → detects errors, saves to state file + PreToolUse (this file) → reads state file, blocks edits to OTHER files + +Logic: + - No state file or empty → ALLOW + - Editing the SAME file that has errors → ALLOW (they're fixing it) + - Errored file in a DIFFERENT branch → ALLOW (not your problem) + - Editing a DIFFERENT file in SAME branch → BLOCK (fix errors first) + +Version: 1.2.0 +""" + +import json +import sys +from pathlib import Path + +STATE_FILE = Path(__file__).parent / ".diagnostics_state.json" +EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} + + +def _get_branch(file_path: str) -> str: + """Extract AIPass branch name from file path. + + Looks for src/aipass/{branch}/ pattern. Returns branch name + or empty string if not in a branch. + """ + parts = Path(file_path).parts + for i, part in enumerate(parts): + if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts): + return parts[i + 1] + return "" + + +def main(): + try: + input_data = json.load(sys.stdin) + tool_name = input_data.get("tool_name", "") + tool_input = input_data.get("tool_input", {}) + file_path = tool_input.get("file_path", "") + + # Only gate edit tools + if tool_name not in EDIT_TOOLS: + return + + # Only gate Python files + if not file_path.endswith(".py"): + return + + # No state file → no pending errors → allow + if not STATE_FILE.exists(): + return + + try: + state = json.loads(STATE_FILE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, IOError): + return # Corrupted state → allow + + errored_file = state.get("file", "") + errors = state.get("errors", []) + + # No errors in state → allow + if not errors: + return + + # Resolve both paths for comparison + try: + current = str(Path(file_path).resolve()) + errored = str(Path(errored_file).resolve()) + except (OSError, ValueError): + return # Path resolution failed → allow + + # Editing the file WITH errors → allow (they're fixing it) + if current == errored: + return + + # Different branch → allow (cross-branch errors aren't your problem) + # If errored file is outside AIPass entirely → allow (external projects) + current_branch = _get_branch(current) + errored_branch = _get_branch(errored) + if not errored_branch: + return # Errored file is outside src/aipass/ — don't gate + if current_branch and errored_branch and current_branch != errored_branch: + return + + # Editing a DIFFERENT file in SAME branch while errors exist → BLOCK + error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5]) + reason = f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}" + + output = { + "decision": "block", + "reason": reason + } + print(json.dumps(output)) + sys.exit(2) + + except Exception: + pass # Silent fail → allow + + +if __name__ == "__main__": + main() diff --git a/.claude/worktrees/agent-aa2b7d43 b/.claude/worktrees/agent-aa2b7d43 deleted file mode 160000 index 98191fa9..00000000 --- a/.claude/worktrees/agent-aa2b7d43 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 98191fa97e9e91fd5dd1f0b12e23b97907dbdadc diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..ade4769a --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +# Include hooks directory for pip packaging +recursive-include .claude/hooks *.py *.md diff --git a/pyproject.toml b/pyproject.toml index 1c1e8ea9..4a3bc2d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,9 @@ aipass = "aipass.cli:cli_entry" [tool.hatch.build.targets.wheel] packages = ["src/aipass"] +[tool.hatch.build.targets.wheel.force-include] +".claude/hooks" = "aipass/_hooks" + [tool.pytest.ini_options] testpaths = ["tests", "src"] norecursedirs = ["templates", "*.egg-info", ".git", ".venv", "__pycache__"] diff --git a/src/aipass/seedgo/tests/test_hooks_track_a.py b/src/aipass/seedgo/tests/test_hooks_track_a.py new file mode 100644 index 00000000..23b6ff6d --- /dev/null +++ b/src/aipass/seedgo/tests/test_hooks_track_a.py @@ -0,0 +1,384 @@ +"""Tests for Track A hook scripts — auto_fix_diagnostics, pre_edit_gate, subagent_stop_gate. + +# =================== META ==================== +# Name: test_hooks_track_a.py +# Description: Track A hook tests — DPLAN-0139 coverage for auto_fix, pre_edit_gate, subagent_stop +# Version: 1.0.0 +# Created: 2026-04-20 +# Modified: 2026-04-20 +# ============================================= +""" + +import importlib.util +import io +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Helpers — hook loader +# --------------------------------------------------------------------------- + + +def _find_repo_root() -> Path: + """Walk up from this file to find the git repo root.""" + current = Path(__file__).resolve().parent + for parent in (current, *current.parents): + if (parent / ".git").exists(): + return parent + return Path(__file__).resolve().parents[4] # fallback + + +HOOKS_DIR = _find_repo_root() / ".claude" / "hooks" + + +def _load_hook(name: str): + """Import a hook script by filename via importlib (outside package).""" + path = HOOKS_DIR / name + if not path.exists(): + pytest.skip(f"Hook script not found: {path}") + spec = importlib.util.spec_from_file_location(name.replace(".py", ""), path) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) # type: ignore[union-attr] + return mod + + +# --------------------------------------------------------------------------- +# auto_fix_diagnostics.py tests +# --------------------------------------------------------------------------- + + +def test_auto_fix_skips_non_edit_tool(capsys): + """stdin with tool_name=Read → no output (not an edit tool).""" + mod = _load_hook("auto_fix_diagnostics.py") + payload = json.dumps({"tool_name": "Read", "tool_input": {"file_path": "/tmp/foo.py"}}) + with patch("sys.stdin", io.StringIO(payload)): + mod.main() + captured = capsys.readouterr() + assert captured.out == "" + + +def test_auto_fix_skips_non_py_file(capsys): + """stdin with tool_name=Edit, file_path ending .md → no output (skipped extension).""" + mod = _load_hook("auto_fix_diagnostics.py") + payload = json.dumps({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/foo.md"}}) + with patch("sys.stdin", io.StringIO(payload)): + mod.main() + captured = capsys.readouterr() + assert captured.out == "" + + +def test_auto_fix_does_not_crash_empty_stdin(): + """Empty/malformed JSON on stdin → no exception raised.""" + mod = _load_hook("auto_fix_diagnostics.py") + with patch("sys.stdin", io.StringIO("")): + mod.main() # must not raise + + +def test_auto_fix_does_not_crash_missing_fields(): + """Valid JSON but no tool_name → no exception raised.""" + mod = _load_hook("auto_fix_diagnostics.py") + payload = json.dumps({"some_other_key": "value"}) + with patch("sys.stdin", io.StringIO(payload)): + mod.main() # must not raise + + +def test_auto_fix_silent_fix_label(tmp_path, capsys): + """When run_python_checks returns an error, output contains [SILENT-FIX] not [AUTO-FIX].""" + mod = _load_hook("auto_fix_diagnostics.py") + + # Point to a real .py file so extension check passes + fake_py = tmp_path / "fake.py" + fake_py.write_text("x = 1\n", encoding="utf-8") + + payload = json.dumps({"tool_name": "Edit", "tool_input": {"file_path": str(fake_py)}}) + + with ( + patch("sys.stdin", io.StringIO(payload)), + patch.object(mod, "run_python_checks", return_value=["LINT: E501 line too long"]), + patch.object(mod, "run_seedgo_checklist", return_value=[]), + patch.object(mod, "run_pyright_check", return_value=([], False)), + patch.object(mod, "run_ruff_lint_structured", return_value=[]), + patch.object(mod, "save_diagnostics_state"), + ): + mod.main() + + captured = capsys.readouterr() + assert captured.out.strip() != "" + output = json.loads(captured.out) + context = output["hookSpecificOutput"]["additionalContext"] + assert "[SILENT-FIX]" in context + assert "[AUTO-FIX]" not in context + assert "[SILENT-FIX]" in output["systemMessage"] + + +def test_auto_fix_fallback_message_in_context(tmp_path, capsys): + """When pyright is unavailable (fallback_needed=True), context contains [FALLBACK] text.""" + mod = _load_hook("auto_fix_diagnostics.py") + + fake_py = tmp_path / "check.py" + fake_py.write_text("x = 1\n", encoding="utf-8") + + payload = json.dumps({"tool_name": "Edit", "tool_input": {"file_path": str(fake_py)}}) + + with ( + patch("sys.stdin", io.StringIO(payload)), + patch.object(mod, "run_python_checks", return_value=["LINT: E501 line too long"]), + patch.object(mod, "run_seedgo_checklist", return_value=[]), + patch.object(mod, "run_pyright_check", return_value=([], True)), + patch.object(mod, "run_ruff_lint_structured", return_value=[]), + patch.object(mod, "save_diagnostics_state"), + ): + mod.main() + + captured = capsys.readouterr() + output = json.loads(captured.out) + context = output["hookSpecificOutput"]["additionalContext"] + assert "[FALLBACK]" in context + assert "mcp__ide__getDiagnostics" in context + + +def test_auto_fix_fallback_stderr_announce(tmp_path, capsys): + """When fallback_needed=True, [FALLBACK ENGAGED] is printed to stderr.""" + mod = _load_hook("auto_fix_diagnostics.py") + + fake_py = tmp_path / "check2.py" + fake_py.write_text("x = 1\n", encoding="utf-8") + + payload = json.dumps({"tool_name": "Edit", "tool_input": {"file_path": str(fake_py)}}) + + with ( + patch("sys.stdin", io.StringIO(payload)), + patch.object(mod, "run_python_checks", return_value=[]), + patch.object(mod, "run_seedgo_checklist", return_value=[]), + patch.object(mod, "run_pyright_check", return_value=([], True)), + patch.object(mod, "run_ruff_lint_structured", return_value=[]), + patch.object(mod, "save_diagnostics_state"), + ): + mod.main() + + captured = capsys.readouterr() + assert "[FALLBACK ENGAGED" in captured.err + + +def test_run_pyright_check_returns_tuple_on_file_not_found(): + """run_pyright_check returns ([], True) when pyright binary is missing.""" + mod = _load_hook("auto_fix_diagnostics.py") + with patch("subprocess.run", side_effect=FileNotFoundError("pyright not found")): + result = mod.run_pyright_check("/tmp/some_file.py") + assert result == ([], True) + + +def test_run_pyright_check_returns_false_on_timeout(): + """run_pyright_check returns ([], False) on timeout — no fallback signal.""" + import subprocess + + mod = _load_hook("auto_fix_diagnostics.py") + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("pyright", 15)): + result = mod.run_pyright_check("/tmp/some_file.py") + assert result == ([], False) + + +def test_run_pyright_check_skips_hook_files(): + """run_pyright_check returns ([], False) for hook files without calling subprocess.""" + mod = _load_hook("auto_fix_diagnostics.py") + mock_run = MagicMock() + with patch("subprocess.run", mock_run): + result = mod.run_pyright_check("/home/user/.claude/hooks/some_hook.py") + mock_run.assert_not_called() + assert result == ([], False) + + +# --------------------------------------------------------------------------- +# pre_edit_gate.py tests +# (pre_edit_gate lives in ~/.claude/hooks — load from global location) +# --------------------------------------------------------------------------- + + +GLOBAL_HOOKS_DIR = Path.home() / ".claude" / "hooks" + + +def _load_global_hook(name: str): + """Import a hook script from the global ~/.claude/hooks/ directory.""" + path = GLOBAL_HOOKS_DIR / name + if not path.exists(): + pytest.skip(f"Global hook script not found: {path}") + spec = importlib.util.spec_from_file_location(name.replace(".py", ""), path) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) # type: ignore[union-attr] + return mod + + +def test_gate_allows_no_state_file(tmp_path, capsys, monkeypatch): + """No state file present → no output (allow).""" + mod = _load_global_hook("pre_edit_gate.py") + monkeypatch.setattr(mod, "STATE_FILE", tmp_path / "no_such_state.json") + + payload = json.dumps({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/seedgo/foo.py"}}) + with patch("sys.stdin", io.StringIO(payload)): + mod.main() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_gate_allows_same_file(tmp_path, capsys, monkeypatch): + """State has error for file A, editing file A → allow (no block output).""" + mod = _load_global_hook("pre_edit_gate.py") + + errored_file = tmp_path / "foo.py" + errored_file.write_text("x = 1\n", encoding="utf-8") + + state = {"file": str(errored_file), "errors": [{"line": 1, "message": "some error"}]} + state_file = tmp_path / "state.json" + state_file.write_text(json.dumps(state), encoding="utf-8") + monkeypatch.setattr(mod, "STATE_FILE", state_file) + + payload = json.dumps({"tool_name": "Edit", "tool_input": {"file_path": str(errored_file)}}) + with patch("sys.stdin", io.StringIO(payload)): + mod.main() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_gate_blocks_different_file_same_branch(tmp_path, capsys, monkeypatch): + """State has error for seedgo/foo.py, editing seedgo/bar.py → block + sys.exit(2).""" + mod = _load_global_hook("pre_edit_gate.py") + + # Use real AIPass src paths so _get_branch works + aipass_src = Path("/home/patrick/Projects/AIPass/src/aipass") + errored_file = str(aipass_src / "seedgo" / "foo.py") + edit_target = str(aipass_src / "seedgo" / "bar.py") + + state = {"file": errored_file, "errors": [{"line": 5, "message": "type error here"}]} + state_file = tmp_path / "state.json" + state_file.write_text(json.dumps(state), encoding="utf-8") + monkeypatch.setattr(mod, "STATE_FILE", state_file) + + payload = json.dumps({"tool_name": "Edit", "tool_input": {"file_path": edit_target}}) + with patch("sys.stdin", io.StringIO(payload)): + with pytest.raises(SystemExit) as exc_info: + mod.main() + + assert exc_info.value.code == 2 + captured = capsys.readouterr() + assert captured.out.strip() != "" + output = json.loads(captured.out) + assert output["decision"] == "block" + + +def test_gate_allows_different_branch(tmp_path, capsys, monkeypatch): + """State has error for flow/foo.py, editing seedgo/bar.py → allow (different branches).""" + mod = _load_global_hook("pre_edit_gate.py") + + aipass_src = Path("/home/patrick/Projects/AIPass/src/aipass") + errored_file = str(aipass_src / "flow" / "foo.py") + edit_target = str(aipass_src / "seedgo" / "bar.py") + + state = {"file": errored_file, "errors": [{"line": 1, "message": "error in flow"}]} + state_file = tmp_path / "state.json" + state_file.write_text(json.dumps(state), encoding="utf-8") + monkeypatch.setattr(mod, "STATE_FILE", state_file) + + payload = json.dumps({"tool_name": "Edit", "tool_input": {"file_path": edit_target}}) + with patch("sys.stdin", io.StringIO(payload)): + mod.main() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_gate_does_not_crash_malformed_stdin(monkeypatch, tmp_path): + """Bad JSON on stdin → no exception raised.""" + mod = _load_global_hook("pre_edit_gate.py") + monkeypatch.setattr(mod, "STATE_FILE", tmp_path / "no_state.json") + + with patch("sys.stdin", io.StringIO("not json!!!!")): + mod.main() # must not raise + + +def test_gate_allows_non_edit_tool(tmp_path, capsys, monkeypatch): + """Non-edit tool_name → no output regardless of state.""" + mod = _load_global_hook("pre_edit_gate.py") + + aipass_src = Path("/home/patrick/Projects/AIPass/src/aipass") + errored_file = str(aipass_src / "seedgo" / "foo.py") + edit_target = str(aipass_src / "seedgo" / "bar.py") + + state = {"file": errored_file, "errors": [{"line": 1, "message": "error"}]} + state_file = tmp_path / "state.json" + state_file.write_text(json.dumps(state), encoding="utf-8") + monkeypatch.setattr(mod, "STATE_FILE", state_file) + + payload = json.dumps({"tool_name": "Read", "tool_input": {"file_path": edit_target}}) + with patch("sys.stdin", io.StringIO(payload)): + mod.main() + + captured = capsys.readouterr() + assert captured.out == "" + + +# --------------------------------------------------------------------------- +# subagent_stop_gate.py tests +# --------------------------------------------------------------------------- + + +def test_subagent_gate_no_crash_empty_stdin(): + """Bad JSON on stdin → no exception raised.""" + mod = _load_hook("subagent_stop_gate.py") + with patch("sys.stdin", io.StringIO("bad json")): + mod.main() # must not raise + + +def test_subagent_gate_no_crash_no_files(capsys): + """Valid stdin but no modified files → no exception, no block output.""" + mod = _load_hook("subagent_stop_gate.py") + payload = json.dumps({"stop_hook_active": True}) + + with ( + patch("sys.stdin", io.StringIO(payload)), + patch.object(mod, "get_modified_py_files", return_value=[]), + ): + mod.main() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_subagent_gate_no_block_when_no_violations(capsys): + """Modified files present but no seedgo violations → no block output.""" + mod = _load_hook("subagent_stop_gate.py") + payload = json.dumps({"stop_hook_active": True}) + + with ( + patch("sys.stdin", io.StringIO(payload)), + patch.object(mod, "get_modified_py_files", return_value=["/tmp/foo.py"]), + patch.object(mod, "run_seedgo_checklist", return_value=[]), + ): + mod.main() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_subagent_gate_blocks_on_violations(capsys): + """Modified files with seedgo violations → block decision in output.""" + mod = _load_hook("subagent_stop_gate.py") + payload = json.dumps({"stop_hook_active": True}) + + with ( + patch("sys.stdin", io.StringIO(payload)), + patch.object(mod, "get_modified_py_files", return_value=["/tmp/foo.py"]), + patch.object(mod, "run_seedgo_checklist", return_value=["open() without encoding='utf-8'"]), + ): + mod.main() + + captured = capsys.readouterr() + assert captured.out.strip() != "" + output = json.loads(captured.out) + assert output["decision"] == "block"