feat: hooks branch — hook engine + bridge wiring (DPLAN-0184 Phase 1)
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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 <EventType>
|
||||
|
||||
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 <EventType>\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()
|
||||
@@ -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"]}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -121,4 +121,4 @@ branch_audits/
|
||||
claude_4_7_transition_notes.md
|
||||
README_ORIGINAL_DISABLED.md
|
||||
*.bak
|
||||
test/
|
||||
test/
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,87 @@
|
||||
# HOOKS — Branch Prompt
|
||||
|
||||
<!--
|
||||
INSTRUCTIONS FOR FILLING OUT THIS TEMPLATE
|
||||
==========================================
|
||||
|
||||
This file is your local prompt. It gets injected every turn alongside the global prompt.
|
||||
The global prompt already covers: dispatch syntax, git workflow, hard rules, logging,
|
||||
memory system, breadcrumbs philosophy, and system-wide commands. Don't repeat any of that here.
|
||||
|
||||
Your job: fill out each section below with content specific to YOUR branch.
|
||||
Replace the guidance text (in italics) with real content, then delete this instruction block.
|
||||
|
||||
PRINCIPLES:
|
||||
- Breadcrumbs, not encyclopedias. Enough to navigate, not everything there is to know.
|
||||
- Operational, not descriptive. Tell the agent how to ACT, not just what things ARE.
|
||||
- Include concrete reference data — lookup tables, checklists, decision trees.
|
||||
- If you can run `drone @hooks --help` to get it, don't duplicate it here.
|
||||
- This file should be STABLE. If it changes every session, that content belongs in
|
||||
STATUS.local.md or .trinity/ instead.
|
||||
|
||||
WHEN YOU'RE DONE:
|
||||
- Delete this instruction block
|
||||
- Delete all italic guidance text
|
||||
- What remains should be pure operational reference
|
||||
-->
|
||||
|
||||
*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}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,15 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
.venv/
|
||||
*.egg-info/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
# HOOKS apps package
|
||||
from . import handlers # noqa: F401
|
||||
@@ -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.
|
||||
@@ -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 ("<string>", "<stdin>"):
|
||||
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.<module> import <function>\n"
|
||||
f"\n"
|
||||
f" For full standards guide:\n"
|
||||
f" drone @seedgo handlers\n"
|
||||
f"{'=' * 60}"
|
||||
)
|
||||
|
||||
|
||||
# Run guard at import time
|
||||
_guard_branch_access()
|
||||
@@ -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 <EventType>\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()
|
||||
@@ -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())
|
||||
@@ -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 <contract>`) — 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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Docs
|
||||
|
||||
Documentation files for the `HOOKS` branch.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
# Tests package for hooks
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user