feat: DPLAN-0184 Phase 2 start — first handler built + standards compliance (tool_sound + engine fixes)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:]
|
||||
@@ -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
|
||||
@@ -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}
|
||||
+110
-78
@@ -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 <command> [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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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"}]}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user