diff --git a/.claude/hooks/engine.jsonl b/.claude/hooks/engine.jsonl index 9265eb46..22a08a87 100644 --- a/.claude/hooks/engine.jsonl +++ b/.claude/hooks/engine.jsonl @@ -77,3 +77,44 @@ {"ts": 1779124421.537384, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 99.3, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} {"ts": 1779124421.654711, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 116.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} {"ts": 1779124421.6555922, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 1805.7} +{"ts": 1779163144.6138675, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 46.0, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163144.6146653, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 337.3} +{"ts": 1779163151.1238678, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 684.6, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163151.124623, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 933.5} +{"ts": 1779163219.5444095, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 55.7, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163219.6031945, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 57.6, "stdout_len": 130, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163219.65857, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 54.1, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163219.7402287, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 80.5, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163219.7411332, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 4, "total_ms": 686.5} +{"ts": 1779163230.543275, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 53.9, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163230.5454974, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 1981.7} +{"ts": 1779163260.8500037, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 56.9, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163260.9615414, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 110.3, "stdout_len": 130, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163261.0168633, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 54.4, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163261.066856, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 48.9, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163261.0678494, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 4, "total_ms": 1144.6} +{"ts": 1779163287.7181246, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 101.0, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163287.719954, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 2324.9} +{"ts": 1779163350.5735116, "event": "Notification", "hook": "notification_sound", "exit_code": 0, "elapsed_ms": 56.4, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163350.574208, "event": "Notification", "action": "complete", "hooks_run": 0, "total_ms": 2560.9} +{"ts": 1779163395.6311812, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 85.5, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163395.7033641, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 71.0, "stdout_len": 130, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163395.790108, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 85.7, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163395.8714736, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 80.4, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163395.8721743, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 4, "total_ms": 1668.1} +{"ts": 1779163428.9231517, "event": "Notification", "hook": "notification_sound", "exit_code": 0, "elapsed_ms": 92.6, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163428.9238687, "event": "Notification", "action": "complete", "hooks_run": 0, "total_ms": 1155.0} +{"ts": 1779163470.642621, "event": "Notification", "hook": "notification_sound", "exit_code": 0, "elapsed_ms": 82.4, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779163470.6436064, "event": "Notification", "action": "complete", "hooks_run": 0, "total_ms": 2824.2} +{"ts": 1779250863.9149616, "event": "PreToolUse", "hook": "pre_edit_gate", "action": "skipped_no_match", "matcher": "Edit|MultiEdit|Write|NotebookEdit", "value": "Bash"} +{"ts": 1779250864.2848454, "event": "PreToolUse", "hook": "git_gate", "exit_code": 0, "elapsed_ms": 43.7, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779250864.2875721, "event": "PreToolUse", "hook": "engine_test_sound", "action": "skipped_disabled"} +{"ts": 1779250864.288863, "event": "PreToolUse", "action": "complete", "hooks_run": 0, "total_ms": 372.7} +{"ts": 1779250886.5456672, "event": "PreToolUse", "hook": "pre_edit_gate", "action": "skipped_no_match", "matcher": "Edit|MultiEdit|Write|NotebookEdit", "value": "Bash"} +{"ts": 1779250886.9372535, "event": "PreToolUse", "hook": "git_gate", "exit_code": 0, "elapsed_ms": 35.8, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779250886.9380789, "event": "PreToolUse", "hook": "engine_test_sound", "action": "skipped_disabled"} +{"ts": 1779250886.9388382, "event": "PreToolUse", "action": "complete", "hooks_run": 0, "total_ms": 392.5} +{"ts": 1779250909.1423523, "event": "PreToolUse", "hook": "pre_edit_gate", "exit_code": 0, "elapsed_ms": 52.4, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779250909.1921146, "event": "PreToolUse", "hook": "git_gate", "exit_code": 0, "elapsed_ms": 48.8, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779250909.1928554, "event": "PreToolUse", "hook": "engine_test_sound", "action": "skipped_disabled"} +{"ts": 1779250909.1935382, "event": "PreToolUse", "action": "complete", "hooks_run": 0, "total_ms": 488.8} diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json index e26c6beb..858ee861 100644 --- a/src/aipass/hooks/.seedgo/bypass.json +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -1,55 +1,96 @@ { "metadata": { - "version": "1.0.0", + "version": "1.1.0", "created": "2026-05-18", + "updated": "2026-05-19", "description": "Standards bypass configuration for this branch" }, "bypass": [ - { - "file": "apps/modules/engine.py", - "standard": "modules", - "reason": "Internal dispatch module called by bridges, not a drone-routable command module" - }, - { - "file": "apps/modules/engine.py", - "standard": "introspection", - "reason": "Internal dispatch module, not discoverable via drone @hooks" - }, { "file": "apps/modules/engine.py", "standard": "json_structure", - "reason": "Engine has its own JSONL diagnostic logging, does not use branch json_handler" + "reason": "Engine uses JSONL diagnostic logging, not branch json_handler — different purpose" }, { "file": "apps/handlers/bridges/claude.py", "standard": "handlers", - "reason": "Bridges are designed to import engine module — that is their entire purpose" + "reason": "Bridges import engine module by design — that is their entire purpose" }, { "file": "apps/handlers/bridges/claude.py", "standard": "json_structure", - "reason": "Bridge is a thin entry point, no JSON operations to log" + "reason": "Thin entry point, no JSON operations to log" + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "dead_code", + "reason": "Bridge called externally by provider settings subprocess — no internal import" + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "architecture", + "reason": "Bridge importing engine module is its architectural purpose" + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "imports", + "reason": "Bridge imports engine module by design — sole purpose" }, { "file": "apps/hooks.py", - "standard": "cli", - "reason": "Drone-routed entry point, output via sys.stdout.write to drone subprocess capture, not CLI" + "standard": "unused_function", + "reason": "print_introspection() called by drone's discovery system, not internal code" }, { - "file": "apps/hooks.py", - "standard": "cli_flags", - "reason": "Drone-routed entry point, --version handled in main(), not a standalone CLI binary" + "file": "apps/handlers/config/loader.py", + "standard": "json_structure", + "reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse — json_handler does not support this" + }, + { + "file": "apps/handlers/config/diagnostics.py", + "standard": "json_structure", + "reason": "JSONL append-only diagnostic log — different pattern from branch json_handler storage" + }, + { + "file": "apps/handlers/notification/tool_sound.py", + "standard": "json_structure", + "reason": "Sound handler — no JSON operations, plays WAV files" }, { "file": "tests/conftest.py", "standard": "architecture", "reason": "Test fixtures live in tests/, not in the 3-layer apps structure" }, + { + "file": "tests/test_tool_sound.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure" + }, + { + "file": "tests/test_tool_sound.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention" + }, + { + "file": "tests/test_tool_sound.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details" + }, + { + "file": "tests/test_tool_sound.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers" + }, { "file": "tests/test_engine.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure" }, + { + "file": "tests/test_engine.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details" + }, { "file": "tests/test_engine.py", "standard": "documentation", @@ -60,36 +101,6 @@ "standard": "help_text", "reason": "Test data contains command references as part of test fixtures, not user-facing help" }, - { - "file": "apps/handlers/bridges/claude.py", - "standard": "dead_code", - "reason": "Bridge called from provider settings (external subprocess), not internal imports" - }, - { - "file": "apps/handlers/bridges/claude.py", - "standard": "architecture", - "reason": "Bridge intentionally imports engine module — bridges exist to call the engine" - }, - { - "file": "apps/handlers/bridges/claude.py", - "standard": "imports", - "reason": "Bridge imports engine module by design — that is its sole purpose" - }, - { - "file": "apps/hooks.py", - "standard": "unused_function", - "reason": "print_introspection() called by drone's discovery system, not internal code" - }, - { - "file": "apps/handlers/bridges/claude.py", - "standard": "dead_code", - "reason": "Bridge called from provider settings (external subprocess), not imported internally" - }, - { - "file": "handlers/bridges/claude.py", - "standard": "dead_code", - "reason": "Bridge called externally by provider settings subprocess — no internal import" - }, { "file": "tests/test_engine.py", "standard": "json_handler", @@ -102,18 +113,6 @@ } ], "notes": { - "usage": "Add entries to bypass specific seedgo standard violations", - "example": { - "file": "apps/example.py", - "standard": "imports", - "reason": "Legacy import required for compatibility" - }, - "fields": { - "file": "Relative path to the file", - "standard": "Which standard to bypass (imports, cli, naming, etc.)", - "lines": "Optional array of line numbers", - "pattern": "Optional regex pattern to match", - "reason": "Why this bypass exists" - } + "removed_2026-05-19": "Stripped 4 illegitimate bypasses — hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead." } } diff --git a/src/aipass/hooks/apps/handlers/config/__init__.py b/src/aipass/hooks/apps/handlers/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/apps/handlers/config/diagnostics.py b/src/aipass/hooks/apps/handlers/config/diagnostics.py new file mode 100644 index 00000000..01db6a65 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/config/diagnostics.py @@ -0,0 +1,37 @@ +# =================== AIPass ==================== +# Name: diagnostics.py +# Version: 1.0.0 +# Description: JSONL diagnostic logging for hook engine +# Branch: hooks +# Layer: apps/handlers/config +# Created: 2026-05-19 +# Modified: 2026-05-19 +# ============================================= + +"""JSONL diagnostic logging — appends structured entries for hook activity.""" + +import json +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent.parent +LOG_FILE = BRANCH_ROOT / "logs" / "engine.jsonl" + + +def log_entry(entry: dict) -> None: + """Append a JSONL log entry for detailed diagnostics.""" + try: + LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + except OSError as exc: + logger.error("[HOOKS] log write failed: %s", exc) + + +def tail_log(count: int = 20) -> list[str]: + """Return the last N lines from the engine log.""" + if not LOG_FILE.exists(): + return [] + lines = LOG_FILE.read_text().strip().split("\n") + return lines[-count:] diff --git a/src/aipass/hooks/apps/handlers/config/loader.py b/src/aipass/hooks/apps/handlers/config/loader.py new file mode 100644 index 00000000..1648e53a --- /dev/null +++ b/src/aipass/hooks/apps/handlers/config/loader.py @@ -0,0 +1,38 @@ +# =================== AIPass ==================== +# Name: loader.py +# Version: 1.0.0 +# Description: Hook config loader — finds and parses .aipass/hooks.json +# Branch: hooks +# Layer: apps/handlers/config +# Created: 2026-05-19 +# Modified: 2026-05-19 +# ============================================= + +"""Loads per-project hook configuration from .aipass/hooks.json.""" + +import json +import os +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +AIPASS_HOME = os.environ.get("AIPASS_HOME", "") + + +def find_project_config() -> dict | None: + """Walk up from CWD looking for .aipass/hooks.json.""" + search = Path.cwd() + home = Path.home() + while search != home and search.parent != search: + config = search / ".aipass" / "hooks.json" + if config.exists(): + try: + raw = config.read_text(encoding="utf-8") + if AIPASS_HOME: + raw = raw.replace("$AIPASS_HOME", AIPASS_HOME) + return json.loads(raw) + except (json.JSONDecodeError, OSError) as exc: + logger.error("[HOOKS] bad config %s: %s", config, exc) + return None + search = search.parent + return None diff --git a/src/aipass/hooks/apps/handlers/notification/tool_sound.py b/src/aipass/hooks/apps/handlers/notification/tool_sound.py new file mode 100644 index 00000000..716eba89 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/notification/tool_sound.py @@ -0,0 +1,68 @@ +# =================== AIPass ==================== +# Name: tool_sound.py +# Version: 1.1.0 +# Description: Announces hook name via Piper TTS on tool use +# Branch: hooks +# Layer: apps/handlers/notification +# Created: 2026-05-19 +# Modified: 2026-05-19 +# ============================================= + +"""Announces hook name via Piper TTS when the AI uses tools (PreToolUse event).""" + +import subprocess +import tempfile +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper" +PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx" + + +def _speak(text: str) -> None: + """Generate speech via Piper TTS and play it (fire-and-forget).""" + if not PIPER_BIN.exists() or not PIPER_VOICE.exists(): + logger.info("[HOOKS] tool_sound: piper not available") + return + + try: + wav_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + wav_path = wav_file.name + wav_file.close() + + piper_result = subprocess.run( + [str(PIPER_BIN), "-m", str(PIPER_VOICE), "-f", wav_path], + input=text, + capture_output=True, + text=True, + timeout=5, + ) + + if piper_result.returncode == 0 and Path(wav_path).exists(): + subprocess.Popen( + ["aplay", "-q", wav_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except subprocess.TimeoutExpired: + logger.info("[HOOKS] tool_sound: piper timed out") + except OSError as exc: + logger.info("[HOOKS] tool_sound: playback error: %s", exc) + + +def handle(hook_data: dict) -> dict: + """Announce hook name for matching tool use events. + + Args: + hook_data: Parsed hook event dict from engine (tool_name, etc.) + + Returns: + Result dict with stdout (empty) and exit_code. + """ + tool_name = hook_data.get("tool_name", "") + if not tool_name: + return {"stdout": "", "exit_code": 0} + + _speak(f"tool sound: {tool_name}") + return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/hooks.py b/src/aipass/hooks/apps/hooks.py index 1cbe2f68..8132c611 100644 --- a/src/aipass/hooks/apps/hooks.py +++ b/src/aipass/hooks/apps/hooks.py @@ -1,94 +1,141 @@ # =================== AIPass ==================== # Name: hooks.py -# Version: 1.0.0 +# Version: 1.1.0 # Description: Hook infrastructure — drone entry point # Branch: hooks # Layer: apps # Created: 2026-05-18 -# Modified: 2026-05-18 +# Modified: 2026-05-19 # ============================================= """ -HOOKS Branch — Hook infrastructure for AIPass. +HOOKS Branch - Main Orchestrator -Owns all hook dispatch via engine.py. Platform bridges (Claude, Codex, Gemini) -call the engine, which reads per-project config and routes to handlers. +Auto-discovery architecture: +- Scans modules/ directory for .py files with handle_command() +- Routes commands to discovered modules automatically +- No manual imports or routing needed """ import os import sys +import importlib from pathlib import Path +from typing import Any os.environ.setdefault("AIPASS_BRANCH_NAME", "hooks") from aipass.prax.apps.modules.logger import system_logger as logger # noqa: E402 +from aipass.cli.apps.modules import err_console # noqa: E402 + +CONSOLE = err_console + +# ============================================================================= +# MODULE DISCOVERY +# ============================================================================= + +MODULES_DIR = Path(__file__).parent / "modules" -def print_help() -> None: - """Print hook system help.""" - logger.info("[HOOKS] help requested") - sys.stdout.write( - "HOOKS - Hook infrastructure for AIPass\n" - "\n" - "USAGE:\n" - " drone @hooks status Show hook config for current project\n" - " drone @hooks log Tail recent hook activity\n" - " drone @hooks test Run hook test suite\n" - " drone @hooks --help This help\n" - " drone @hooks --version Version info\n" - "\n" - "MODULES:\n" - " engine Core dispatch — routes events to handlers via config\n" - "\n" - "BRIDGES:\n" - " claude Claude Code bridge (provider settings entry point)\n" - " codex Codex bridge (planned)\n" - " gemini Gemini bridge (planned)\n" - ) +def discover_modules() -> list[Any]: + """Auto-discover modules in modules/ directory.""" + modules = [] + + if not MODULES_DIR.exists(): + return modules + + for file_path in sorted(MODULES_DIR.glob("*.py")): + if file_path.name.startswith("_"): + continue + + module_names = [ + f"aipass.hooks.apps.modules.{file_path.stem}", + f"apps.modules.{file_path.stem}", + ] + + loaded = False + for module_name in module_names: + try: + module = importlib.import_module(module_name) + if hasattr(module, "handle_command"): + modules.append(module) + loaded = True + break + except (ImportError, ModuleNotFoundError) as e: + logger.info("[HOOKS] Module %s not found: %s", module_name, e) + continue + except Exception as e: + logger.error("[HOOKS] Failed to load module %s: %s", module_name, e) + loaded = True + break + + if not loaded: + logger.error("[HOOKS] Could not import module %s", file_path.stem) + + return modules -def print_introspection() -> dict: - """Return branch introspection data for drone discovery.""" - return { - "branch": "hooks", - "role": "hook_infrastructure", - "commands": ["status", "log", "test"], - "modules": ["engine"], - "bridges": ["claude"], - } +def print_introspection(): + """Print branch introspection — discovered modules and capabilities.""" + modules = discover_modules() + CONSOLE.print("[bold cyan]HOOKS[/bold cyan] — Hook Infrastructure for AIPass") + CONSOLE.print(f" Modules discovered: {len(modules)}") + for module in modules: + name = module.__name__.split(".")[-1] + desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" + CONSOLE.print(f" {name:20} {desc}") + + +def print_help(): + """Print CLI help — usage instructions and available commands.""" + modules = discover_modules() + CONSOLE.print("[bold cyan]HOOKS[/bold cyan] — Usage") + CONSOLE.print() + CONSOLE.print(" drone @hooks [args...]") + CONSOLE.print() + CONSOLE.print("[bold]COMMANDS:[/bold]") + for module in modules: + name = module.__name__.split(".")[-1] + desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" + CONSOLE.print(f" {name:20} {desc}") + CONSOLE.print() + CONSOLE.print("[bold]BRIDGES:[/bold]") + CONSOLE.print(" claude Claude Code bridge (provider settings entry point)") + CONSOLE.print() + CONSOLE.print("[bold]FLAGS:[/bold]") + CONSOLE.print(" --help, -h Show this help message") + CONSOLE.print(" --version, -V Show version") + + +def route_command(command: str, args: list[str], modules: list[Any]) -> bool: + """Route command to appropriate module.""" + for module in modules: + try: + if module.handle_command(command, args): + return True + except Exception as e: + logger.error("[HOOKS] Module %s error: %s", module.__name__, e) + return False + + +# ============================================================================= +# MAIN ENTRY POINT +# ============================================================================= def handle_command(command: str, args: list) -> bool: - """Route commands to appropriate handler.""" - if command == "status": - from aipass.hooks.apps.modules.engine import find_project_config + """Entry point for drone routing.""" + modules = discover_modules() - config = find_project_config() - if config is None: - sys.stdout.write("No .aipass/hooks.json found for current project\n") - else: - enabled = config.get("hooks_enabled", True) - sys.stdout.write(f"Hooks enabled: {enabled}\n") - for event_type, hooks in config.items(): - if event_type.startswith("_") or event_type == "hooks_enabled": - continue - if isinstance(hooks, dict): - active = sum(1 for h in hooks.values() if isinstance(h, dict) and h.get("enabled", True)) - total = sum(1 for h in hooks.values() if isinstance(h, dict)) - sys.stdout.write(f" {event_type}: {active}/{total} hooks active\n") + if command in ["--help", "-h", "help"]: + print_help() return True - if command == "log": - log_file = Path(__file__).resolve().parent.parent / "logs" / "engine.jsonl" - if not log_file.exists(): - sys.stdout.write("No engine log found\n") - else: - lines = log_file.read_text().strip().split("\n") - for line in lines[-20:]: - sys.stdout.write(line + "\n") + if command in ["--version", "-V"]: + CONSOLE.print("hooks 1.1.0") return True - return False + return route_command(command, args, modules) def main() -> int: @@ -96,28 +143,13 @@ def main() -> int: args = sys.argv[1:] if not args: - data = print_introspection() - sys.stdout.write(f"HOOKS — {data['role']}\n") - sys.stdout.write(f" Modules: {', '.join(data['modules'])}\n") - sys.stdout.write(f" Bridges: {', '.join(data['bridges'])}\n") - sys.stdout.write(f" Commands: {', '.join(data['commands'])}\n") + print_introspection() return 0 - if args[0] in ("--help", "-h", "help"): - print_help() + if handle_command(args[0], args[1:]): return 0 - if args[0] in ("--version", "-V"): - sys.stdout.write("hooks 1.0.0\n") - return 0 - - command = args[0] - remaining = args[1:] if len(args) > 1 else [] - - if handle_command(command, remaining): - return 0 - - sys.stdout.write(f"Unknown command: {command}. Try: drone @hooks --help\n") + CONSOLE.print(f"Unknown command: {args[0]}. Try: drone @hooks --help") return 1 diff --git a/src/aipass/hooks/apps/modules/engine.py b/src/aipass/hooks/apps/modules/engine.py index 39680887..003dab0f 100644 --- a/src/aipass/hooks/apps/modules/engine.py +++ b/src/aipass/hooks/apps/modules/engine.py @@ -1,20 +1,16 @@ # =================== AIPass ==================== # Name: engine.py -# Version: 1.0.0 +# Version: 1.1.0 # Description: Hook engine — unified dispatcher for all hook events # Branch: hooks # Layer: apps/modules # Created: 2026-05-18 -# Modified: 2026-05-18 +# Modified: 2026-05-19 # ============================================= -""" -Hook Engine — core dispatch logic. - -Reads per-project config (.aipass/hooks.json), routes to registered hooks, -logs everything via prax + JSONL. Called by platform bridges, not directly. -""" +"""Hook engine — dispatches hook events to handlers, logs via prax + JSONL.""" +import importlib import json import os import subprocess @@ -22,39 +18,12 @@ import time from pathlib import Path from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.cli.apps.modules import err_console +from aipass.hooks.apps.handlers.config.loader import find_project_config +from aipass.hooks.apps.handlers.config.diagnostics import log_entry as _log, tail_log -AIPASS_HOME = os.environ.get("AIPASS_HOME", "") +CONSOLE = err_console BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent -LOG_FILE = BRANCH_ROOT / "logs" / "engine.jsonl" - - -def find_project_config() -> dict | None: - """Walk up from CWD looking for .aipass/hooks.json.""" - search = Path.cwd() - home = Path.home() - while search != home and search.parent != search: - config = search / ".aipass" / "hooks.json" - if config.exists(): - try: - raw = config.read_text(encoding="utf-8") - if AIPASS_HOME: - raw = raw.replace("$AIPASS_HOME", AIPASS_HOME) - return json.loads(raw) - except (json.JSONDecodeError, OSError) as exc: - logger.error("[HOOKS] bad config %s: %s", config, exc) - return None - search = search.parent - return None - - -def _log(entry: dict) -> None: - """Append a JSONL log entry for detailed diagnostics.""" - try: - LOG_FILE.parent.mkdir(parents=True, exist_ok=True) - with open(LOG_FILE, "a", encoding="utf-8") as f: - f.write(json.dumps(entry, ensure_ascii=False) + "\n") - except OSError as exc: - logger.error("[HOOKS] log write failed: %s", exc) def _run_hook(hook_cmd: str, stdin_data: str, timeout_s: int = 30) -> dict: @@ -88,6 +57,27 @@ def _run_hook(hook_cmd: str, stdin_data: str, timeout_s: int = 30) -> dict: return {"exit_code": -1, "stdout": "", "stderr": str(exc), "elapsed_ms": round(elapsed_ms, 1)} +def _run_handler(handler_path: str, hook_data: dict) -> dict: + """Call a handler function directly (no subprocess). Module imports handler.""" + start = time.monotonic() + try: + module_path, func_name = handler_path.rsplit(".", 1) + module = importlib.import_module(module_path) + handler_func = getattr(module, func_name) + result = handler_func(hook_data) + elapsed_ms = (time.monotonic() - start) * 1000 + return { + "exit_code": result.get("exit_code", 0), + "stdout": result.get("stdout", ""), + "stderr": "", + "elapsed_ms": round(elapsed_ms, 1), + } + except Exception as exc: + elapsed_ms = (time.monotonic() - start) * 1000 + logger.error("[HOOKS] handler error %s: %s", handler_path, exc) + return {"exit_code": -1, "stdout": "", "stderr": str(exc), "elapsed_ms": round(elapsed_ms, 1)} + + def _matches(matcher: str, value: str) -> bool: """Check if a hook's matcher string matches the given value. Empty matcher = always match.""" if not matcher: @@ -96,11 +86,7 @@ def _matches(matcher: str, value: str) -> bool: def dispatch(event_type: str, stdin_data: str, config: dict) -> str: - """Core dispatch — run hooks for event, return merged stdout. - - Bail semantics: first hook returning exit=2 with {"decision":"block"} JSON - on stdout stops execution. Exit=2 without JSON = crash (log + continue). - """ + """Core dispatch — run hooks for event, return merged stdout.""" if not config.get("hooks_enabled", True): logger.info("[HOOKS] all hooks disabled") _log({"ts": time.time(), "event": event_type, "action": "all_hooks_disabled"}) @@ -112,6 +98,7 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> str: return "" match_value = "" + parsed = {} try: parsed = json.loads(stdin_data) if stdin_data.strip() else {} match_value = parsed.get("tool_name", "") or parsed.get("compact_type", "") or parsed.get("type", "") @@ -127,9 +114,10 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> str: _log({"ts": time.time(), "event": event_type, "hook": hook_name, "action": "skipped_disabled"}) continue + handler = hook_def.get("handler", "") command = hook_def.get("command", "") matcher = hook_def.get("matcher", "") - if not command: + if not handler and not command: continue if matcher and not _matches(matcher, match_value): @@ -145,8 +133,11 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> str: ) continue - hook_timeout = hook_def.get("timeout", 30) - result = _run_hook(command, stdin_data, timeout_s=hook_timeout) + if handler: + result = _run_handler(handler, parsed) + else: + hook_timeout = hook_def.get("timeout", 30) + result = _run_hook(command, stdin_data, timeout_s=hook_timeout) logger.info( "[HOOKS] %s.%s exit=%d out=%db %dms", @@ -224,3 +215,62 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> str: ) return "\n".join(outputs) + + +# ============================================================================= +# MODULE INTERFACE (drone @hooks routing) +# ============================================================================= + + +def print_introspection(): + """Print module structure — connected handlers.""" + CONSOLE.print("[bold cyan]engine[/bold cyan] Module") + CONSOLE.print(" Connected Handlers:") + handlers_root = BRANCH_ROOT / "apps" / "handlers" + for category_dir in sorted(handlers_root.iterdir()): + if not category_dir.is_dir() or category_dir.name.startswith("_"): + continue + handler_files = [f.name for f in sorted(category_dir.glob("*.py")) if not f.name.startswith("_")] + if handler_files: + CONSOLE.print(f" handlers/{category_dir.name}/ — {', '.join(handler_files)}") + + +def handle_command(command: str, args: list) -> bool: + """Route engine commands from drone @hooks.""" + if not args and command in ("engine", ""): + print_introspection() + return True + + if command in ("--help", "-h", "help"): + CONSOLE.print("[bold cyan]engine[/bold cyan] — Hook dispatch engine") + CONSOLE.print() + CONSOLE.print(" drone @hooks status Show hook config for current project") + CONSOLE.print(" drone @hooks log Tail recent hook activity") + return True + + if command == "status": + config = find_project_config() + if config is None: + CONSOLE.print("No .aipass/hooks.json found for current project") + else: + enabled = config.get("hooks_enabled", True) + CONSOLE.print(f"Hooks enabled: {enabled}") + for event_type, hooks in config.items(): + if event_type.startswith("_") or event_type == "hooks_enabled": + continue + if isinstance(hooks, dict): + active = sum(1 for h in hooks.values() if isinstance(h, dict) and h.get("enabled", True)) + total = sum(1 for h in hooks.values() if isinstance(h, dict)) + CONSOLE.print(f" {event_type}: {active}/{total} hooks active") + return True + + if command == "log": + lines = tail_log(20) + if not lines: + CONSOLE.print("No engine log found") + else: + for line in lines: + CONSOLE.print(line) + return True + + return False diff --git a/src/aipass/hooks/tests/test_engine.py b/src/aipass/hooks/tests/test_engine.py index 56a15bcb..5a04cf6a 100644 --- a/src/aipass/hooks/tests/test_engine.py +++ b/src/aipass/hooks/tests/test_engine.py @@ -288,7 +288,7 @@ class TestFindProjectConfig: } (config_dir / "hooks.json").write_text(json.dumps(config)) with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir): - with patch("aipass.hooks.apps.modules.engine.AIPASS_HOME", "/test/path"): + with patch("aipass.hooks.apps.handlers.config.loader.AIPASS_HOME", "/test/path"): result = find_project_config() assert "/test/path/hook.py" in result["Stop"]["sound"]["command"] @@ -298,7 +298,7 @@ class TestLog: def test_writes_jsonl_entry(self, temp_test_dir, mock_logger): log_file = temp_test_dir / "test.jsonl" - with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): _log({"event": "Test", "action": "test_write"}) lines = log_file.read_text().strip().split("\n") assert len(lines) == 1 @@ -308,13 +308,13 @@ class TestLog: def test_creates_parent_directory(self, temp_test_dir, mock_logger): log_file = temp_test_dir / "subdir" / "test.jsonl" - with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): _log({"event": "Test"}) assert log_file.exists() def test_appends_multiple_entries(self, temp_test_dir, mock_logger): log_file = temp_test_dir / "test.jsonl" - with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): _log({"event": "A"}) _log({"event": "B"}) lines = log_file.read_text().strip().split("\n") @@ -348,13 +348,13 @@ class TestHooksEntryPoint: with patch("sys.argv", ["hooks", "nonexistent_command"]): assert main() == 1 - def test_print_introspection_returns_dict(self): + def test_print_introspection_prints_output(self, capsys): from aipass.hooks.apps.hooks import print_introspection - result = print_introspection() - assert isinstance(result, dict) - assert result["branch"] == "hooks" - assert "engine" in result["modules"] + print_introspection() + captured = capsys.readouterr() + assert "HOOKS" in captured.err + assert "Modules discovered" in captured.err def test_handle_command_returns_bool(self): from aipass.hooks.apps.hooks import handle_command @@ -415,7 +415,7 @@ class TestErrorResilience: def test_log_write_failure_does_not_crash(self, mock_logger): with patch("builtins.open", side_effect=OSError("disk full")): - with patch("aipass.hooks.apps.modules.engine.LOG_FILE") as mock_path: + with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE") as mock_path: mock_path.parent.mkdir = MagicMock() _log({"event": "Test"}) @@ -434,7 +434,7 @@ class TestDataStructureContracts: def test_log_entry_has_required_fields(self, temp_test_dir, mock_logger): log_file = temp_test_dir / "test.jsonl" - with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): _log({"ts": 123.0, "event": "Test", "action": "check"}) entry = json.loads(log_file.read_text().strip()) assert "ts" in entry @@ -483,7 +483,7 @@ class TestInitProvisioning: def test_log_auto_creates_directory(self, temp_test_dir, mock_logger): log_file = temp_test_dir / "new_dir" / "engine.jsonl" - with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): _log({"event": "init_test"}) assert log_file.parent.exists() @@ -501,7 +501,7 @@ class TestInitProvisioning: def test_log_no_overwrite_on_append(self, temp_test_dir, mock_logger): log_file = temp_test_dir / "test.jsonl" log_file.write_text('{"existing": true}\n') - with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): _log({"event": "new"}) lines = log_file.read_text().strip().split("\n") assert len(lines) == 2 @@ -557,13 +557,13 @@ class TestConftest: class TestCliRouting: """Additional CLI routing tests for print_help and output capture.""" - def test_print_help_writes_to_stdout(self, capsys): + def test_print_help_writes_output(self, capsys): from aipass.hooks.apps.hooks import print_help print_help() captured = capsys.readouterr() - assert "HOOKS" in captured.out - assert "drone @hooks" in captured.out + assert "HOOKS" in captured.err + assert "drone @hooks" in captured.err def test_output_capture_status(self, capsys): from aipass.hooks.apps.hooks import handle_command @@ -571,7 +571,7 @@ class TestCliRouting: with patch("aipass.hooks.apps.modules.engine.find_project_config", return_value=None): handle_command("status", []) captured = capsys.readouterr() - assert "No .aipass/hooks.json" in captured.out + assert "No .aipass/hooks.json" in captured.err def test_version_output(self, capsys): from aipass.hooks.apps.hooks import main @@ -579,7 +579,7 @@ class TestCliRouting: with patch("sys.argv", ["hooks", "--version"]): main() captured = capsys.readouterr() - assert "1.0.0" in captured.out + assert "1.1.0" in captured.err class TestConfigDataContracts: @@ -596,7 +596,7 @@ class TestConfigDataContracts: def test_data_keys_in_log_entry(self, temp_test_dir, mock_logger): log_file = temp_test_dir / "test.jsonl" - with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): _log({"ts": 1.0, "event": "Test", "hook": "test_hook", "exit_code": 0}) entry = json.loads(log_file.read_text().strip()) assert "ts" in entry @@ -609,7 +609,8 @@ class TestPathContracts: """Tests for path-returning functions.""" def test_paths_return_path(self): - from aipass.hooks.apps.modules.engine import BRANCH_ROOT, LOG_FILE + from aipass.hooks.apps.modules.engine import BRANCH_ROOT + from aipass.hooks.apps.handlers.config.diagnostics import LOG_FILE assert isinstance(BRANCH_ROOT, Path) assert isinstance(LOG_FILE, Path) @@ -638,7 +639,7 @@ class TestErrorResilienceExtended: def test_missing_file_in_log_path(self, temp_test_dir, mock_logger): missing = temp_test_dir / "missing" / "nonexistent.jsonl" - with patch("aipass.hooks.apps.modules.engine.LOG_FILE", missing): + with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", missing): _log({"event": "test_missing"}) assert missing.exists() @@ -676,13 +677,13 @@ class TestJsonHandlerNotApplicable: def test_log_default_factory(self, temp_test_dir, mock_logger): log_file = temp_test_dir / "factory.jsonl" assert not log_file.exists() - with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): _log({"event": "factory_test"}) assert log_file.exists() def test_log_validate_json_output(self, temp_test_dir, mock_logger): log_file = temp_test_dir / "validate.jsonl" - with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): _log({"event": "A", "ts": 1.0}) _log({"event": "B", "ts": 2.0}) for line in log_file.read_text().strip().split("\n"): @@ -690,20 +691,20 @@ class TestJsonHandlerNotApplicable: assert isinstance(entry, dict) def test_log_get_path(self): - from aipass.hooks.apps.modules.engine import LOG_FILE + from aipass.hooks.apps.handlers.config.diagnostics import LOG_FILE assert LOG_FILE.name == "engine.jsonl" assert "logs" in str(LOG_FILE) def test_log_ensure_exists(self, temp_test_dir, mock_logger): log_file = temp_test_dir / "new_dir" / "ensure.jsonl" - with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): _log({"ensure": True}) assert log_file.parent.exists() def test_log_save_entry(self, temp_test_dir, mock_logger): log_file = temp_test_dir / "save.jsonl" - with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): _log({"saved": True, "value": 42}) entry = json.loads(log_file.read_text().strip()) assert entry["saved"] is True @@ -718,7 +719,7 @@ class TestJsonHandlerNotApplicable: def test_log_operation_recorded(self, temp_test_dir, mock_logger): log_file = temp_test_dir / "ops.jsonl" - with patch("aipass.hooks.apps.modules.engine.LOG_FILE", log_file): + with patch("aipass.hooks.apps.handlers.config.diagnostics.LOG_FILE", log_file): _log({"event": "PreToolUse", "hook": "test", "exit_code": 0}) entry = json.loads(log_file.read_text().strip()) assert entry["event"] == "PreToolUse" diff --git a/src/aipass/hooks/tests/test_tool_sound.py b/src/aipass/hooks/tests/test_tool_sound.py new file mode 100644 index 00000000..5602856f --- /dev/null +++ b/src/aipass/hooks/tests/test_tool_sound.py @@ -0,0 +1,128 @@ +# =================== AIPass ==================== +# Name: test_tool_sound.py +# Version: 1.1.0 +# Description: Tests for tool_sound notification handler +# Branch: hooks +# Created: 2026-05-19 +# Modified: 2026-05-19 +# ============================================= + +"""Tests for handlers/notification/tool_sound.py.""" + +from unittest.mock import patch, MagicMock + + +class TestToolSoundHandler: + """Core handler behavior tests.""" + + def test_handle_returns_result_dict(self): + from aipass.hooks.apps.handlers.notification.tool_sound import handle + + with patch("aipass.hooks.apps.handlers.notification.tool_sound._speak"): + result = handle({"tool_name": "Bash"}) + + assert isinstance(result, dict) + assert "stdout" in result + assert "exit_code" in result + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + def test_speaks_tool_name(self): + from aipass.hooks.apps.handlers.notification.tool_sound import handle + + with patch("aipass.hooks.apps.handlers.notification.tool_sound._speak") as mock_speak: + handle({"tool_name": "Edit"}) + + mock_speak.assert_called_once_with("tool sound: Edit") + + def test_no_speak_when_no_tool_name(self): + from aipass.hooks.apps.handlers.notification.tool_sound import handle + + with patch("aipass.hooks.apps.handlers.notification.tool_sound._speak") as mock_speak: + handle({}) + + mock_speak.assert_not_called() + + def test_no_speak_when_empty_tool_name(self): + from aipass.hooks.apps.handlers.notification.tool_sound import handle + + with patch("aipass.hooks.apps.handlers.notification.tool_sound._speak") as mock_speak: + handle({"tool_name": ""}) + + mock_speak.assert_not_called() + + +class TestSpeakFunction: + """Piper TTS integration tests.""" + + def test_speak_calls_piper_then_aplay(self): + from aipass.hooks.apps.handlers.notification.tool_sound import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.tool_sound.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.tool_sound.PIPER_VOICE") as mock_voice, + patch("aipass.hooks.apps.handlers.notification.tool_sound.subprocess") as mock_sub, + patch("aipass.hooks.apps.handlers.notification.tool_sound.tempfile") as mock_tmp, + patch("aipass.hooks.apps.handlers.notification.tool_sound.Path") as mock_path, + ): + mock_piper_bin.exists.return_value = True + mock_voice.exists.return_value = True + mock_file = MagicMock() + mock_file.name = "/tmp/test.wav" + mock_tmp.NamedTemporaryFile.return_value = mock_file + mock_sub.run.return_value = MagicMock(returncode=0) + mock_path.return_value.exists.return_value = True + + _speak("test text") + + mock_sub.run.assert_called_once() + mock_sub.Popen.assert_called_once() + + def test_speak_skips_when_piper_missing(self): + from aipass.hooks.apps.handlers.notification.tool_sound import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.tool_sound.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.tool_sound.subprocess") as mock_sub, + ): + mock_piper_bin.exists.return_value = False + _speak("test") + + mock_sub.run.assert_not_called() + + def test_speak_graceful_on_timeout(self): + import subprocess as real_sub + from aipass.hooks.apps.handlers.notification.tool_sound import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.tool_sound.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.tool_sound.PIPER_VOICE") as mock_voice, + patch("aipass.hooks.apps.handlers.notification.tool_sound.subprocess") as mock_sub, + patch("aipass.hooks.apps.handlers.notification.tool_sound.tempfile") as mock_tmp, + ): + mock_piper_bin.exists.return_value = True + mock_voice.exists.return_value = True + mock_file = MagicMock() + mock_file.name = "/tmp/test.wav" + mock_tmp.NamedTemporaryFile.return_value = mock_file + mock_sub.run.side_effect = real_sub.TimeoutExpired("piper", 5) + mock_sub.TimeoutExpired = real_sub.TimeoutExpired + + _speak("test") + + def test_speak_graceful_on_os_error(self): + from aipass.hooks.apps.handlers.notification.tool_sound import _speak + + with ( + patch("aipass.hooks.apps.handlers.notification.tool_sound.PIPER_BIN") as mock_piper_bin, + patch("aipass.hooks.apps.handlers.notification.tool_sound.PIPER_VOICE") as mock_voice, + patch("aipass.hooks.apps.handlers.notification.tool_sound.subprocess.run", side_effect=OSError("broken")), + patch("aipass.hooks.apps.handlers.notification.tool_sound.tempfile") as mock_tmp, + ): + mock_piper_bin.exists.return_value = True + mock_voice.exists.return_value = True + mock_file = MagicMock() + mock_file.name = "/tmp/test.wav" + mock_tmp.NamedTemporaryFile.return_value = mock_file + + _speak("test") diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/introspection_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/introspection_check.py index 2136b637..e92b7eb4 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/introspection_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/introspection_check.py @@ -141,6 +141,18 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: introspection_check = check_print_introspection_exists(tree, path.name) checks.append(introspection_check) + # Check 1b: print_introspection produces output (not just returns data) + if introspection_check["passed"]: + behavior_check = check_introspection_output_behavior(tree, path.name) + if behavior_check: + checks.append(behavior_check) + + # Check 1c: print_introspection uses dynamic discovery (no hardcoded module lists) + if introspection_check["passed"]: + discovery_check = check_introspection_dynamic_discovery(tree, path.name) + if discovery_check: + checks.append(discovery_check) + # Check 2: Execution order (entry points only) if is_entry_point: order_check = check_execution_order(tree, content, path.name) @@ -219,6 +231,122 @@ def check_print_introspection_exists(tree: ast.Module, filename: str) -> Dict: } +def _find_print_introspection(tree: ast.Module) -> Optional[ast.FunctionDef]: + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name == "print_introspection": + return node + return None + + +def _has_output_calls(func_node: ast.FunctionDef) -> bool: + for node in ast.walk(func_node): + if not isinstance(node, ast.Call): + continue + if isinstance(node.func, ast.Name): + name = node.func.id + if name == "print": + return True + # Delegation to helper functions counts as output production + if name.startswith("_"): + return True + if isinstance(node.func, ast.Attribute): + attr = node.func.attr + if attr in ("print", "write"): + return True + return False + + +def _returns_data(func_node: ast.FunctionDef) -> Optional[int]: + for node in ast.walk(func_node): + if isinstance(node, ast.Return) and node.value is not None: + if not isinstance(node.value, ast.Constant) or node.value.value is not None: + return node.lineno + return None + + +def check_introspection_output_behavior(tree: ast.Module, filename: str) -> Optional[Dict]: + """Verify print_introspection() produces output via print/console.print, not just returns data.""" + func = _find_print_introspection(tree) + if func is None: + return None + + has_output = _has_output_calls(func) + return_line = _returns_data(func) + + if return_line is not None and not has_output: + return { + "name": "Introspection output", + "passed": False, + "message": ( + f"print_introspection() in {filename} returns data (line {return_line}) " + f"but does not print — should use console.print() to display structure" + ), + } + + if not has_output and return_line is None: + return { + "name": "Introspection output", + "passed": False, + "message": ( + f"print_introspection() in {filename} produces no output — " + f"should use console.print() to display branch structure" + ), + } + + return { + "name": "Introspection output", + "passed": True, + "message": "print_introspection() produces output", + } + + +def check_introspection_dynamic_discovery(tree: ast.Module, filename: str) -> Optional[Dict]: + """Verify print_introspection() uses dynamic module discovery, not hardcoded lists.""" + func = _find_print_introspection(tree) + if func is None: + return None + + hardcoded_lists = [] + for node in ast.walk(func): + if isinstance(node, ast.Return) and node.value is not None: + _collect_hardcoded_string_lists(node.value, hardcoded_lists) + if isinstance(node, ast.Assign): + _collect_hardcoded_string_lists(node.value, hardcoded_lists) + + if hardcoded_lists: + items_preview = ", ".join(hardcoded_lists[:4]) + extra = f" +{len(hardcoded_lists) - 4} more" if len(hardcoded_lists) > 4 else "" + return { + "name": "Introspection discovery", + "passed": False, + "message": ( + f"print_introspection() in {filename} has hardcoded lists " + f"[{items_preview}{extra}] — should auto-discover modules via Path.glob or similar" + ), + } + + return { + "name": "Introspection discovery", + "passed": True, + "message": "print_introspection() has no hardcoded module/handler lists", + } + + +def _collect_hardcoded_string_lists(node: ast.AST, result: list) -> None: + if isinstance(node, ast.List): + string_vals = [elt.value for elt in node.elts if isinstance(elt, ast.Constant) and isinstance(elt.value, str)] + if string_vals: + result.extend(string_vals) + elif isinstance(node, ast.Dict): + for value in node.values: + if value is not None: + _collect_hardcoded_string_lists(value, result) + elif isinstance(node, ast.Tuple): + string_vals = [elt.value for elt in node.elts if isinstance(elt, ast.Constant) and isinstance(elt.value, str)] + if string_vals: + result.extend(string_vals) + + def check_execution_order(tree: ast.Module, content: str, filename: str) -> Optional[Dict]: """ In the main() function (or if __name__ block), verify that no-args check diff --git a/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json b/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json index 40b9aef6..57f487d6 100644 --- a/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json +++ b/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json @@ -7,6 +7,7 @@ ], "PreToolUse": [ {"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/tool_use_sound.py"}]}, + {"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", "hooks": [{"type": "command", "command": "/home/patrick/Projects/AIPass/.venv/bin/python3 /home/patrick/Projects/AIPass/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse"}]}, {"matcher": "Edit|MultiEdit|Write|NotebookEdit", "hooks": [{"type": "command", "command": "python3 /home/patrick/.claude/hooks/pre_edit_gate.py"}]}, {"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", "hooks": [{"type": "command", "command": "python3 /home/patrick/.claude/hooks/git_gate.py"}]} ], @@ -17,9 +18,7 @@ "SubagentStop": [ {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/subagent_stop_gate.py"}]} ], - "Stop": [ - {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/stop_sound.py"}]} - ], + "Stop": [], "Notification": [ {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/notification_sound.py"}]} ],