feat: DPLAN-0184 Phase 2 start — first handler built + standards compliance (tool_sound + engine fixes)

This commit is contained in:
AIOSAI
2026-05-19 21:29:24 -07:00
parent ece29256f4
commit 7df4f57bd4
12 changed files with 738 additions and 217 deletions
+41
View File
@@ -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}
+61 -62
View File
@@ -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
View File
@@ -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
+97 -47
View File
@@ -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
+28 -27
View File
@@ -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"
+128
View File
@@ -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"}]}
],