Features: - reviewer_tool: Multi-language code review (Python, JS/TS, Go, Rust, Java, C++, C#, PHP) - lazy_loader: Context-triggered skill loading with 3-tier budget system - manifest: Profile-based skill installation (coding, debugging, planning, shipping, research) - instinct: Learning system that tracks patterns and predicts skill loading - hook_profile: Security hook profiles (minimal, standard, strict, developer, rust-dev) - hook_tool + hooks: Post-write hook execution system Code Review Tool: - Supports 9 languages with lint, typecheck, format, test, security checks - Auto-detects language from file extensions - Configurable tools per language (e.g., ruff, eslint, golangci-lint) Lazy Loading: - CONTEXT_TO_SKILLS mapping for 20+ context triggers - Budget-aware loading (Tier 0: core, Tier 1: context, Tier 2: rare) - Emergency mode at >90% context usage Integration: - Registered in model_tools.py and toolsets.py - 8 language reviewers mapped in lazy_loader (python, go, rust, js, java, cpp, csharp, php)
579 lines
17 KiB
Python
579 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Hermes Hook System - Trigger-based automation for file and command events.
|
|
|
|
Hooks are YAML config files in ~/.hermes/hooks/ that run actions when
|
|
specific events occur (file writes, terminal commands, etc.).
|
|
|
|
Directory Structure:
|
|
~/.hermes/hooks/
|
|
├── pre-write/ # Before file write
|
|
│ └── <hook>.yaml
|
|
├── post-write/ # After file write
|
|
│ └── <hook>.yaml
|
|
├── pre-command/ # Before terminal command
|
|
│ └── <hook>.yaml
|
|
└── post-command/ # After terminal command
|
|
└── <hook>.yaml
|
|
|
|
Hook Config Format:
|
|
name: pytest-on-py
|
|
description: Run pytest on Python files after write
|
|
enabled: true
|
|
|
|
trigger:
|
|
event: file_write # file_write | file_patch | terminal_command
|
|
pattern: "*.py" # glob pattern
|
|
path: null # optional directory filter
|
|
|
|
actions:
|
|
- type: command
|
|
command: pytest {file}
|
|
timeout: 60
|
|
block_on_failure: true
|
|
|
|
- type: notification
|
|
message: "Tests passed for {file}"
|
|
|
|
conditions:
|
|
if_file_exists: true # only for pre-write (file already exists)
|
|
if_command_exists: [pytest, ruff]
|
|
|
|
Exit Codes:
|
|
- Hook command exit 0 + block_on_failure=false → continue
|
|
- Hook command exit 0 + block_on_failure=true → continue
|
|
- Hook command exit != 0 + block_on_failure=true → BLOCK
|
|
- Hook command exit != 0 + block_on_failure=false → warn + continue
|
|
"""
|
|
|
|
import fnmatch
|
|
import json
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import threading
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Optional
|
|
|
|
import yaml
|
|
|
|
from hermes_constants import get_hermes_home
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Hook event types
|
|
EVENT_FILE_WRITE = "file_write"
|
|
EVENT_FILE_PATCH = "file_patch"
|
|
EVENT_FILE_READ = "file_read"
|
|
EVENT_TERMINAL_COMMAND = "terminal_command"
|
|
|
|
# Hook directories relative to HERMES_HOME
|
|
HOOK_SUBDIRS = ["pre-write", "post-write", "pre-command", "post-command"]
|
|
|
|
# Cache for loaded hooks (thread-safe)
|
|
_hooks_cache: dict[str, list["Hook"]] = {}
|
|
_cache_lock = threading.RLock()
|
|
_cache_version: int = 0
|
|
|
|
|
|
def _get_hooks_dir() -> Path:
|
|
"""Return the hooks directory path."""
|
|
return get_hermes_home() / "hooks"
|
|
|
|
|
|
@dataclass
|
|
class HookAction:
|
|
"""A single action within a hook."""
|
|
|
|
type: str # "command", "notification", "log"
|
|
command: Optional[str] = None
|
|
message: Optional[str] = None
|
|
timeout: int = 30
|
|
block_on_failure: bool = False
|
|
continue_on_error: bool = False
|
|
platform: Optional[str] = None # telegram, discord, etc.
|
|
|
|
|
|
@dataclass
|
|
class Hook:
|
|
"""A loaded hook configuration."""
|
|
|
|
name: str
|
|
description: str
|
|
enabled: bool = True
|
|
trigger_event: Optional[str] = None
|
|
trigger_pattern: Optional[str] = None
|
|
trigger_path: Optional[str] = None
|
|
actions: list[HookAction] = field(default_factory=list)
|
|
conditions: dict[str, Any] = field(default_factory=dict)
|
|
source_file: Optional[Path] = None
|
|
|
|
def matches(self, event: str, file_path: Optional[str] = None) -> bool:
|
|
"""Check if this hook matches the given event and file."""
|
|
if not self.enabled:
|
|
return False
|
|
if self.trigger_event and self.trigger_event != event:
|
|
return False
|
|
if self.trigger_pattern and file_path:
|
|
if not fnmatch.fnmatch(Path(file_path).name, self.trigger_pattern):
|
|
return False
|
|
if self.trigger_path and file_path:
|
|
if not Path(file_path).as_posix().startswith(self.trigger_path):
|
|
return False
|
|
return True
|
|
|
|
|
|
@dataclass
|
|
class HookResult:
|
|
"""Result of running a hook."""
|
|
|
|
hook_name: str
|
|
success: bool
|
|
blocked: bool = False
|
|
message: str = ""
|
|
details: str = ""
|
|
|
|
|
|
def load_hooks(event: str) -> list[Hook]:
|
|
"""Load all enabled hooks matching the given event type."""
|
|
global _cache_version
|
|
|
|
cache_key = event
|
|
with _cache_lock:
|
|
# Check if we have a valid cache
|
|
if cache_key in _hooks_cache:
|
|
return _hooks_cache[cache_key]
|
|
|
|
hooks_dir = _get_hooks_dir()
|
|
loaded_hooks: list[Hook] = []
|
|
|
|
# Determine which subdirs to check based on event
|
|
subdir_map = {
|
|
EVENT_FILE_WRITE: ["pre-write", "post-write"],
|
|
EVENT_FILE_PATCH: ["pre-write", "post-write"],
|
|
EVENT_FILE_READ: [],
|
|
EVENT_TERMINAL_COMMAND: ["pre-command", "post-command"],
|
|
}
|
|
subdirs = subdir_map.get(event, [])
|
|
|
|
for subdir in subdirs:
|
|
subdir_path = hooks_dir / subdir
|
|
if not subdir_path.is_dir():
|
|
continue
|
|
|
|
for hook_file in subdir_path.glob("*.yaml"):
|
|
try:
|
|
hook = _load_hook_file(hook_file)
|
|
if hook and hook.matches(event):
|
|
loaded_hooks.append(hook)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load hook {hook_file}: {e}")
|
|
|
|
with _cache_lock:
|
|
_hooks_cache[cache_key] = loaded_hooks
|
|
|
|
return loaded_hooks
|
|
|
|
|
|
def _load_hook_file(path: Path) -> Optional[Hook]:
|
|
"""Load a single hook YAML file."""
|
|
try:
|
|
with open(path, "r") as f:
|
|
data = yaml.safe_load(f)
|
|
|
|
if not data:
|
|
return None
|
|
|
|
name = data.get("name", path.stem)
|
|
enabled = data.get("enabled", True)
|
|
trigger = data.get("trigger", {})
|
|
|
|
actions = []
|
|
for action_data in data.get("actions", []):
|
|
action = HookAction(
|
|
type=action_data.get("type", "command"),
|
|
command=action_data.get("command"),
|
|
message=action_data.get("message"),
|
|
timeout=action_data.get("timeout", 30),
|
|
block_on_failure=action_data.get("block_on_failure", False),
|
|
continue_on_error=action_data.get("continue_on_error", False),
|
|
platform=action_data.get("platform"),
|
|
)
|
|
actions.append(action)
|
|
|
|
conditions = data.get("conditions", {})
|
|
|
|
# Determine event type from subdir
|
|
subdir = path.parent.name
|
|
event_map = {
|
|
"pre-write": EVENT_FILE_WRITE,
|
|
"post-write": EVENT_FILE_WRITE,
|
|
"pre-command": EVENT_TERMINAL_COMMAND,
|
|
"post-command": EVENT_TERMINAL_COMMAND,
|
|
}
|
|
hook_event = trigger.get("event") or event_map.get(subdir, EVENT_FILE_WRITE)
|
|
|
|
return Hook(
|
|
name=name,
|
|
description=data.get("description", ""),
|
|
enabled=enabled,
|
|
trigger_event=hook_event,
|
|
trigger_pattern=trigger.get("pattern"),
|
|
trigger_path=trigger.get("path"),
|
|
actions=actions,
|
|
conditions=conditions,
|
|
source_file=path,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error loading hook {path}: {e}")
|
|
return None
|
|
|
|
|
|
def _check_conditions(hook: Hook, file_path: Optional[str] = None) -> bool:
|
|
"""Check if hook conditions are met."""
|
|
conditions = hook.conditions
|
|
|
|
# Check if_command_exists
|
|
if "if_command_exists" in conditions:
|
|
required_commands = conditions["if_command_exists"]
|
|
if isinstance(required_commands, str):
|
|
required_commands = [required_commands]
|
|
for cmd in required_commands:
|
|
if not _command_exists(cmd):
|
|
return False
|
|
|
|
# Check if_file_exists
|
|
if "if_file_exists" in conditions:
|
|
if file_path:
|
|
exists = Path(file_path).exists()
|
|
if conditions["if_file_exists"] and not exists:
|
|
return False
|
|
if not conditions["if_file_exists"] and exists:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def _command_exists(cmd: str) -> bool:
|
|
"""Check if a command exists in PATH."""
|
|
try:
|
|
result = subprocess.run(
|
|
["which", cmd],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
return result.returncode == 0
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _expand_variables(text: str, context: dict[str, Any]) -> str:
|
|
"""Expand {variable} placeholders in text."""
|
|
result = text
|
|
for key, value in context.items():
|
|
placeholder = "{" + key + "}"
|
|
if placeholder in result:
|
|
result = result.replace(placeholder, str(value))
|
|
return result
|
|
|
|
|
|
def _run_hook_action(
|
|
action: HookAction, context: dict[str, Any], dry_run: bool = False
|
|
) -> tuple[bool, str]:
|
|
"""
|
|
Run a single hook action.
|
|
|
|
Returns (success, message).
|
|
"""
|
|
if action.type == "command":
|
|
if not action.command:
|
|
return True, "No command specified"
|
|
|
|
if dry_run:
|
|
return True, f"[DRY RUN] Would run: {action.command}"
|
|
|
|
command = _expand_variables(action.command, context)
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
command,
|
|
shell=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=action.timeout,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
error_msg = f"Command failed: {result.stderr or result.stdout}"
|
|
if action.block_on_failure:
|
|
return False, error_msg
|
|
elif not action.continue_on_error:
|
|
return False, error_msg
|
|
# continue_on_error = True → warn but continue
|
|
return True, f"WARN: {error_msg}"
|
|
return True, result.stdout or "Command succeeded"
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return False, f"Command timed out after {action.timeout}s"
|
|
except Exception as e:
|
|
return False, f"Command error: {e}"
|
|
|
|
elif action.type == "notification":
|
|
message = _expand_variables(action.message or "", context)
|
|
platform = action.platform
|
|
|
|
if platform == "telegram":
|
|
# Would integrate with send_message_tool
|
|
logger.info(f"[NOTIFICATION] {message}")
|
|
return True, f"Notification sent: {message}"
|
|
elif platform == "discord":
|
|
logger.info(f"[DISCORD] {message}")
|
|
return True, f"Discord notification sent: {message}"
|
|
else:
|
|
# Log to stdout
|
|
logger.info(f"[NOTIFICATION] {message}")
|
|
return True, message
|
|
|
|
elif action.type == "log":
|
|
message = _expand_variables(action.message or "", context)
|
|
logger.info(f"[HOOK LOG] {message}")
|
|
return True, message
|
|
|
|
return True, f"Unknown action type: {action.type}"
|
|
|
|
|
|
def run_hooks(
|
|
event: str,
|
|
file_path: Optional[str] = None,
|
|
command: Optional[str] = None,
|
|
context: Optional[dict[str, Any]] = None,
|
|
dry_run: bool = False,
|
|
) -> list[HookResult]:
|
|
"""
|
|
Run all hooks matching the given event.
|
|
|
|
Args:
|
|
event: Event type (EVENT_FILE_WRITE, etc.)
|
|
file_path: File path for file events
|
|
command: Command for terminal events
|
|
context: Additional context for variable expansion
|
|
dry_run: If True, don't execute commands, just show what would run
|
|
|
|
Returns:
|
|
List of HookResult, one per hook that ran
|
|
"""
|
|
hooks = load_hooks(event)
|
|
results: list[HookResult] = []
|
|
blocked = False
|
|
|
|
# Build context for variable expansion
|
|
ctx = context or {}
|
|
if file_path:
|
|
ctx["file"] = file_path
|
|
ctx["filename"] = Path(file_path).name
|
|
ctx["dir"] = str(Path(file_path).parent)
|
|
if command:
|
|
ctx["command"] = command
|
|
|
|
for hook in hooks:
|
|
# Check conditions
|
|
if not _check_conditions(hook, file_path):
|
|
continue
|
|
|
|
# Check if pre-write and file doesn't exist (for new files)
|
|
if "if_file_exists" in hook.conditions:
|
|
# Already checked in _check_conditions
|
|
pass
|
|
|
|
hook_result = HookResult(
|
|
hook_name=hook.name,
|
|
success=True,
|
|
blocked=False,
|
|
message="",
|
|
details="",
|
|
)
|
|
|
|
for action in hook.actions:
|
|
success, message = _run_hook_action(action, ctx, dry_run)
|
|
|
|
if not success:
|
|
hook_result.success = False
|
|
hook_result.details = message
|
|
if action.block_on_failure:
|
|
hook_result.blocked = True
|
|
hook_result.message = f"Hook '{hook.name}' blocked: {message}"
|
|
blocked = True
|
|
results.append(hook_result)
|
|
return results # Early return on block
|
|
else:
|
|
hook_result.message = f"Hook '{hook.name}' warned: {message}"
|
|
else:
|
|
if message:
|
|
hook_result.details += message + "\n"
|
|
|
|
results.append(hook_result)
|
|
|
|
if blocked:
|
|
# Add a summary result if hooks blocked
|
|
results.insert(
|
|
0,
|
|
HookResult(
|
|
hook_name="__HOOK_BLOCK__",
|
|
success=False,
|
|
blocked=True,
|
|
message="Hook execution was blocked",
|
|
),
|
|
)
|
|
|
|
return results
|
|
|
|
|
|
def invalidate_cache():
|
|
"""Clear the hooks cache to force reload."""
|
|
global _cache_version
|
|
with _cache_lock:
|
|
_hooks_cache.clear()
|
|
_cache_version += 1
|
|
|
|
|
|
def list_hooks() -> dict[str, list[dict[str, Any]]]:
|
|
"""List all configured hooks by type."""
|
|
hooks_dir = _get_hooks_dir()
|
|
result: dict[str, list[dict[str, Any]]] = {}
|
|
|
|
for subdir in HOOK_SUBDIRS:
|
|
subdir_path = hooks_dir / subdir
|
|
if not subdir_path.is_dir():
|
|
continue
|
|
|
|
hooks_list: list[dict[str, Any]] = []
|
|
for hook_file in sorted(subdir_path.glob("*.yaml")):
|
|
try:
|
|
hook = _load_hook_file(hook_file)
|
|
if hook:
|
|
hooks_list.append(
|
|
{
|
|
"name": hook.name,
|
|
"description": hook.description,
|
|
"enabled": hook.enabled,
|
|
"trigger_event": hook.trigger_event,
|
|
"trigger_pattern": hook.trigger_pattern,
|
|
"actions_count": len(hook.actions),
|
|
"source": str(hook_file.relative_to(hooks_dir)),
|
|
}
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load hook {hook_file}: {e}")
|
|
|
|
if hooks_list:
|
|
result[subdir] = hooks_list
|
|
|
|
return result
|
|
|
|
|
|
def create_hook(
|
|
name: str,
|
|
subdir: str,
|
|
trigger_event: str,
|
|
pattern: Optional[str] = None,
|
|
actions: Optional[list[dict[str, Any]]] = None,
|
|
description: str = "",
|
|
) -> tuple[bool, str]:
|
|
"""
|
|
Create a new hook file.
|
|
|
|
Args:
|
|
name: Hook name (will be filename)
|
|
subdir: One of pre-write, post-write, pre-command, post-command
|
|
trigger_event: Event type
|
|
pattern: Optional glob pattern
|
|
actions: List of action dicts
|
|
description: Hook description
|
|
|
|
Returns:
|
|
(success, message)
|
|
"""
|
|
hooks_dir = _get_hooks_dir()
|
|
subdir_path = hooks_dir / subdir
|
|
|
|
try:
|
|
subdir_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
hook_data = {
|
|
"name": name,
|
|
"description": description,
|
|
"enabled": True,
|
|
"trigger": {"event": trigger_event},
|
|
"actions": actions or [],
|
|
}
|
|
|
|
if pattern:
|
|
hook_data["trigger"]["pattern"] = pattern
|
|
|
|
hook_file = subdir_path / f"{name}.yaml"
|
|
with open(hook_file, "w") as f:
|
|
yaml.dump(hook_data, f, default_flow_style=False, sort_keys=False)
|
|
|
|
invalidate_cache()
|
|
return True, f"Created hook: {hook_file}"
|
|
|
|
except Exception as e:
|
|
return False, f"Failed to create hook: {e}"
|
|
|
|
|
|
def delete_hook(hook_path: str) -> tuple[bool, str]:
|
|
"""
|
|
Delete a hook by its relative path (e.g., 'post-write/pytest-on-py').
|
|
|
|
Returns:
|
|
(success, message)
|
|
"""
|
|
hooks_dir = _get_hooks_dir()
|
|
full_path = hooks_dir / hook_path
|
|
|
|
if not full_path.exists() or not str(full_path).startswith(str(hooks_dir)):
|
|
return False, "Hook not found or invalid path"
|
|
|
|
try:
|
|
full_path.unlink()
|
|
invalidate_cache()
|
|
return True, f"Deleted hook: {hook_path}"
|
|
except Exception as e:
|
|
return False, f"Failed to delete hook: {e}"
|
|
|
|
|
|
def enable_hook(hook_path: str) -> tuple[bool, str]:
|
|
"""Enable a hook."""
|
|
return _set_hook_enabled(hook_path, True)
|
|
|
|
|
|
def disable_hook(hook_path: str) -> tuple[bool, str]:
|
|
"""Disable a hook."""
|
|
return _set_hook_enabled(hook_path, False)
|
|
|
|
|
|
def _set_hook_enabled(hook_path: str, enabled: bool) -> tuple[bool, str]:
|
|
"""Set a hook's enabled state."""
|
|
hooks_dir = _get_hooks_dir()
|
|
full_path = hooks_dir / hook_path
|
|
|
|
if not full_path.exists():
|
|
return False, "Hook not found"
|
|
|
|
try:
|
|
with open(full_path, "r") as f:
|
|
data = yaml.safe_load(f)
|
|
|
|
data["enabled"] = enabled
|
|
|
|
with open(full_path, "w") as f:
|
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
|
|
invalidate_cache()
|
|
return True, f"{'Enabled' if enabled else 'Disabled'} hook: {hook_path}"
|
|
|
|
except Exception as e:
|
|
return False, f"Failed to update hook: {e}"
|