Files
lijiaoqiao/tools/hooks.py
Your Name c169d35c72 feat: add reviewer tool, lazy loading, manifest profiles, instinct learning, and hook profiles
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)
2026-04-14 22:40:56 +08:00

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}"