diff --git a/.AIPASS_REGISTRY.lock b/.AIPASS_REGISTRY.lock new file mode 100644 index 00000000..e69de29b diff --git a/.aipass/aipass_global_prompt.md b/.aipass/aipass_global_prompt.md index 4321196b..16ecd8cc 100644 --- a/.aipass/aipass_global_prompt.md +++ b/.aipass/aipass_global_prompt.md @@ -85,9 +85,13 @@ Local files = source of truth. Edit file → state on disk IS reality. Linting and formatting run automatically on commit via drone's commit handler (ruff check --fix + ruff format). -# aipass init +# aipass CLI -Bootstraps AIPass project in any directory, inside or outside repo. Creates registry, identity, memory, local prompt. Any folder becomes AI-powered workspace with persistent memory. Spawn adds full agent scaffolding on top. +`aipass` = standalone binary (`/usr/local/bin/aipass`). User-facing tool — not drone-routed. Users run `aipass` directly without knowing about drone. + +Commands: `aipass init`, `aipass doctor`, `aipass handoff`, `aipass help`, `aipass profile`. Never `drone @aipass` — that's not how it works. + +`aipass init` bootstraps AIPass project in any directory, inside or outside repo. Creates registry, identity, memory, local prompt. Any folder becomes AI-powered workspace with persistent memory. Spawn adds full agent scaffolding on top. Source: `src/aipass/cli/apps/handlers/init/bootstrap.py` diff --git a/.claude/hooks/engine.jsonl b/.claude/hooks/engine.jsonl new file mode 100644 index 00000000..9265eb46 --- /dev/null +++ b/.claude/hooks/engine.jsonl @@ -0,0 +1,79 @@ +{"ts": 1779087885.8874333, "event": "PreToolUse", "hook": "tool_use_sound", "exit_code": 0, "elapsed_ms": 40.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087885.8876748, "event": "PreToolUse", "hook": "pre_edit_gate", "action": "skipped_no_match", "matcher": "Edit|MultiEdit|Write|NotebookEdit", "value": "Read"} +{"ts": 1779087885.8881602, "event": "PreToolUse", "hook": "git_gate", "action": "skipped_no_match", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", "value": "Read"} +{"ts": 1779087885.888458, "event": "PreToolUse", "hook": "engine_test_sound", "action": "skipped_no_match", "matcher": "WebSearch", "value": "Read"} +{"ts": 1779087885.9210913, "event": "PreToolUse", "hook": "BROKEN_crash_test", "exit_code": 2, "elapsed_ms": 31.2, "stdout_len": 0, "stderr_preview": "python3: can't open file '/tmp/THIS_DOES_NOT_EXIST_AT_ALL.py': [Errno 2] No such file or directory\n", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087885.9220648, "event": "PreToolUse", "hook": "BROKEN_crash_test", "action": "crashed", "stderr": "python3: can't open file '/tmp/THIS_DOES_NOT_EXIST_AT_ALL.py': [Errno 2] No such file or directory\n"} +{"ts": 1779087885.9231257, "event": "PreToolUse", "action": "complete", "hooks_run": 0, "total_ms": 372.8} +{"ts": 1779087903.771124, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 211.9, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087903.7721746, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 1407.3} +{"ts": 1779087908.359278, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 1317.3, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087908.360058, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 1900.4} +{"ts": 1779087948.5783036, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 53.2, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087948.6398153, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 60.5, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087948.7356682, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 94.9, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087948.8025231, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 66.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087948.8031442, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 592.6} +{"ts": 1779087969.61676, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 83.6, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087969.6175067, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 549.3} +{"ts": 1779087973.7319121, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 660.8, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779087973.7324946, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 927.3} +{"ts": 1779088321.8144712, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 44.0, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088321.8797712, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 62.3, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088321.939397, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 57.8, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088321.9993627, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 59.2, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088322.0001252, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 539.6} +{"ts": 1779088523.355975, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 69.6, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088523.356537, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 392.2} +{"ts": 1779088557.6554952, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 33.4, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088557.696279, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 39.6, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088557.7499194, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 52.2, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088557.7993498, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 48.2, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088557.8000984, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 995.9} +{"ts": 1779088567.4456015, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 69.6, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088567.4462054, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 449.2} +{"ts": 1779088570.872307, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 758.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088570.8730876, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 1024.6} +{"ts": 1779088693.5529475, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 44.5, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088693.6015344, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 47.8, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088693.6411777, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 38.0, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088693.6902359, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 48.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088693.6908083, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 439.1} +{"ts": 1779088702.3629355, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 85.2, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088702.3633838, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 459.4} +{"ts": 1779088706.1254911, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 649.0, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779088706.1261542, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 904.8} +{"ts": 1779122916.2700117, "event": "Notification", "hook": "notification_sound", "exit_code": 0, "elapsed_ms": 41.0, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779122916.270565, "event": "Notification", "action": "complete", "hooks_run": 0, "total_ms": 390.0} +{"ts": 1779122954.4108016, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 97.3, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779122954.4598262, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 47.8, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779122954.557771, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 97.1, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779122954.6079268, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 48.9, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779122954.6094744, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 672.6} +{"ts": 1779122967.6779344, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 45.0, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779122967.6787398, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 401.8} +{"ts": 1779123157.5541441, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 624.7, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123157.554982, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 883.3} +{"ts": 1779123163.3793867, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 33.0, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123163.4235747, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 43.1, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123163.4708867, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 46.4, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123163.5132086, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 41.4, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123163.5137308, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 429.9} +{"ts": 1779123186.0301352, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 50.1, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123186.0307028, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 342.4} +{"ts": 1779123254.4626336, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 46.5, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123254.5158958, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 52.1, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123254.5792346, "event": "UserPromptSubmit", "hook": "identity_injector", "exit_code": 0, "elapsed_ms": 62.4, "stdout_len": 2187, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123254.6368563, "event": "UserPromptSubmit", "hook": "email_notification", "exit_code": 0, "elapsed_ms": 56.7, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123254.6375203, "event": "UserPromptSubmit", "action": "complete", "hooks_run": 3, "total_ms": 481.4} +{"ts": 1779123520.2690194, "event": "Stop", "hook": "stop_sound", "exit_code": 0, "elapsed_ms": 70.4, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123520.269594, "event": "Stop", "action": "complete", "hooks_run": 0, "total_ms": 360.6} +{"ts": 1779123580.7943206, "event": "Notification", "hook": "notification_sound", "exit_code": 0, "elapsed_ms": 45.5, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123580.7948642, "event": "Notification", "action": "complete", "hooks_run": 0, "total_ms": 319.3} +{"ts": 1779123705.523997, "event": "SubagentStop", "hook": "subagent_stop_gate", "exit_code": 0, "elapsed_ms": 633.8, "stdout_len": 0, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779123705.5245044, "event": "SubagentStop", "action": "complete", "hooks_run": 0, "total_ms": 904.5} +{"ts": 1779124421.3406193, "event": "UserPromptSubmit", "hook": "global_prompt", "exit_code": 0, "elapsed_ms": 114.5, "stdout_len": 14036, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"ts": 1779124421.437293, "event": "UserPromptSubmit", "hook": "branch_prompt", "exit_code": 0, "elapsed_ms": 95.5, "stdout_len": 8300, "stderr_preview": "", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse"} +{"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} diff --git a/.claude/hooks/engine.py b/.claude/hooks/engine.py new file mode 100644 index 00000000..4c46bcd3 --- /dev/null +++ b/.claude/hooks/engine.py @@ -0,0 +1,215 @@ +# =================== AIPass ==================== +# Name: engine.py +# Description: Hook Engine — unified dispatcher for all hook events +# Version: 0.3.0 +# Created: 2026-05-17 +# Modified: 2026-05-17 +# ============================================= + +""" +Hook Engine — DPLAN-0184 Phase 1. + +Single entry point for all hook events. Reads per-project config (.aipass/hooks.json), +dispatches to registered hooks, logs everything via prax logger. + +Called from provider settings (must use venv python for prax imports): + $AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/.claude/hooks/engine.py + +Stdin/stdout contract matches the platform's hook interface. +""" + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +AIPASS_HOME = os.environ.get("AIPASS_HOME", "") +LOG_DIR = Path(AIPASS_HOME) / ".claude" / "hooks" if AIPASS_HOME else Path(__file__).parent +LOG_FILE = LOG_DIR / "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("[hook_engine] 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: + 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("[hook_engine] log write failed: %s", exc) + + +def _run_hook(hook_cmd: str, stdin_data: str) -> dict: + """Run a single hook subprocess, capture output and timing.""" + env = os.environ.copy() + start = time.monotonic() + try: + result = subprocess.run( + hook_cmd, + shell=True, + input=stdin_data, + capture_output=True, + text=True, + timeout=30, + env=env, + ) + elapsed_ms = (time.monotonic() - start) * 1000 + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + "elapsed_ms": round(elapsed_ms, 1), + } + except subprocess.TimeoutExpired: + elapsed_ms = (time.monotonic() - start) * 1000 + logger.error("[hook_engine] timeout after 30s: %s", hook_cmd) + return {"exit_code": -1, "stdout": "", "stderr": "TIMEOUT", "elapsed_ms": round(elapsed_ms, 1)} + except OSError as exc: + elapsed_ms = (time.monotonic() - start) * 1000 + logger.error("[hook_engine] exec error: %s: %s", hook_cmd, 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.""" + if not matcher: + return True + return value in matcher.split("|") + + +def dispatch(event_type: str, stdin_data: str, config: dict) -> str: + """Core dispatch — run hooks for event, return merged stdout.""" + if not config.get("hooks_enabled", True): + logger.info("[hook_engine] all hooks disabled") + _log({"ts": time.time(), "event": event_type, "action": "all_hooks_disabled"}) + return "" + + event_hooks = config.get(event_type, {}) + if not event_hooks: + _log({"ts": time.time(), "event": event_type, "action": "no_hooks_configured"}) + return "" + + match_value = "" + 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", "") + except json.JSONDecodeError as exc: + logger.warning("[hook_engine] stdin parse error: %s", exc) + + outputs = [] + total_start = time.monotonic() + + for hook_name, hook_def in event_hooks.items(): + if not hook_def.get("enabled", True): + logger.info("[hook_engine] %s.%s skipped (disabled)", event_type, hook_name) + _log({"ts": time.time(), "event": event_type, "hook": hook_name, "action": "skipped_disabled"}) + continue + + command = hook_def.get("command", "") + matcher = hook_def.get("matcher", "") + if not command: + continue + + if matcher and not _matches(matcher, match_value): + _log({"ts": time.time(), "event": event_type, "hook": hook_name, "action": "skipped_no_match", + "matcher": matcher, "value": match_value}) + continue + + result = _run_hook(command, stdin_data) + + logger.info( + "[hook_engine] %s.%s exit=%d out=%db %dms", + event_type, hook_name, result["exit_code"], + len(result["stdout"]), result["elapsed_ms"], + ) + _log({ + "ts": time.time(), + "event": event_type, + "hook": hook_name, + "exit_code": result["exit_code"], + "elapsed_ms": result["elapsed_ms"], + "stdout_len": len(result["stdout"]), + "stderr_preview": result["stderr"][:200] if result["stderr"] else "", + "cwd": str(Path.cwd()), + }) + + if result["exit_code"] == 2: + is_intentional_block = False + try: + decision = json.loads(result["stdout"]) if result["stdout"].strip() else {} + is_intentional_block = decision.get("decision") == "block" + except (json.JSONDecodeError, AttributeError): + pass + + if is_intentional_block: + total_ms = (time.monotonic() - total_start) * 1000 + logger.warning( + "[hook_engine] %s BLOCKED by %s (%dms)", + event_type, hook_name, total_ms, + ) + _log({"ts": time.time(), "event": event_type, "action": "blocked", + "hook": hook_name, "total_ms": round(total_ms, 1)}) + return result["stdout"] + + logger.error( + "[hook_engine] %s.%s CRASHED exit=2: %s", + event_type, hook_name, result["stderr"][:200], + ) + _log({"ts": time.time(), "event": event_type, "hook": hook_name, + "action": "crashed", "stderr": result["stderr"][:200]}) + + if result["stdout"]: + outputs.append(result["stdout"]) + + total_ms = (time.monotonic() - total_start) * 1000 + logger.info("[hook_engine] %s complete: %d hooks %dms", event_type, len(outputs), total_ms) + _log({"ts": time.time(), "event": event_type, "action": "complete", + "hooks_run": len(outputs), "total_ms": round(total_ms, 1)}) + + return "\n".join(outputs) + + +def main() -> None: + """Entry point — receive event type, dispatch, output result.""" + if len(sys.argv) < 2: + sys.stderr.write("Usage: engine.py \n") + sys.exit(1) + + event_type = sys.argv[1] + + stdin_data = "" + if not sys.stdin.isatty(): + stdin_data = sys.stdin.read() + + config = _find_project_config() + if config is None: + config = {"hooks_enabled": True} + + output = dispatch(event_type, stdin_data, config) + if output: + sys.stdout.write(output) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/engine_test.log b/.claude/hooks/engine_test.log new file mode 100644 index 00000000..e2eb6298 --- /dev/null +++ b/.claude/hooks/engine_test.log @@ -0,0 +1,10 @@ +{"ts": 1779086437.4402049, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "agent_id"]} +{"ts": 1779086460.0803485, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["user_prompt"]} +{"ts": 1779086493.5130055, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "hook_event_name"]} +{"ts": 1779086501.5574267, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "effort"]} +{"ts": 1779086534.0177336, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "effort"]} +{"ts": 1779086594.7874434, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "hook_event_name", "message"]} +{"ts": 1779086688.7642086, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "hook_event_name"]} +{"ts": 1779086728.029055, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["tool_name", "tool_input"]} +{"ts": 1779086747.247906, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "effort"]} +{"ts": 1779086820.3323202, "hook": "engine_test_hook", "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", "event_keys": ["session_id", "transcript_path", "cwd", "permission_mode", "hook_event_name"]} diff --git a/.claude/hooks/engine_test_hook.py b/.claude/hooks/engine_test_hook.py new file mode 100644 index 00000000..c766e445 --- /dev/null +++ b/.claude/hooks/engine_test_hook.py @@ -0,0 +1,54 @@ +# =================== AIPass ==================== +# Name: engine_test_hook.py +# Description: Test hook for engine POC — proves engine dispatch works +# Version: 0.1.0 +# Created: 2026-05-17 +# Modified: 2026-05-17 +# ============================================= + +""" +Test hook that proves the engine dispatched correctly. +Writes a timestamped entry to engine_test.log and outputs a system reminder. +Safe — no side effects beyond logging. +""" + +import json +import os +import sys +import time +from pathlib import Path + +LOG_FILE = Path(os.environ.get("AIPASS_HOME", "")) / ".claude" / "hooks" / "engine_test.log" + + +def main() -> None: + """Log proof of execution and output a system reminder.""" + stdin_data = "" + if not sys.stdin.isatty(): + stdin_data = sys.stdin.read() + + event_info = {} + try: + if stdin_data.strip(): + event_info = json.loads(stdin_data) + except json.JSONDecodeError as exc: + sys.stderr.write(f"engine_test_hook: parse error: {exc}\n") + + entry = { + "ts": time.time(), + "hook": "engine_test_hook", + "cwd": str(Path.cwd()), + "event_keys": list(event_info.keys())[:5], + } + + try: + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + except OSError as exc: + sys.stderr.write(f"engine_test_hook: log failed: {exc}\n") + + sys.stdout.write("ENGINE_TEST: Hook engine dispatched successfully\n") + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/engine_test_sound.py b/.claude/hooks/engine_test_sound.py new file mode 100644 index 00000000..d453b310 --- /dev/null +++ b/.claude/hooks/engine_test_sound.py @@ -0,0 +1,40 @@ +# =================== AIPass ==================== +# Name: engine_test_sound.py +# Description: Test sound hook for engine POC — plays distinct beep +# Version: 0.2.0 +# Created: 2026-05-17 +# Modified: 2026-05-18 +# ============================================= + +"""Plays a distinct A5 beep to prove the engine dispatched this hook.""" + +import subprocess +import sys +from pathlib import Path + +SOUNDS_DIR = Path(__file__).parent.parent / "sounds" +DEFAULT_SOUND = SOUNDS_DIR / "engine_test_beep.wav" + + +def main() -> None: + """Play the test beep sound. Accepts optional sound file arg.""" + sound = DEFAULT_SOUND + if len(sys.argv) > 1: + candidate = Path(sys.argv[1]) + if candidate.exists(): + sound = candidate + + if not sound.exists(): + return + try: + subprocess.run( + ["aplay", "-q", str(sound)], + timeout=5, check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + except (OSError, subprocess.TimeoutExpired): + pass + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/pre_compact_rollover.py b/.claude/hooks/pre_compact_rollover.py index 6620bc42..ebf4c4b6 100755 --- a/.claude/hooks/pre_compact_rollover.py +++ b/.claude/hooks/pre_compact_rollover.py @@ -66,7 +66,8 @@ def _check_file(file_path): schema_version = metadata.get("schema_version", "1.0.0") limits = metadata.get("limits", {}) - if schema_version.startswith("2"): + has_v2_limits = any(k in limits for k in ("max_sessions", "max_key_learnings", "max_observations")) + if has_v2_limits: reasons = [] max_sessions = limits.get("max_sessions") if max_sessions is not None: diff --git a/.claude/sounds/engine_test_beep.wav b/.claude/sounds/engine_test_beep.wav new file mode 100644 index 00000000..76a4b12e Binary files /dev/null and b/.claude/sounds/engine_test_beep.wav differ diff --git a/.gitignore b/.gitignore index 2420662b..1a421b81 100644 --- a/.gitignore +++ b/.gitignore @@ -121,4 +121,4 @@ branch_audits/ claude_4_7_transition_notes.md README_ORIGINAL_DISABLED.md *.bak -test/ +test/ \ No newline at end of file diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py b/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py index ded83066..063d0dc8 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py @@ -424,7 +424,12 @@ def spawn_agent( # Update lock with real monitor PID lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock" - lock_data = {"pid": monitor_pid, "timestamp": datetime.now().isoformat(), "branch": str(branch_path)} + lock_data = { + "pid": monitor_pid, + "timestamp": datetime.now().isoformat(), + "branch": str(branch_path), + "subject": subject, + } _write_json(lock_file, lock_data) # Track session cycles for rotation diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py index 917179aa..aecdfa41 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py @@ -501,7 +501,12 @@ def wake_branch( # Update lock with real monitor PID lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock" - lock_data = {"pid": monitor_pid, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), "branch": str(branch_path)} + lock_data = { + "pid": monitor_pid, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), + "branch": str(branch_path), + "subject": custom_message or "manual wake", + } with open(lock_file, "w", encoding="utf-8") as f: json.dump(lock_data, f, indent=2) diff --git a/src/aipass/aipass/apps/handlers/init/bootstrap.py b/src/aipass/aipass/apps/handlers/init/bootstrap.py index be128ded..51208e52 100644 --- a/src/aipass/aipass/apps/handlers/init/bootstrap.py +++ b/src/aipass/aipass/apps/handlers/init/bootstrap.py @@ -44,30 +44,20 @@ from aipass.aipass.apps.handlers.init import scaffold_content as sc logger = logging.getLogger(__name__) -PROJECT_HOOKS = [ +# Hooks are NOT distributed to projects. All hooks fire from provider +# settings (~/.claude/settings.json), installed by setup.sh. Provider hooks +# use CWD-walking patterns that work from any directory in any project. +# Hook files are shipped as reference copies only (for debugging/inspection). +HOOKS_TO_SHIP = [ "branch_prompt_loader.py", "email_notification.py", "identity_injector.py", "pre_compact.py", -] - -# These are shipped as reference copies but NOT wired in project settings.json -# because PreToolUse/PostToolUse/SubagentStop only fire from provider settings. -PROVIDER_ONLY_HOOKS = [ "auto_fix_diagnostics.py", "pre_edit_gate.py", "subagent_stop_gate.py", ] -HOOKS_TO_SHIP = PROJECT_HOOKS + PROVIDER_ONLY_HOOKS - -HOOK_EVENTS: dict[str, str] = { - "pre_compact.py": "PreCompact", - "branch_prompt_loader.py": "UserPromptSubmit", - "email_notification.py": "UserPromptSubmit", - "identity_injector.py": "UserPromptSubmit", -} - def _ship_hooks(aipass_home: str, target: Path) -> list[str]: """Copy enforcement + injector hooks from AIPass install to target project. @@ -155,31 +145,31 @@ def _hook_fingerprint(hook_entry: dict) -> str: def _merge_settings(existing: dict, generated: dict) -> dict: """Merge AIPass-generated settings with existing user settings. - Strategy: for each event type, build a set of fingerprints from the - generated (AIPass) hooks. Keep any existing hook whose fingerprint - does NOT appear in the generated set — those are user-added. - Replace all AIPass hooks with the latest generated versions. + Hooks are no longer distributed to projects (provider handles them). + On update, strip any previously-injected AIPass hooks from project + settings while preserving genuine user hooks. """ merged = {} + _aipass_hook_markers = ( + ".claude/hooks/", + "aipass_global_prompt.md", + "aipass_local_prompt.md", + ) + existing_hooks = existing.get("hooks", {}) - generated_hooks = generated.get("hooks", {}) - - merged_hooks: dict[str, list] = {} - all_events = set(existing_hooks.keys()) | set(generated_hooks.keys()) - - for event in all_events: - existing_entries = existing_hooks.get(event, []) - generated_entries = generated_hooks.get(event, []) - - # Fingerprint all generated hooks for this event - generated_fps = {_hook_fingerprint(e) for e in generated_entries} - - # Keep existing hooks that DON'T match any generated hook - user_entries = [e for e in existing_entries if _hook_fingerprint(e) not in generated_fps] - merged_hooks[event] = generated_entries + user_entries - - merged["hooks"] = merged_hooks + if existing_hooks: + cleaned_hooks: dict[str, list] = {} + for event, entries in existing_hooks.items(): + user_entries = [] + for entry in entries: + fp = _hook_fingerprint(entry) + if not any(marker in fp for marker in _aipass_hook_markers): + user_entries.append(entry) + if user_entries: + cleaned_hooks[event] = user_entries + if cleaned_hooks: + merged["hooks"] = cleaned_hooks # Merge env: generated wins for AIPASS_HOME, preserve user additions existing_env = existing.get("env", {}) @@ -213,61 +203,22 @@ def _merge_settings(existing: dict, generated: dict) -> dict: def _claude_settings(aipass_home: str | None = None) -> str: - """Generate .claude/settings.json — hooks for prompt injection at project level. + """Generate .claude/settings.json — env and permissions only. - Only wires hooks that fire from project-level settings: - - UserPromptSubmit: global/local prompt injection + branch_prompt_loader, - email_notification, identity_injector - - PreCompact: pre_compact + Hooks are NOT wired at the project level. All AIPass hooks + (prompt injection, identity, email, pre-compact, edit gates) fire + from provider settings (~/.claude/settings.json), installed by + setup.sh. Provider hooks use CWD-walking patterns that work from + any directory in any project. - PreToolUse/PostToolUse/SubagentStop hooks are NOT wired here — they only - fire from provider settings (~/.claude/settings.json). The scripts are - still shipped as reference copies. Provider wiring is handled by setup.sh. + Project settings only contain: + - env.AIPASS_HOME (so hooks can find the AIPass installation) + - permissions.deny (basic safety rails) Args: aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME. """ - _local_prompt_cmd = ( - 'python3 -c "' - "from pathlib import Path; " - "p=next((x/'.aipass'/'aipass_local_prompt.md' " - "for x in [Path.cwd(),*Path.cwd().parents] " - "if (x/'.aipass'/'aipass_local_prompt.md').exists()),None); " - "p and print(p.read_text(encoding='utf-8'),end='')" - '"' - ) - - event_hooks: dict[str, list] = {} - for hook_name, event in HOOK_EVENTS.items(): - entry = { - "matcher": "", - "hooks": [{"type": "command", "command": f"python3 .claude/hooks/{hook_name}"}], - } - event_hooks.setdefault(event, []).append(entry) - - prompt_hooks = [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "cat .aipass/aipass_global_prompt.md 2>/dev/null || true", - } - ], - }, - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": _local_prompt_cmd, - } - ], - }, - ] - event_hooks["UserPromptSubmit"] = prompt_hooks + event_hooks.get("UserPromptSubmit", []) - - data: dict = {"hooks": event_hooks} + data: dict = {} data["permissions"] = { "deny": [ diff --git a/src/aipass/aipass/tests/test_bootstrap.py b/src/aipass/aipass/tests/test_bootstrap.py index d54b721c..61fb4f65 100644 --- a/src/aipass/aipass/tests/test_bootstrap.py +++ b/src/aipass/aipass/tests/test_bootstrap.py @@ -273,7 +273,7 @@ def test_init_project_gitignore_content(tmp_path): def test_init_project_claude_settings_content(tmp_path): - """.claude/settings.json has valid hook configuration.""" + """.claude/settings.json has env and permissions, no hooks.""" target = tmp_path / "proj" target.mkdir() @@ -281,12 +281,13 @@ def test_init_project_claude_settings_content(tmp_path): settings_path = target / ".claude" / "settings.json" data = json.loads(settings_path.read_text(encoding="utf-8")) - assert "hooks" in data - assert "UserPromptSubmit" in data["hooks"] + assert "hooks" not in data, "Hooks should not be in project settings — provider handles them" + assert "permissions" in data + assert "deny" in data["permissions"] -def test_init_project_settings_has_all_hooks(tmp_path): - """.claude/settings.json wires project-compatible hook event types.""" +def test_init_project_settings_no_hooks(tmp_path): + """.claude/settings.json has no hooks — all hooks fire from provider level.""" target = tmp_path / "proj" target.mkdir() @@ -295,21 +296,9 @@ def test_init_project_settings_has_all_hooks(tmp_path): settings_path = target / ".claude" / "settings.json" data = json.loads(settings_path.read_text(encoding="utf-8")) - # UserPromptSubmit: 2 prompt injectors + 3 hook files - ups_hooks = data["hooks"]["UserPromptSubmit"] - assert len(ups_hooks) == 5, f"Expected 5 UserPromptSubmit hooks, got {len(ups_hooks)}" - assert "aipass_global_prompt.md" in ups_hooks[0]["hooks"][0]["command"] - assert "aipass_local_prompt.md" in ups_hooks[1]["hooks"][0]["command"] - assert "branch_prompt_loader.py" in ups_hooks[2]["hooks"][0]["command"] - - # PreCompact fires from project level - assert len(data["hooks"]["PreCompact"]) == 1 - assert "pre_compact.py" in data["hooks"]["PreCompact"][0]["hooks"][0]["command"] - - # PreToolUse, PostToolUse, Stop are provider-only — NOT in project settings - assert "PreToolUse" not in data["hooks"] - assert "PostToolUse" not in data["hooks"] - assert "Stop" not in data["hooks"] + assert "hooks" not in data, "Project settings should not contain hooks" + assert "env" in data + assert "permissions" in data def test_init_project_global_prompt_content(tmp_path): @@ -763,16 +752,15 @@ def test_init_project_hooks_idempotent_on_rerun(tmp_path): assert len(hook_paths_rerun) == 0 -def test_init_project_settings_has_all_event_types(tmp_path): - """settings.json contains only project-compatible hook event types.""" +def test_init_project_settings_has_no_hook_events(tmp_path): + """settings.json contains no hooks — provider handles all events.""" target = tmp_path / "proj" target.mkdir() init_project(target, project_name="events") settings = json.loads((target / ".claude" / "settings.json").read_text(encoding="utf-8")) - expected_events = {"UserPromptSubmit", "PreCompact"} - assert set(settings["hooks"].keys()) == expected_events + assert "hooks" not in settings # --------------------------------------------------------------------------- diff --git a/src/aipass/devpulse/.seedgo/bypass.json b/src/aipass/devpulse/.seedgo/bypass.json index 0ba604b3..ea2de42c 100644 --- a/src/aipass/devpulse/.seedgo/bypass.json +++ b/src/aipass/devpulse/.seedgo/bypass.json @@ -54,6 +54,36 @@ "standard": "debug_print", "file": "tools/spot_check.py", "reason": "Standalone CLI tool — print() is the intended output method." + }, + { + "standard": "silent_catch", + "file": "tools/hook_engine_poc/engine.py", + "reason": "POC hook engine — stdlib only, no prax logger. Uses sys.stderr for error reporting." + }, + { + "standard": "error_handling", + "file": "tools/hook_engine_poc/engine.py", + "reason": "POC hook engine — exception handlers write to stderr, not prax logger." + }, + { + "standard": "silent_catch", + "file": "tools/hook_engine_poc/test_engine.py", + "reason": "POC test harness — catches test exceptions to report pass/fail, writes to stderr." + }, + { + "standard": "imports", + "file": "tools/hook_engine_poc/test_engine.py", + "reason": "POC test harness — sys.path needed to import engine.py from same directory." + }, + { + "standard": "trigger", + "file": "tools/hook_engine_poc/test_engine.py", + "reason": "POC test harness — .unlink() clears ephemeral JSONL log between tests, not a tracked file." + }, + { + "standard": "help_text", + "file": "tools/hook_engine_poc/test_engine.py", + "reason": "POC test harness — usage example in docstring." } ], "notes": { diff --git a/src/aipass/hooks/.aipass/README.md b/src/aipass/hooks/.aipass/README.md new file mode 100644 index 00000000..6504ae58 --- /dev/null +++ b/src/aipass/hooks/.aipass/README.md @@ -0,0 +1,3 @@ +# Branch Prompt + +AI context for `HOOKS`. The `aipass_local_prompt.md` file is injected every turn, telling the AI who you are and how to work in your branch. diff --git a/src/aipass/hooks/.aipass/aipass_local_prompt.md b/src/aipass/hooks/.aipass/aipass_local_prompt.md new file mode 100644 index 00000000..b6e0a91e --- /dev/null +++ b/src/aipass/hooks/.aipass/aipass_local_prompt.md @@ -0,0 +1,87 @@ +# HOOKS — Branch Prompt + + + +*Injected every turn. Breadcrumbs only — details in README, --help, .trinity/ memories, STATUS.local.md.* + +## Identity + +*One line. Who you are and what your role is. This is the first thing the agent reads every turn — make it count.* + +You are HOOKS — {one-line role description}. + +## What I Do + +*3-5 bullets covering what happens in this branch. Not a mission statement — concrete actions. Think "if someone asked what this branch does day-to-day, what would you say?"* + +- {Primary responsibility} +- {Secondary responsibility} +- {What you build/maintain/operate} + +## Key Commands + +*The 5-8 commands you use most, with real arguments. Not your full command list — just the ones you'd need in 80% of sessions. Always show the full `drone @branch command [args]` syntax.* + +``` +drone @hooks {command1} [args] # What it does +drone @hooks {command2} [args] # What it does +``` + +## Architecture + +*Your directory tree showing the code layout. Helps the agent find things without guessing. Skip this section entirely if your branch has no apps/ directory.* + +``` +apps/ +├── hooks.py # Entry point +├── modules/ +│ ├── {module1}.py # What it orchestrates +│ └── {module2}.py # What it orchestrates +└── handlers/ + ├── {domain1}/ # What it handles + └── {domain2}/ # What it handles +``` + +## Integration + +*Which branches you depend on or serve. Every branch connects to others — document those relationships so the agent knows who to ask and who's asking.* + +- **Depends on:** @{branch} for {what}, @{branch} for {what} +- **Serves:** @{branch} uses my {feature}, @{branch} calls my {command} + +## Working Habits + +*Behavioral patterns specific to this branch. How you approach work differently from other branches. Decision frameworks, common workflows, domain-specific patterns. Only include habits that are unique to this branch — if it applies to all branches, it's in the global prompt.* + +- {Habit or pattern that shapes how you work} +- {Decision framework or workflow unique to this domain} + +## Known Gotchas + +*Non-obvious quirks, hard-won lessons, things that will waste 20 minutes if you don't know them. These are the breadcrumbs that save time — the stuff you'd tell a new agent on day one.* + +- {Gotcha or non-obvious behavior} +- {Hard-won lesson from a past session} diff --git a/src/aipass/hooks/.claude/README.md b/src/aipass/hooks/.claude/README.md new file mode 100644 index 00000000..c9e0e521 --- /dev/null +++ b/src/aipass/hooks/.claude/README.md @@ -0,0 +1,5 @@ +# Claude Code Settings + +Claude Code configuration for `HOOKS`. + +Contains `settings.local.json` with permission rules. Most branches are denied raw git commands and must use `drone @git` instead. diff --git a/src/aipass/hooks/.gitignore b/src/aipass/hooks/.gitignore new file mode 100644 index 00000000..e0a8e539 --- /dev/null +++ b/src/aipass/hooks/.gitignore @@ -0,0 +1,15 @@ +__pycache__/ +*.pyc +*.pyo +.env +.venv/ +*.egg-info/ +.coverage +htmlcov/ +.pytest_cache/ +.mypy_cache/ +dist/ +build/ +*.log +*.tmp +*.swp diff --git a/src/aipass/hooks/.seedgo/README.md b/src/aipass/hooks/.seedgo/README.md new file mode 100644 index 00000000..83ae2364 --- /dev/null +++ b/src/aipass/hooks/.seedgo/README.md @@ -0,0 +1,5 @@ +# Standards Bypass + +Seedgo audit bypass config for `HOOKS`. + +When an audit flags a false positive that doesn't apply to your architecture, add a bypass entry in `bypass.json` with a reason explaining why it's justified. diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json new file mode 100644 index 00000000..e26c6beb --- /dev/null +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -0,0 +1,119 @@ +{ + "metadata": { + "version": "1.0.0", + "created": "2026-05-18", + "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" + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "handlers", + "reason": "Bridges are designed to import engine module — 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" + }, + { + "file": "apps/hooks.py", + "standard": "cli", + "reason": "Drone-routed entry point, output via sys.stdout.write to drone subprocess capture, not CLI" + }, + { + "file": "apps/hooks.py", + "standard": "cli_flags", + "reason": "Drone-routed entry point, --version handled in main(), not a standalone CLI binary" + }, + { + "file": "tests/conftest.py", + "standard": "architecture", + "reason": "Test fixtures live in tests/, not in the 3-layer apps structure" + }, + { + "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": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention" + }, + { + "file": "tests/test_engine.py", + "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", + "reason": "Hooks branch does not use json_handler — has its own JSONL logging" + }, + { + "file": "tests/test_engine.py", + "standard": "exception_contracts", + "reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns" + } + ], + "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" + } + } +} diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md new file mode 100644 index 00000000..95a09dfa --- /dev/null +++ b/src/aipass/hooks/README.md @@ -0,0 +1,77 @@ +[← Back to AIPass](../../../README.md) + +# Hooks + +> Hook infrastructure for AIPass. Single engine dispatches all hooks across platforms (Claude, Codex, Gemini) with per-project config, full logging, and testability. The 13th citizen. + +Every hook event flows through one engine. Platform bridges normalize the event format, the engine reads per-project config (.aipass/hooks.json), dispatches matching handlers, and logs everything to prax + JSONL. + +## Start here + +| You want to | Read | +|---|---| +| What's happening right now | [STATUS.local.md](STATUS.local.md) | +| Identity, memory, session history | [`.trinity/`](.trinity/) | +| Hook engine design | `DPLAN-0184` | +| Per-project config | `.aipass/hooks.json` | + +## Commands + +| Command | What it does | +|---|---| +| `drone @hooks status` | Show hook config for current project | +| `drone @hooks log` | Tail recent hook activity (last 20 JSONL entries) | +| `drone @hooks test` | Run hook test suite (planned) | +| `drone @hooks --help` | Full help reference | +| `drone @hooks --version` | Version info | + +## Architecture + +``` +src/aipass/hooks/ +├── .trinity/ # Identity & memory +├── apps/ +│ ├── hooks.py # Entry point (drone @hooks) +│ ├── modules/ +│ │ └── engine.py # Core dispatch — routes events to handlers +│ ├── handlers/ +│ │ ├── bridges/ # One per provider (thin normalization) +│ │ │ └── claude.py # Claude Code bridge +│ │ ├── prompt/ # Prompt injection hooks +│ │ ├── security/ # Enforcement hooks (edit gate, git gate) +│ │ ├── lifecycle/ # Session hooks (compact, stop, subagent) +│ │ └── notification/ # Sound/alert hooks +│ └── config/ # hooks.json validation +├── logs/ +│ └── engine.jsonl # JSONL diagnostics (every hook execution) +├── tests/ +└── STATUS.local.md +``` + +## How It Works + +1. Provider settings have ONE bridge entry per event type (e.g., `claude.py UserPromptSubmit`) +2. Bridge calls `engine.dispatch(event_type, stdin_data, config)` +3. Engine reads `.aipass/hooks.json` (walks up from CWD) +4. Engine runs matching hooks sequentially, logs each one +5. First hook returning `{"decision": "block"}` with exit code 2 = bail (block the action) +6. Exit code 2 without JSON = crash (log error, continue to next hook) +7. All hook stdout concatenated and returned to platform + +## Integration Points + +### Depends On + +| Branch | What for | +|---|---| +| prax | Logging (system_logger for prax monitor visibility) | + +### Provides To + +All branches via hook dispatch. Every Claude Code session routes through the engine. + +*Last Updated: 2026-05-18* + +--- + +[← Back to AIPass](../../../README.md) diff --git a/src/aipass/hooks/apps/README.md b/src/aipass/hooks/apps/README.md new file mode 100644 index 00000000..7af3befe --- /dev/null +++ b/src/aipass/hooks/apps/README.md @@ -0,0 +1,8 @@ +# Apps + +Application layer for `HOOKS`. + +- `hooks.py` — Entry point. Auto-discovers and routes commands to modules. +- `modules/` — Business logic and orchestration. One module per command. +- `handlers/` — Implementation details. Called by modules, never by CLI directly. +- `plugins/` — Scheduled tasks and extensions. diff --git a/src/aipass/hooks/apps/__init__.py b/src/aipass/hooks/apps/__init__.py new file mode 100644 index 00000000..aab6abaa --- /dev/null +++ b/src/aipass/hooks/apps/__init__.py @@ -0,0 +1,2 @@ +# HOOKS apps package +from . import handlers # noqa: F401 diff --git a/src/aipass/hooks/apps/config/__init__.py b/src/aipass/hooks/apps/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/apps/handlers/README.md b/src/aipass/hooks/apps/handlers/README.md new file mode 100644 index 00000000..8343ca11 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/README.md @@ -0,0 +1,5 @@ +# Handlers + +Implementation details for `HOOKS`. + +Handlers do the actual work. They are called by modules, never directly by the CLI. Keep business logic in modules, implementation in handlers. diff --git a/src/aipass/hooks/apps/handlers/__init__.py b/src/aipass/hooks/apps/handlers/__init__.py new file mode 100644 index 00000000..fdf840d9 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/__init__.py @@ -0,0 +1,88 @@ +"""HOOKS handlers package - Security protected.""" + +import inspect +from pathlib import Path + +MY_BRANCH = "aipass.hooks" + + +def _find_real_caller(): + """Walk the stack to find the actual file that triggered this import. + + Skips this file, importlib internals, and frozen modules. + Returns tuple: (file_path, import_line) or (None, None). + """ + stack = inspect.stack() + this_file = str(Path(__file__).resolve()) + + for frame_info in stack: + filename = frame_info.filename + + if this_file in str(Path(filename).resolve()): + continue + + if filename.startswith("<") or "importlib" in filename: + continue + + import_line = None + if frame_info.code_context: + import_line = frame_info.code_context[0].strip() + + return str(Path(filename).resolve()), import_line + + return None, None + + +def _extract_branch_name(filepath: str) -> str: + """Extract branch name from a file path.""" + parts = Path(filepath).parts + for i, part in enumerate(parts): + if part == "aipass": + if i + 1 < len(parts): + return parts[i + 1] + return "unknown" + + +def _guard_branch_access(): + """Block cross-branch handler imports. + + Only code from within the 'hooks' branch can import these handlers. + External branches must use aipass.hooks.apps.modules instead. + """ + caller_file, import_line = _find_real_caller() + + if caller_file is None: + stack = inspect.stack() + for frame in stack: + if frame.filename in ("", ""): + return + return + + branch_path = "/" + MY_BRANCH.replace(".", "/") + "/" + if branch_path in caller_file.replace("\\", "/"): + return + + caller_branch = _extract_branch_name(caller_file) + caller_filename = Path(caller_file).name + blocked_import = import_line if import_line else "unknown" + + raise ImportError( + f"\n{'=' * 60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'=' * 60}\n" + f" Caller branch: {caller_branch}\n" + f" Caller file: {caller_filename}\n" + f" Blocked: {blocked_import}\n" + f"\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from {MY_BRANCH}.apps.modules. import \n" + f"\n" + f" For full standards guide:\n" + f" drone @seedgo handlers\n" + f"{'=' * 60}" + ) + + +# Run guard at import time +_guard_branch_access() diff --git a/src/aipass/hooks/apps/handlers/bridges/__init__.py b/src/aipass/hooks/apps/handlers/bridges/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/apps/handlers/bridges/claude.py b/src/aipass/hooks/apps/handlers/bridges/claude.py new file mode 100644 index 00000000..d5a9bd51 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/bridges/claude.py @@ -0,0 +1,49 @@ +# =================== AIPass ==================== +# Name: claude.py +# Version: 1.0.0 +# Description: Claude Code bridge — entry point for provider hook settings +# Branch: hooks +# Layer: apps/handlers/bridges +# Created: 2026-05-18 +# Modified: 2026-05-18 +# ============================================= + +""" +Claude Code bridge. + +Thin entry point called from ~/.claude/settings.json hook entries. +Normalizes Claude Code's stdin/stdout format and calls the engine. + +Called from provider settings as the sole hook entry point per event type. +""" + +import sys + +from aipass.hooks.apps.modules.engine import dispatch, find_project_config +from aipass.prax.apps.modules.logger import system_logger as logger + + +def main() -> None: + """Entry point — receive event type from Claude Code, dispatch via engine.""" + if len(sys.argv) < 2: + sys.stderr.write("Usage: claude.py \n") + sys.exit(1) + + event_type = sys.argv[1] + + stdin_data = "" + if not sys.stdin.isatty(): + stdin_data = sys.stdin.read() + + config = find_project_config() + if config is None: + config = {"hooks_enabled": True} + logger.info("[HOOKS:claude] no project config found, using defaults") + + output = dispatch(event_type, stdin_data, config) + if output: + sys.stdout.write(output) + + +if __name__ == "__main__": + main() diff --git a/src/aipass/hooks/apps/handlers/lifecycle/__init__.py b/src/aipass/hooks/apps/handlers/lifecycle/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/apps/handlers/notification/__init__.py b/src/aipass/hooks/apps/handlers/notification/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/apps/handlers/prompt/__init__.py b/src/aipass/hooks/apps/handlers/prompt/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/apps/handlers/security/__init__.py b/src/aipass/hooks/apps/handlers/security/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/apps/hooks.py b/src/aipass/hooks/apps/hooks.py new file mode 100644 index 00000000..1cbe2f68 --- /dev/null +++ b/src/aipass/hooks/apps/hooks.py @@ -0,0 +1,125 @@ +# =================== AIPass ==================== +# Name: hooks.py +# Version: 1.0.0 +# Description: Hook infrastructure — drone entry point +# Branch: hooks +# Layer: apps +# Created: 2026-05-18 +# Modified: 2026-05-18 +# ============================================= + +""" +HOOKS Branch — Hook infrastructure for AIPass. + +Owns all hook dispatch via engine.py. Platform bridges (Claude, Codex, Gemini) +call the engine, which reads per-project config and routes to handlers. +""" + +import os +import sys +from pathlib import Path + +os.environ.setdefault("AIPASS_BRANCH_NAME", "hooks") + +from aipass.prax.apps.modules.logger import system_logger as logger # noqa: E402 + + +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 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 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 + + 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") + 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") + return True + + return False + + +def main() -> int: + """Main entry point — routes commands or shows help.""" + 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") + return 0 + + if args[0] in ("--help", "-h", "help"): + print_help() + 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") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/aipass/hooks/apps/integrations/README.md b/src/aipass/hooks/apps/integrations/README.md new file mode 100644 index 00000000..c2dc6c5b --- /dev/null +++ b/src/aipass/hooks/apps/integrations/README.md @@ -0,0 +1,64 @@ +# apps/integrations/ + +Private integration space for `HOOKS`. + +**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline. + +## What goes here + +**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain. + +``` +apps/integrations/ +└── {project}/ + ├── wrapper.py # How this branch uses the driver + ├── config.json # Optional — local config + └── tests/ # Private tests colocated +``` + +Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here. + +## What does NOT go here + +- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer). +- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that. +- **Drone plugins** — use `apps/plugins/` for those. +- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo. + +## Architecture + +The full design is in DPLAN-0133 (private integrations architecture). Three layers: + +1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name. +2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects. +3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call `) — advertise the extension points without naming specifics. Fork-safe. + +## Usage + +```python +# Your public code (committed, in apps/modules/ or apps/handlers/) +from aipass.api import memory_backend + +results = memory_backend.query("when did we ship watchdog?") +# memory_backend is a generic contract. In your local setup it routes to whatever +# driver you registered in @api/apps/integrations/. In a fresh clone with nothing +# registered, it returns NotConfigured gracefully. +``` + +```python +# Your private wrapper (in this folder, gitignored) +# apps/integrations/{project}/wrapper.py + +from aipass.api import memory_backend + +def domain_specific_query(context): + """Branch-specific query pattern for domain needs.""" + hint = build_query_from_context(context) + return memory_backend.query(hint, top_k=5, filter={"kind": "decision"}) +``` + +The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code. + +--- + +See DPLAN-0133 for the full design rationale. diff --git a/src/aipass/hooks/apps/modules/README.md b/src/aipass/hooks/apps/modules/README.md new file mode 100644 index 00000000..cb464728 --- /dev/null +++ b/src/aipass/hooks/apps/modules/README.md @@ -0,0 +1,5 @@ +# Modules + +Business logic for `HOOKS`. One module per command. + +Modules orchestrate work by calling handlers. They are the public API of the branch — drone routes commands here. diff --git a/src/aipass/hooks/apps/modules/__init__.py b/src/aipass/hooks/apps/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/apps/modules/engine.py b/src/aipass/hooks/apps/modules/engine.py new file mode 100644 index 00000000..39680887 --- /dev/null +++ b/src/aipass/hooks/apps/modules/engine.py @@ -0,0 +1,226 @@ +# =================== AIPass ==================== +# Name: engine.py +# Version: 1.0.0 +# Description: Hook engine — unified dispatcher for all hook events +# Branch: hooks +# Layer: apps/modules +# Created: 2026-05-18 +# Modified: 2026-05-18 +# ============================================= + +""" +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. +""" + +import json +import os +import subprocess +import time +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +AIPASS_HOME = os.environ.get("AIPASS_HOME", "") +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: + """Run a single hook subprocess, capture output and timing.""" + env = os.environ.copy() + start = time.monotonic() + try: + result = subprocess.run( + hook_cmd, + shell=True, + input=stdin_data, + capture_output=True, + text=True, + timeout=timeout_s, + env=env, + ) + elapsed_ms = (time.monotonic() - start) * 1000 + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + "elapsed_ms": round(elapsed_ms, 1), + } + except subprocess.TimeoutExpired: + elapsed_ms = (time.monotonic() - start) * 1000 + logger.error("[HOOKS] timeout after %ds: %s", timeout_s, hook_cmd) + return {"exit_code": -1, "stdout": "", "stderr": "TIMEOUT", "elapsed_ms": round(elapsed_ms, 1)} + except OSError as exc: + elapsed_ms = (time.monotonic() - start) * 1000 + logger.error("[HOOKS] exec error: %s: %s", hook_cmd, 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: + return True + return value in matcher.split("|") + + +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). + """ + if not config.get("hooks_enabled", True): + logger.info("[HOOKS] all hooks disabled") + _log({"ts": time.time(), "event": event_type, "action": "all_hooks_disabled"}) + return "" + + event_hooks = config.get(event_type, {}) + if not event_hooks: + _log({"ts": time.time(), "event": event_type, "action": "no_hooks_configured"}) + return "" + + match_value = "" + 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", "") + except json.JSONDecodeError as exc: + logger.warning("[HOOKS] stdin parse error: %s", exc) + + outputs = [] + total_start = time.monotonic() + + for hook_name, hook_def in event_hooks.items(): + if not hook_def.get("enabled", True): + logger.info("[HOOKS] %s.%s skipped (disabled)", event_type, hook_name) + _log({"ts": time.time(), "event": event_type, "hook": hook_name, "action": "skipped_disabled"}) + continue + + command = hook_def.get("command", "") + matcher = hook_def.get("matcher", "") + if not command: + continue + + if matcher and not _matches(matcher, match_value): + _log( + { + "ts": time.time(), + "event": event_type, + "hook": hook_name, + "action": "skipped_no_match", + "matcher": matcher, + "value": match_value, + } + ) + continue + + 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", + event_type, + hook_name, + result["exit_code"], + len(result["stdout"]), + result["elapsed_ms"], + ) + _log( + { + "ts": time.time(), + "event": event_type, + "hook": hook_name, + "exit_code": result["exit_code"], + "elapsed_ms": result["elapsed_ms"], + "stdout_len": len(result["stdout"]), + "stderr_preview": result["stderr"][:200] if result["stderr"] else "", + "cwd": str(Path.cwd()), + } + ) + + # Exit code 2: crash vs intentional block + if result["exit_code"] == 2: + is_intentional_block = False + try: + decision = json.loads(result["stdout"]) if result["stdout"].strip() else {} + is_intentional_block = decision.get("decision") == "block" + except (json.JSONDecodeError, AttributeError): + logger.info("[HOOKS] %s.%s exit=2 stdout not JSON, treating as crash", event_type, hook_name) + + if is_intentional_block: + total_ms = (time.monotonic() - total_start) * 1000 + logger.warning("[HOOKS] %s BLOCKED by %s (%dms)", event_type, hook_name, total_ms) + _log( + { + "ts": time.time(), + "event": event_type, + "action": "blocked", + "hook": hook_name, + "total_ms": round(total_ms, 1), + } + ) + return result["stdout"] + + logger.error( + "[HOOKS] %s.%s CRASHED exit=2: %s", + event_type, + hook_name, + result["stderr"][:200], + ) + _log( + { + "ts": time.time(), + "event": event_type, + "hook": hook_name, + "action": "crashed", + "stderr": result["stderr"][:200], + } + ) + + if result["stdout"]: + outputs.append(result["stdout"]) + + total_ms = (time.monotonic() - total_start) * 1000 + logger.info("[HOOKS] %s complete: %d hooks %dms", event_type, len(outputs), total_ms) + _log( + { + "ts": time.time(), + "event": event_type, + "action": "complete", + "hooks_run": len(outputs), + "total_ms": round(total_ms, 1), + } + ) + + return "\n".join(outputs) diff --git a/src/aipass/hooks/apps/plugins/README.md b/src/aipass/hooks/apps/plugins/README.md new file mode 100644 index 00000000..cb1ed279 --- /dev/null +++ b/src/aipass/hooks/apps/plugins/README.md @@ -0,0 +1,5 @@ +# Plugins + +Scheduled tasks and extensions for `HOOKS`. + +Plugins are standalone units of work that can be scheduled via the daemon. Each plugin handles one specific recurring task. diff --git a/src/aipass/hooks/apps/plugins/__init__.py b/src/aipass/hooks/apps/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/hooks/docs/README.md b/src/aipass/hooks/docs/README.md new file mode 100644 index 00000000..e8e0c72a --- /dev/null +++ b/src/aipass/hooks/docs/README.md @@ -0,0 +1,3 @@ +# Docs + +Documentation files for the `HOOKS` branch. diff --git a/src/aipass/hooks/pytest.ini b/src/aipass/hooks/pytest.ini new file mode 100644 index 00000000..ae4e1b86 --- /dev/null +++ b/src/aipass/hooks/pytest.ini @@ -0,0 +1,17 @@ +[pytest] +# Test discovery paths +testpaths = tests + +# Test file patterns +python_files = test_*.py +python_functions = test_* +python_classes = Test* + +# Command-line options (always applied) +addopts = -v --tb=short --strict-markers -ra + +# Test markers (for categorizing tests) +markers = + unit: Unit tests + integration: Integration tests + slow: Tests that take significant time diff --git a/src/aipass/hooks/requirements.project.txt b/src/aipass/hooks/requirements.project.txt new file mode 100644 index 00000000..bb3eb852 --- /dev/null +++ b/src/aipass/hooks/requirements.project.txt @@ -0,0 +1,3 @@ +# Project-specific Python packages only. +# These are installed into the AIPass venv: pip install -r requirements.project.txt +# Framework packages (drone, prax, chromadb, rich, etc.) are already available via AIPass. diff --git a/src/aipass/hooks/templates/README.md b/src/aipass/hooks/templates/README.md new file mode 100644 index 00000000..d2113202 --- /dev/null +++ b/src/aipass/hooks/templates/README.md @@ -0,0 +1,5 @@ +# Templates + +Branch-specific templates for `HOOKS`. + +Any templates this branch provides to the system or uses internally. Examples: plan templates (flow), trinity templates (memory), test templates (seedgo). diff --git a/src/aipass/hooks/tests/README.md b/src/aipass/hooks/tests/README.md new file mode 100644 index 00000000..29d5cfad --- /dev/null +++ b/src/aipass/hooks/tests/README.md @@ -0,0 +1,6 @@ +# Tests + +Pytest unit tests for `HOOKS`. + +- `conftest.py` — Shared fixtures (temp dirs, mocks, sample data). +- `test_*.py` — Test files. Standard tests cover JSON handler, CLI routing, and error resilience. Custom tests cover branch-specific domain logic. diff --git a/src/aipass/hooks/tests/__init__.py b/src/aipass/hooks/tests/__init__.py new file mode 100644 index 00000000..243b5d7b --- /dev/null +++ b/src/aipass/hooks/tests/__init__.py @@ -0,0 +1 @@ +# Tests package for hooks diff --git a/src/aipass/hooks/tests/conftest.py b/src/aipass/hooks/tests/conftest.py new file mode 100644 index 00000000..3df7d0ef --- /dev/null +++ b/src/aipass/hooks/tests/conftest.py @@ -0,0 +1,80 @@ +# =================== AIPass ==================== +# Name: conftest.py +# Version: 1.0.0 +# Description: Shared pytest fixtures for hooks tests +# Branch: hooks +# Layer: tests +# Created: 2026-05-18 +# Modified: 2026-05-18 +# ============================================= + +"""Shared pytest fixtures for hooks tests.""" + +import json +import shutil +import tempfile +from pathlib import Path +from typing import Generator +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def temp_test_dir() -> Generator[Path, None, None]: + """Creates temporary directory for testing, cleans up after.""" + test_dir = Path(tempfile.mkdtemp()) + yield test_dir + if test_dir.exists(): + shutil.rmtree(test_dir) + + +@pytest.fixture +def sample_hooks_config() -> dict: + """Minimal hooks.json config for testing.""" + return { + "hooks_enabled": True, + "UserPromptSubmit": { + "test_hook": { + "enabled": True, + "command": "echo 'test output'", + "matcher": "", + } + }, + "PreToolUse": { + "matcher_hook": { + "enabled": True, + "command": "echo 'matched'", + "matcher": "Edit|Write", + }, + "disabled_hook": { + "enabled": False, + "command": "echo 'should not fire'", + "matcher": "", + }, + }, + } + + +@pytest.fixture +def hooks_config_file(temp_test_dir: Path, sample_hooks_config: dict) -> Path: + """Creates a .aipass/hooks.json in temp dir.""" + config_dir = temp_test_dir / ".aipass" + config_dir.mkdir() + config_file = config_dir / "hooks.json" + config_file.write_text(json.dumps(sample_hooks_config), encoding="utf-8") + return config_file + + +@pytest.fixture +def mock_logger(): + """Mock the prax system logger.""" + with patch("aipass.hooks.apps.modules.engine.logger") as mock: + yield mock + + +@pytest.fixture +def mock_subprocess(): + """Mock subprocess.run for hook execution tests.""" + with patch("aipass.hooks.apps.modules.engine.subprocess.run") as mock: + yield mock diff --git a/src/aipass/hooks/tests/test_engine.py b/src/aipass/hooks/tests/test_engine.py new file mode 100644 index 00000000..56a15bcb --- /dev/null +++ b/src/aipass/hooks/tests/test_engine.py @@ -0,0 +1,730 @@ +# =================== AIPass ==================== +# Name: test_engine.py +# Version: 1.0.0 +# Description: Tests for hook engine dispatch logic +# Branch: hooks +# Layer: tests +# Created: 2026-05-18 +# Modified: 2026-05-18 +# ============================================= + +"""Tests for hook engine dispatch logic.""" + +import json +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + + +from aipass.hooks.apps.modules.engine import ( + dispatch, + find_project_config, + _matches, + _run_hook, + _log, +) + + +class TestMatches: + """Tests for _matches() matcher logic.""" + + def test_empty_matcher_always_matches(self): + assert _matches("", "Edit") is True + + def test_empty_matcher_matches_empty_value(self): + assert _matches("", "") is True + + def test_single_match(self): + assert _matches("Edit", "Edit") is True + + def test_single_no_match(self): + assert _matches("Edit", "Bash") is False + + def test_pipe_delimited_match_first(self): + assert _matches("Edit|Write|MultiEdit", "Edit") is True + + def test_pipe_delimited_match_middle(self): + assert _matches("Edit|Write|MultiEdit", "Write") is True + + def test_pipe_delimited_match_last(self): + assert _matches("Edit|Write|MultiEdit", "MultiEdit") is True + + def test_pipe_delimited_no_match(self): + assert _matches("Edit|Write|MultiEdit", "Bash") is False + + def test_partial_name_does_not_match(self): + assert _matches("Edit", "MultiEdit") is False + + def test_manual_auto_compact(self): + assert _matches("manual|auto", "manual") is True + assert _matches("manual|auto", "auto") is True + + +class TestRunHook: + """Tests for _run_hook() subprocess execution.""" + + def test_successful_hook(self, mock_subprocess, mock_logger): + mock_subprocess.return_value = MagicMock(returncode=0, stdout="hello", stderr="") + result = _run_hook("echo hello", "stdin_data") + assert result["exit_code"] == 0 + assert result["stdout"] == "hello" + assert result["elapsed_ms"] >= 0 + + def test_hook_with_nonzero_exit(self, mock_subprocess, mock_logger): + mock_subprocess.return_value = MagicMock(returncode=1, stdout="", stderr="error") + result = _run_hook("false", "") + assert result["exit_code"] == 1 + assert result["stderr"] == "error" + + def test_hook_timeout(self, mock_subprocess, mock_logger): + mock_subprocess.side_effect = subprocess.TimeoutExpired("cmd", 30) + result = _run_hook("sleep 999", "", timeout_s=30) + assert result["exit_code"] == -1 + assert result["stderr"] == "TIMEOUT" + + def test_hook_os_error(self, mock_subprocess, mock_logger): + mock_subprocess.side_effect = OSError("No such file") + result = _run_hook("/nonexistent", "") + assert result["exit_code"] == -1 + assert "No such file" in result["stderr"] + + def test_custom_timeout(self, mock_subprocess, mock_logger): + mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="") + _run_hook("cmd", "", timeout_s=120) + mock_subprocess.assert_called_once() + assert mock_subprocess.call_args.kwargs["timeout"] == 120 + + def test_default_timeout_is_30(self, mock_subprocess, mock_logger): + mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="") + _run_hook("cmd", "") + assert mock_subprocess.call_args.kwargs["timeout"] == 30 + + +class TestDispatch: + """Tests for dispatch() core logic.""" + + def test_hooks_disabled_returns_empty(self, mock_logger): + config = {"hooks_enabled": False} + with patch("aipass.hooks.apps.modules.engine._log"): + result = dispatch("UserPromptSubmit", "{}", config) + assert result == "" + + def test_no_hooks_for_event_returns_empty(self, mock_logger): + config = {"hooks_enabled": True} + with patch("aipass.hooks.apps.modules.engine._log"): + result = dispatch("UnknownEvent", "{}", config) + assert result == "" + + def test_disabled_hook_skipped(self, mock_logger): + config = { + "hooks_enabled": True, + "PreToolUse": { + "disabled_hook": { + "enabled": False, + "command": "echo fail", + "matcher": "", + } + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config) + mock_run.assert_not_called() + assert result == "" + + def test_matcher_filters_hooks(self, mock_logger): + config = { + "hooks_enabled": True, + "PreToolUse": { + "edit_only": { + "enabled": True, + "command": "echo matched", + "matcher": "Edit|Write", + } + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.return_value = {"exit_code": 0, "stdout": "matched", "stderr": "", "elapsed_ms": 10} + dispatch("PreToolUse", '{"tool_name":"Bash"}', config) + mock_run.assert_not_called() + + def test_matching_hook_fires(self, mock_logger): + config = { + "hooks_enabled": True, + "PreToolUse": { + "edit_hook": { + "enabled": True, + "command": "echo edit_output", + "matcher": "Edit|Write", + } + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.return_value = {"exit_code": 0, "stdout": "edit_output", "stderr": "", "elapsed_ms": 10} + result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config) + mock_run.assert_called_once() + assert "edit_output" in result + + def test_multiple_hooks_concatenate_output(self, mock_logger): + config = { + "hooks_enabled": True, + "UserPromptSubmit": { + "hook_a": {"enabled": True, "command": "echo A", "matcher": ""}, + "hook_b": {"enabled": True, "command": "echo B", "matcher": ""}, + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.side_effect = [ + {"exit_code": 0, "stdout": "output_A", "stderr": "", "elapsed_ms": 10}, + {"exit_code": 0, "stdout": "output_B", "stderr": "", "elapsed_ms": 10}, + ] + result = dispatch("UserPromptSubmit", '{"user_prompt":"test"}', config) + assert "output_A" in result + assert "output_B" in result + + def test_exit2_with_block_json_bails(self, mock_logger): + config = { + "hooks_enabled": True, + "PreToolUse": { + "blocker": {"enabled": True, "command": "block_cmd", "matcher": ""}, + "after_block": {"enabled": True, "command": "should_not_run", "matcher": ""}, + }, + } + block_json = json.dumps({"decision": "block", "reason": "test block"}) + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.return_value = {"exit_code": 2, "stdout": block_json, "stderr": "", "elapsed_ms": 10} + result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config) + assert mock_run.call_count == 1 + parsed = json.loads(result) + assert parsed["decision"] == "block" + + def test_exit2_without_json_is_crash_not_block(self, mock_logger): + config = { + "hooks_enabled": True, + "PreToolUse": { + "crashed": {"enabled": True, "command": "crash_cmd", "matcher": ""}, + "next_hook": {"enabled": True, "command": "echo survived", "matcher": ""}, + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.side_effect = [ + {"exit_code": 2, "stdout": "", "stderr": "file not found", "elapsed_ms": 10}, + {"exit_code": 0, "stdout": "survived", "stderr": "", "elapsed_ms": 10}, + ] + result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config) + assert mock_run.call_count == 2 + assert "survived" in result + + def test_hook_with_custom_timeout(self, mock_logger): + config = { + "hooks_enabled": True, + "PreCompact": { + "slow_hook": { + "enabled": True, + "command": "slow_cmd", + "matcher": "", + "timeout": 120, + } + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.return_value = {"exit_code": 0, "stdout": "", "stderr": "", "elapsed_ms": 50} + dispatch("PreCompact", '{"type":"manual"}', config) + mock_run.assert_called_once_with("slow_cmd", '{"type":"manual"}', timeout_s=120) + + def test_empty_command_skipped(self, mock_logger): + config = { + "hooks_enabled": True, + "Stop": { + "no_command": {"enabled": True, "command": "", "matcher": ""}, + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + dispatch("Stop", "{}", config) + mock_run.assert_not_called() + + def test_malformed_stdin_does_not_crash(self, mock_logger): + config = { + "hooks_enabled": True, + "UserPromptSubmit": { + "hook": {"enabled": True, "command": "echo ok", "matcher": ""}, + }, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.return_value = {"exit_code": 0, "stdout": "ok", "stderr": "", "elapsed_ms": 5} + result = dispatch("UserPromptSubmit", "not json at all{{{", config) + assert "ok" in result + + +class TestFindProjectConfig: + """Tests for find_project_config() CWD walk.""" + + def test_finds_config_in_cwd(self, hooks_config_file, temp_test_dir, mock_logger): + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir): + config = find_project_config() + assert config is not None + assert config["hooks_enabled"] is True + + def test_returns_none_when_no_config(self, temp_test_dir, mock_logger): + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir): + with patch("aipass.hooks.apps.modules.engine.Path.home", return_value=temp_test_dir.parent): + config = find_project_config() + assert config is None + + def test_expands_aipass_home(self, temp_test_dir, mock_logger): + config_dir = temp_test_dir / ".aipass" + config_dir.mkdir() + config = { + "hooks_enabled": True, + "Stop": {"sound": {"enabled": True, "command": "python3 $AIPASS_HOME/hook.py", "matcher": ""}}, + } + (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"): + result = find_project_config() + assert "/test/path/hook.py" in result["Stop"]["sound"]["command"] + + +class TestLog: + """Tests for _log() JSONL diagnostics.""" + + 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): + _log({"event": "Test", "action": "test_write"}) + lines = log_file.read_text().strip().split("\n") + assert len(lines) == 1 + entry = json.loads(lines[0]) + assert entry["event"] == "Test" + assert entry["action"] == "test_write" + + 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): + _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): + _log({"event": "A"}) + _log({"event": "B"}) + lines = log_file.read_text().strip().split("\n") + assert len(lines) == 2 + + +class TestHooksEntryPoint: + """Tests for hooks.py CLI routing.""" + + def test_help_returns_zero(self): + from aipass.hooks.apps.hooks import main + + with patch("sys.argv", ["hooks", "--help"]): + assert main() == 0 + + def test_version_returns_zero(self): + from aipass.hooks.apps.hooks import main + + with patch("sys.argv", ["hooks", "--version"]): + assert main() == 0 + + def test_no_args_shows_help(self): + from aipass.hooks.apps.hooks import main + + with patch("sys.argv", ["hooks"]): + assert main() == 0 + + def test_unknown_command_returns_one(self): + from aipass.hooks.apps.hooks import main + + with patch("sys.argv", ["hooks", "nonexistent_command"]): + assert main() == 1 + + def test_print_introspection_returns_dict(self): + from aipass.hooks.apps.hooks import print_introspection + + result = print_introspection() + assert isinstance(result, dict) + assert result["branch"] == "hooks" + assert "engine" in result["modules"] + + def test_handle_command_returns_bool(self): + from aipass.hooks.apps.hooks import handle_command + + result = handle_command("nonexistent", []) + assert result is False + + def test_status_command_returns_true(self): + from aipass.hooks.apps.hooks import handle_command + + with patch("aipass.hooks.apps.modules.engine.find_project_config", return_value=None): + result = handle_command("status", []) + assert result is True + + def test_log_command_returns_true(self): + from aipass.hooks.apps.hooks import handle_command + + result = handle_command("log", []) + assert result is True + + def test_short_help_flag(self): + from aipass.hooks.apps.hooks import main + + with patch("sys.argv", ["hooks", "-h"]): + assert main() == 0 + + def test_help_word(self): + from aipass.hooks.apps.hooks import main + + with patch("sys.argv", ["hooks", "help"]): + assert main() == 0 + + +class TestErrorResilience: + """Tests for error handling edge cases.""" + + def test_missing_hooks_json_file(self, temp_test_dir, mock_logger): + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir): + with patch("aipass.hooks.apps.modules.engine.Path.home", return_value=temp_test_dir.parent): + config = find_project_config() + assert config is None + + def test_corrupt_hooks_json(self, temp_test_dir, mock_logger): + config_dir = temp_test_dir / ".aipass" + config_dir.mkdir() + (config_dir / "hooks.json").write_text("{invalid json!!!") + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir): + config = find_project_config() + assert config is None + + def test_empty_hooks_json(self, temp_test_dir, mock_logger): + config_dir = temp_test_dir / ".aipass" + config_dir.mkdir() + (config_dir / "hooks.json").write_text("") + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir): + config = find_project_config() + assert config is None + + 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: + mock_path.parent.mkdir = MagicMock() + _log({"event": "Test"}) + + +class TestDataStructureContracts: + """Tests for config and log data structures.""" + + def test_config_has_hooks_enabled_key(self, sample_hooks_config): + assert "hooks_enabled" in sample_hooks_config + + def test_config_event_values_are_dicts(self, sample_hooks_config): + for key, val in sample_hooks_config.items(): + if key == "hooks_enabled": + continue + assert isinstance(val, dict) + + 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): + _log({"ts": 123.0, "event": "Test", "action": "check"}) + entry = json.loads(log_file.read_text().strip()) + assert "ts" in entry + assert "event" in entry + + +class TestExceptionContracts: + """Tests for expected exceptions and error paths.""" + + def test_dispatch_with_none_config_event_returns_empty(self, mock_logger): + with patch("aipass.hooks.apps.modules.engine._log"): + result = dispatch("Stop", "{}", {"hooks_enabled": True}) + assert result == "" + + def test_run_hook_timeout_returns_negative_exit(self, mock_subprocess, mock_logger): + mock_subprocess.side_effect = subprocess.TimeoutExpired("cmd", 30) + result = _run_hook("cmd", "") + assert result["exit_code"] == -1 + + def test_run_hook_os_error_returns_negative_exit(self, mock_subprocess, mock_logger): + mock_subprocess.side_effect = OSError("not found") + result = _run_hook("cmd", "") + assert result["exit_code"] == -1 + + +class TestInfrastructureMocking: + """Tests verifying mock infrastructure works correctly.""" + + def test_mock_logger_fixture(self, mock_logger): + assert mock_logger is not None + + def test_mock_subprocess_fixture(self, mock_subprocess): + assert mock_subprocess is not None + mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="") + _run_hook("test", "") + mock_subprocess.assert_called_once() + + def test_hooks_config_file_fixture(self, hooks_config_file): + assert hooks_config_file.exists() + config = json.loads(hooks_config_file.read_text()) + assert config["hooks_enabled"] is True + + +class TestInitProvisioning: + """Tests for initialization and directory setup.""" + + 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): + _log({"event": "init_test"}) + assert log_file.parent.exists() + + def test_config_walks_up_from_subdirectory(self, temp_test_dir, mock_logger): + config_dir = temp_test_dir / ".aipass" + config_dir.mkdir() + (config_dir / "hooks.json").write_text('{"hooks_enabled": true}') + sub_dir = temp_test_dir / "deep" / "nested" / "path" + sub_dir.mkdir(parents=True) + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=sub_dir): + config = find_project_config() + assert config is not None + assert config["hooks_enabled"] is True + + 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): + _log({"event": "new"}) + lines = log_file.read_text().strip().split("\n") + assert len(lines) == 2 + assert json.loads(lines[0])["existing"] is True + + def test_dispatch_returns_string(self, mock_logger): + with patch("aipass.hooks.apps.modules.engine._log"): + result = dispatch("Stop", "{}", {"hooks_enabled": True}) + assert isinstance(result, str) + + +class TestConftest: + """Tests verifying conftest fixtures and mock infrastructure.""" + + def test_sample_hooks_config_has_events(self, sample_hooks_config): + assert "UserPromptSubmit" in sample_hooks_config + assert "PreToolUse" in sample_hooks_config + + def test_sample_data_structure(self, sample_hooks_config): + hook = sample_hooks_config["UserPromptSubmit"]["test_hook"] + assert "enabled" in hook + assert "command" in hook + assert "matcher" in hook + + def test_hooks_config_file_is_valid_json(self, hooks_config_file): + content = json.loads(hooks_config_file.read_text()) + assert isinstance(content, dict) + + def test_temp_test_dir_exists(self, temp_test_dir): + assert temp_test_dir.exists() + assert temp_test_dir.is_dir() + + def test_mock_logger_is_mock(self, mock_logger): + mock_logger.info("test") + mock_logger.info.assert_called_once() + + def test_autouse_mock_subprocess(self, mock_subprocess): + mock_subprocess.return_value = MagicMock(returncode=0, stdout="x", stderr="") + result = _run_hook("test_cmd", "input") + assert result["stdout"] == "x" + + def test_sys_modules_mock_logger(self, mock_logger): + mock_logger.error("error msg") + mock_logger.error.assert_called_with("error msg") + + def test_reimport_after_mock(self, mock_logger): + from aipass.hooks.apps.modules import engine + + assert hasattr(engine, "dispatch") + assert hasattr(engine, "find_project_config") + + +class TestCliRouting: + """Additional CLI routing tests for print_help and output capture.""" + + def test_print_help_writes_to_stdout(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 + + def test_output_capture_status(self, capsys): + from aipass.hooks.apps.hooks import handle_command + + 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 + + def test_version_output(self, capsys): + from aipass.hooks.apps.hooks import main + + with patch("sys.argv", ["hooks", "--version"]): + main() + captured = capsys.readouterr() + assert "1.0.0" in captured.out + + +class TestConfigDataContracts: + """Additional data structure contract tests.""" + + def test_config_keys_are_strings(self, sample_hooks_config): + for key in sample_hooks_config: + assert isinstance(key, str) + + def test_hook_def_has_command_key(self, sample_hooks_config): + hook = sample_hooks_config["UserPromptSubmit"]["test_hook"] + assert "command" in hook + assert isinstance(hook["command"], str) + + 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): + _log({"ts": 1.0, "event": "Test", "hook": "test_hook", "exit_code": 0}) + entry = json.loads(log_file.read_text().strip()) + assert "ts" in entry + assert "event" in entry + assert "hook" in entry + assert "exit_code" in entry + + +class TestPathContracts: + """Tests for path-returning functions.""" + + def test_paths_return_path(self): + from aipass.hooks.apps.modules.engine import BRANCH_ROOT, LOG_FILE + + assert isinstance(BRANCH_ROOT, Path) + assert isinstance(LOG_FILE, Path) + + +class TestErrorResilienceExtended: + """Additional error resilience tests.""" + + def test_dispatch_with_empty_stdin(self, mock_logger): + config = { + "hooks_enabled": True, + "Stop": {"hook": {"enabled": True, "command": "echo ok", "matcher": ""}}, + } + with patch("aipass.hooks.apps.modules.engine._log"): + with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: + mock_run.return_value = {"exit_code": 0, "stdout": "ok", "stderr": "", "elapsed_ms": 5} + result = dispatch("Stop", "", config) + assert "ok" in result + + def test_config_with_nonexistent_dir(self, temp_test_dir, mock_logger): + nonexistent = temp_test_dir / "does_not_exist" + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=nonexistent): + with patch("aipass.hooks.apps.modules.engine.Path.home", return_value=temp_test_dir): + config = find_project_config() + assert config is None + + 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): + _log({"event": "test_missing"}) + assert missing.exists() + + def test_empty_file_config(self, temp_test_dir, mock_logger): + config_dir = temp_test_dir / ".aipass" + config_dir.mkdir() + empty_file = config_dir / "hooks.json" + empty_file.write_text("") + with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir): + config = find_project_config() + assert config is None + + +class TestMockInfrastructure: + """Tests verifying sys.modules mocking and reimport patterns.""" + + def test_sys_modules_mock(self): + import sys + + assert "aipass.hooks.apps.modules.engine" in sys.modules + + def test_reimport_after_mock(self, mock_logger): + import importlib + from aipass.hooks.apps.modules import engine + + importlib.reload(engine) + assert hasattr(engine, "dispatch") + assert hasattr(engine, "_matches") + assert hasattr(engine, "_run_hook") + + +class TestJsonHandlerNotApplicable: + """Hooks uses JSONL logging, not json_handler. These verify the log equivalent.""" + + 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): + _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): + _log({"event": "A", "ts": 1.0}) + _log({"event": "B", "ts": 2.0}) + for line in log_file.read_text().strip().split("\n"): + entry = json.loads(line) + assert isinstance(entry, dict) + + def test_log_get_path(self): + from aipass.hooks.apps.modules.engine 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): + _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): + _log({"saved": True, "value": 42}) + entry = json.loads(log_file.read_text().strip()) + assert entry["saved"] is True + assert entry["value"] == 42 + + def test_log_load_entry(self, temp_test_dir, mock_logger): + log_file = temp_test_dir / "load.jsonl" + log_file.write_text('{"loaded": true}\n') + lines = log_file.read_text().strip().split("\n") + entry = json.loads(lines[0]) + assert entry["loaded"] is True + + 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): + _log({"event": "PreToolUse", "hook": "test", "exit_code": 0}) + entry = json.loads(log_file.read_text().strip()) + assert entry["event"] == "PreToolUse" + + def test_log_ensure_module(self): + from aipass.hooks.apps.modules import engine + + assert hasattr(engine, "_log") + assert callable(engine._log) diff --git a/src/aipass/memory/apps/handlers/monitor/detector.py b/src/aipass/memory/apps/handlers/monitor/detector.py index b37be69c..0d6af026 100644 --- a/src/aipass/memory/apps/handlers/monitor/detector.py +++ b/src/aipass/memory/apps/handlers/monitor/detector.py @@ -286,11 +286,11 @@ def _should_rollover(file_path: Path) -> tuple[bool, int, int, str, str]: return (current_lines >= 600, current_lines, 600, "1.0.0", "") metadata = data.get("document_metadata", {}) - schema_version = metadata.get("schema_version", "1.0.0") limits = metadata.get("limits", {}) - # v2: entry-count based limits - if schema_version.startswith("2"): + # v2: entry-count based limits (checked when v2 limit keys are present, regardless of schema_version) + v2_limit_keys = {"max_sessions", "max_key_learnings", "max_observations"} + if v2_limit_keys & set(limits.keys()): reasons = [] max_sessions = limits.get("max_sessions") @@ -311,10 +311,10 @@ def _should_rollover(file_path: Path) -> tuple[bool, int, int, str, str]: if isinstance(observations, list) and len(observations) >= max_observations: reasons.append(f"{len(observations)}/{max_observations} observations") - triggered = len(reasons) > 0 - return (triggered, current_lines, 0, schema_version, ", ".join(reasons)) + if reasons: + return (True, current_lines, 0, "2.0.0", ", ".join(reasons)) - # v1: line-count based + # v1: line-count based (fallback when no v2 limits triggered) max_lines = limits.get("max_lines") if max_lines is None: max_lines = _get_max_lines(file_path) diff --git a/src/aipass/memory/apps/handlers/rollover/extractor.py b/src/aipass/memory/apps/handlers/rollover/extractor.py index a0862b0c..eb17565e 100644 --- a/src/aipass/memory/apps/handlers/rollover/extractor.py +++ b/src/aipass/memory/apps/handlers/rollover/extractor.py @@ -372,9 +372,10 @@ def extract_items(file_path: Path, percentage: int | None = None) -> Dict[str, A logger.warning(f"[extractor] Failed to read file {file_path}: {e}") return {"success": False, "error": f"Failed to read file: {e}"} - # v2 schema: delegate to entry-count based extraction - schema_version = data.get("document_metadata", {}).get("schema_version", "1.0.0") - if schema_version.startswith("2"): + # v2: entry-count based extraction (when v2 limit keys are present, regardless of schema_version) + ext_limits = data.get("document_metadata", {}).get("limits", {}) + v2_limit_keys = {"max_sessions", "max_key_learnings", "max_observations"} + if v2_limit_keys & set(ext_limits.keys()): return _extract_items_v2(file_path, data) # v1: line-count based extraction diff --git a/src/aipass/prax/apps/handlers/dashboard/refresh.py b/src/aipass/prax/apps/handlers/dashboard/refresh.py index 565d7233..587b5afe 100644 --- a/src/aipass/prax/apps/handlers/dashboard/refresh.py +++ b/src/aipass/prax/apps/handlers/dashboard/refresh.py @@ -202,6 +202,16 @@ def _preserve_write_through_sections(dashboard: Dict, branch_path: Path, branch_ logger.warning("Failed to preserve write-through sections for %s: %s", branch_name, e) +def _run_devpulse_plugin(branch_path: Path) -> None: + """Invoke devpulse dashboard plugin refresh for custom sections.""" + try: + from aipass.prax.apps.plugins.devpulse_dashboard.refresh import refresh as devpulse_refresh + + devpulse_refresh(branch_path) + except Exception as e: + logger.warning("Devpulse plugin refresh failed: %s", e) + + def refresh_all_dashboards() -> Dict: """ Refresh all branch dashboards from central files. @@ -246,6 +256,11 @@ def refresh_all_dashboards() -> Dict: # Save save_dashboard(branch_path, dashboard) + + # Invoke devpulse plugin refresh for custom sections (git, session, dispatch) + if branch_name == "DEVPULSE": + _run_devpulse_plugin(branch_path) + branches_updated += 1 except Exception as e: @@ -304,6 +319,10 @@ def refresh_single_dashboard(branch_path: Path) -> Dict: save_dashboard(branch_path, dashboard) + # Invoke devpulse plugin refresh for custom sections (git, session, dispatch) + if branch_name == "DEVPULSE": + _run_devpulse_plugin(branch_path) + return {"status": "success", "branch": branch_name} except Exception as e: diff --git a/src/aipass/prax/apps/handlers/dashboard/template_pusher.py b/src/aipass/prax/apps/handlers/dashboard/template_pusher.py index bd5a3c45..2bed58ef 100644 --- a/src/aipass/prax/apps/handlers/dashboard/template_pusher.py +++ b/src/aipass/prax/apps/handlers/dashboard/template_pusher.py @@ -180,6 +180,35 @@ def _safe_write_dashboard( return False +def _update_spawn_template( + spawn_path: Path, template: dict, dry_run: bool, result: Dict[str, Any], updated_list: List[str] +) -> None: + """Apply structural updates to spawn's builder template, preserving placeholders.""" + if not spawn_path.exists(): + return + try: + spawn_data = json.loads(spawn_path.read_text()) + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to read spawn template: %s", e) + result["errors"].append(f"SPAWN_TEMPLATE: {e}") + return + + spawn_actions: List[str] = [] + changed, spawn_actions = _apply_structural_updates(spawn_data, template, spawn_actions) + if not changed: + return + + spawn_data["branch"] = "{{BRANCHNAME}}" + spawn_data["last_updated"] = "{{DATE}}" + for section in spawn_data.get("sections", {}).values(): + if isinstance(section, dict) and "last_updated" in section: + section["last_updated"] = "{{DATE}}" + + if _safe_write_dashboard(spawn_path, spawn_data, "SPAWN_TEMPLATE", dry_run, result): + updated_list.append("SPAWN_TEMPLATE") + result["changes"].append({"branch": "SPAWN_TEMPLATE", "actions": spawn_actions}) + + def _apply_structural_updates(data: dict, template: dict, branch_actions: List[str]) -> tuple: """Apply structural updates from template to existing dashboard data. @@ -336,6 +365,10 @@ def push_dashboard_template(dry_run: bool = False) -> Dict[str, Any]: branches_updated_list.append(branch_name) result["changes"].append({"branch": branch_name, "actions": branch_actions}) + # Update spawn template (scaffold for new branches) + spawn_template_path = repo_root / "src" / "aipass" / "spawn" / "templates" / "builder" / "DASHBOARD.local.json" + _update_spawn_template(spawn_template_path, template, dry_run, result, branches_updated_list) + # Update version file if any branches were modified if not dry_run and branches_updated_list: _update_version_file(branches_updated_list) diff --git a/src/aipass/prax/apps/plugins/devpulse_dashboard/refresh.py b/src/aipass/prax/apps/plugins/devpulse_dashboard/refresh.py index 1722d809..567bd3d4 100644 --- a/src/aipass/prax/apps/plugins/devpulse_dashboard/refresh.py +++ b/src/aipass/prax/apps/plugins/devpulse_dashboard/refresh.py @@ -14,7 +14,7 @@ Failures in one section don't block others. """ from pathlib import Path -from typing import Dict, List +from typing import Dict, List, Optional from aipass.prax.apps.modules.logger import system_logger as logger @@ -24,7 +24,7 @@ _AIPASS_SRC = Path(__file__).resolve().parents[4] # .../src/aipass/ DEVPULSE_PATH = _AIPASS_SRC / "devpulse" -def refresh(branch_path: Path = None) -> Dict: +def refresh(branch_path: Optional[Path] = None) -> Dict: """Refresh all devpulse custom dashboard sections. Args: diff --git a/src/aipass/prax/templates/.dashboard_version.json b/src/aipass/prax/templates/.dashboard_version.json index 40236e44..75a57659 100644 --- a/src/aipass/prax/templates/.dashboard_version.json +++ b/src/aipass/prax/templates/.dashboard_version.json @@ -10,19 +10,9 @@ "description": "Branch dashboard template - v3 schema with write-through sections" } }, - "last_push": "2026-05-16 21:34:52", + "last_push": "2026-05-17 00:08:31", "last_push_branches": [ - "AI_MAIL", - "AIPASS", - "API", - "CLI", "DEVPULSE", - "DRONE", - "FLOW", - "MEMORY", - "PRAX", - "SEEDGO", - "SPAWN", - "TRIGGER" + "SPAWN_TEMPLATE" ] } diff --git a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json index 2647965d..236e7aa8 100644 --- a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json +++ b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json @@ -1,7 +1,7 @@ { "metadata": { "version": "1.0.0", - "last_updated": "2026-05-16", + "last_updated": "2026-05-18", "description": "Template file tracking registry for ID-based updates" }, "files": { @@ -104,7 +104,7 @@ "f016": { "path": "DASHBOARD.local.json", "name": "DASHBOARD.local.json", - "content_hash": "88360d943d4b", + "content_hash": "f4775daf1f75", "has_branch_placeholder": false }, "f017": { @@ -155,7 +155,7 @@ "content_hash": "a4cf0a8e3b4f", "has_branch_placeholder": false }, - "f015": { + "f026": { "path": "apps/modules/__init__.py", "name": "__init__.py", "content_hash": "e3b0c44298fc", @@ -269,7 +269,7 @@ "content_hash": "28e9ae373563", "has_branch_placeholder": false }, - "f026": { + "f015": { "path": "apps/plugins/__init__.py", "name": "__init__.py", "content_hash": "e3b0c44298fc", diff --git a/src/aipass/spawn/templates/builder/DASHBOARD.local.json b/src/aipass/spawn/templates/builder/DASHBOARD.local.json index 5360cd69..3c524cfc 100644 --- a/src/aipass/spawn/templates/builder/DASHBOARD.local.json +++ b/src/aipass/spawn/templates/builder/DASHBOARD.local.json @@ -6,9 +6,8 @@ "new_mail": 0, "opened_mail": 0, "active_plans": 0, - "commons_mentions": 0, "action_required": false, - "summary": "" + "summary": "All clear" }, "sections": { "ai_mail": { @@ -26,37 +25,11 @@ "recently_closed": [], "last_updated": "{{DATE}}" }, - "memory_bank": { - "managed_by": "memory_bank", + "memory": { + "managed_by": "memory", "vectors_stored": 0, "notes": {}, "last_updated": "{{DATE}}" - }, - "devpulse": { - "managed_by": "devpulse", - "summary": { - "issues": 0, - "todos": 0 - }, - "dplan_counts": {}, - "recent_activity": "", - "last_updated": "{{DATE}}" - }, - "commons_activity": { - "managed_by": "the_commons", - "new_posts_since_last_visit": 0, - "new_comments_since_last_visit": 0, - "mentions": 0, - "trending": "None", - "last_checked": null, - "last_updated": "{{DATE}}" - }, - "agent_status": { - "managed_by": "prax", - "active_agents": [], - "agent_count": 0, - "stale_agents": [], - "last_updated": "{{DATE}}" } } }