feat: hooks branch — hook engine + bridge wiring (DPLAN-0184 Phase 1)

This commit is contained in:
AIOSAI
2026-05-18 20:43:30 -07:00
parent a01d09b3cf
commit ece29256f4
56 changed files with 2303 additions and 172 deletions
View File
+6 -2
View File
@@ -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`
+79
View File
@@ -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}
+215
View File
@@ -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()
+10
View File
@@ -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"]}
+54
View File
@@ -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()
+40
View File
@@ -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()
+2 -1
View File
@@ -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
View File
@@ -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": [
+12 -24
View File
@@ -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
# ---------------------------------------------------------------------------
+30
View File
@@ -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": {
+3
View File
@@ -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}
+5
View File
@@ -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.
+15
View File
@@ -0,0 +1,15 @@
__pycache__/
*.pyc
*.pyo
.env
.venv/
*.egg-info/
.coverage
htmlcov/
.pytest_cache/
.mypy_cache/
dist/
build/
*.log
*.tmp
*.swp
+5
View File
@@ -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.
+119
View File
@@ -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"
}
}
}
+77
View File
@@ -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)
+8
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
# HOOKS apps package
from . import handlers # noqa: F401
+5
View File
@@ -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()
+125
View File
@@ -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.
+5
View File
@@ -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.
+226
View File
@@ -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)
+5
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
# Docs
Documentation files for the `HOOKS` branch.
+17
View File
@@ -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.
+5
View File
@@ -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).
+6
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
# Tests package for hooks
+80
View File
@@ -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
+730
View File
@@ -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}}"
}
}
}