orchestrata_ultimate.core.adapters.claude.hooks

Reference › orchestrata_ultimate › core › adapters › claude › hooks

Hook management for the Claude Code integration. HookOrchestrator keeps a registry of hooks by HookCategory (PRE_TOOL, POST_TOOL, SESSION_BEGIN, SESSION_FINISH, SYSTEM_STOP). Each hook is a HookDescriptor with an identifier, a handler callable, an ExecutionPriority (URGENT 0, HIGH 10, MEDIUM 50, LOW 90), an active flag and an optional tool-name filter. register, unregister, activate_hook, deactivate_hook and list_hooks manage the registry.

Hooks can come from a JSON configuration file given to the constructor. _load_configuration reads its hooks list, and _resolve_handler turns a module.path:callable string into a callable. A hook with no handler, or one that cannot be resolved, is logged as an error and skipped rather than turned into a silent no-op. run_pre_tool_hooks walks the active pre-tool hooks whose tool filter matches and returns an InterceptorResult from tool_interceptor; the first hook that blocks ends the walk, and if none blocks the result is an ALLOW marked "Allowed by default". run_post_tool_hooks, run_session_begin_hooks and run_session_finish_hooks run the other categories. Every execution is recorded as an ExecutionOutcome (succeeded, verdict, note, elapsed time), available through get_execution_history. An optional ToolInterceptor can be supplied to the orchestrator.

At a glance

Source src/orchestrata_ultimate/core/adapters/claude/hooks.py
Lines of code 535
Classes 5
Functions 0
Imports (internal) orchestrata_ultimate.core.adapters.claude.tool_interceptor
Imported by orchestrata_ultimate.core.adapters, orchestrata_ultimate.core.adapters.claude
Tests that import it tests/core/test_adapters.py, tests/core/test_claude_hooks_config.py, tests/generated_core/test_adapters_claude_hooks_generated.py

Class diagram

Class diagram orchestrata_ultimate.core.adapters.claude.hooks

Dependency diagram

Dependency diagram orchestrata_ultimate.core.adapters.claude.hooks

API

Classes

HookCategory (enum)

Bases: StrEnum · Line 27

Hook execution categories.

Member Value
PRE_TOOL 'pre_tool'
POST_TOOL 'post_tool'
SESSION_BEGIN 'session_begin'
SESSION_FINISH 'session_finish'
SYSTEM_STOP 'system_stop'

ExecutionPriority (enum)

Bases: IntEnum · Line 37

Hook execution priority ordering.

Member Value
URGENT 0
HIGH 10
MEDIUM 50
LOW 90

HookDescriptor (dataclass)

Decorators: dataclass · Line 47

Describes a registered hook.

Attribute Type Default
identifier str
category HookCategory
handler Callable
priority ExecutionPriority ExecutionPriority.MEDIUM
active bool True
tool_filter str | None None
metadata dict field(default_factory=dict)

ExecutionOutcome (dataclass)

Decorators: dataclass · Line 60

Result from hook execution.

Attribute Type Default
hook_id str
succeeded bool
verdict InterceptorDecision InterceptorDecision.ALLOW
note str ''
elapsed_ms float 0.0
extra dict field(default_factory=dict)

HookOrchestrator

Line 71

Orchestrates hook execution for Claude Code.

Method Signature Summary
__init__ __init__(config_file: Path | None = None, interceptor: ToolInterceptor | None = None) -> None Initialize orchestrator.
_load_configuration _load_configuration(path: Path) -> None Load hook configuration from file.
_resolve_handler staticmethod _resolve_handler(spec: str) -> Callable Resolve a 'module.path:callable' string to a real callable.
register register(identifier: str, category: HookCategory, handler: Callable, priority: ExecutionPriority = ExecutionPriority.MEDIUM, tool_filter: str | None = None) -> None Register new hook.
unregister unregister(identifier: str, category: HookCategory) -> bool Remove hook by identifier.
list_hooks list_hooks(category: HookCategory) -> list[HookDescriptor] Get hooks for category.
activate_hook activate_hook(identifier: str, category: HookCategory) -> bool Activate hook.
deactivate_hook deactivate_hook(identifier: str, category: HookCategory) -> bool Deactivate hook.
run_pre_tool_hooks async run_pre_tool_hooks(tool: str, parameters: dict[str, Any]) -> InterceptorResult Execute pre-tool hooks.
_run_pre_tool_hook_loop async _run_pre_tool_hook_loop(tool: str, parameters: dict[str, Any], start: datetime) -> InterceptorResult Iterate over registered pre-tool hooks and apply each one.
_apply_pre_tool_hook async _apply_pre_tool_hook(hook: HookDescriptor, tool: str, parameters: dict[str, Any], start: datetime) -> InterceptorResult | None Execute a single pre-tool hook and return a block result if blocked.
run_post_tool_hooks async run_post_tool_hooks(tool: str, parameters: dict[str, Any], output: Any) -> None Execute post-tool hooks.
run_session_begin_hooks async run_session_begin_hooks(session_id: str) -> None Execute session begin hooks.
run_session_finish_hooks async run_session_finish_hooks(session_id: str) -> None Execute session finish hooks.
_execute_hook async _execute_hook(hook: HookDescriptor, **args, ****kwargs) -> ExecutionOutcome Execute single hook.
_matches_filter _matches_filter(tool: str, pattern: str) -> bool Check if tool matches filter pattern.
_record_outcome _record_outcome(hook_id: str, succeeded: bool, verdict: InterceptorDecision, note: str, start: datetime) -> None Record hook execution outcome.
get_execution_history get_execution_history() -> list[ExecutionOutcome] Get execution history.
clear_history clear_history() -> None Clear execution history.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/core/adapters/claude/hooks.py

# mypy: ignore-errors
"""Hook management for Claude Code integration.

Completely rewritten with fresh architecture.
"""

import importlib
import json
import logging
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import IntEnum, StrEnum
from pathlib import Path
from typing import Any

from orchestrata_ultimate.core.adapters.claude.tool_interceptor import (
    InterceptorDecision,
    InterceptorResult,
    ToolInterceptor,
)

log = logging.getLogger(__name__)


class HookCategory(StrEnum):
    """Hook execution categories."""

    PRE_TOOL = "pre_tool"
    POST_TOOL = "post_tool"
    SESSION_BEGIN = "session_begin"
    SESSION_FINISH = "session_finish"
    SYSTEM_STOP = "system_stop"


class ExecutionPriority(IntEnum):
    """Hook execution priority ordering."""

    URGENT = 0
    HIGH = 10
    MEDIUM = 50
    LOW = 90


@dataclass
class HookDescriptor:
    """Describes a registered hook."""

    identifier: str
    category: HookCategory
    handler: Callable
    priority: ExecutionPriority = ExecutionPriority.MEDIUM
    active: bool = True
    tool_filter: str | None = None
    metadata: dict = field(default_factory=dict)


@dataclass
class ExecutionOutcome:
    """Result from hook execution."""

    hook_id: str
    succeeded: bool
    verdict: InterceptorDecision = InterceptorDecision.ALLOW
    note: str = ""
    elapsed_ms: float = 0.0
    extra: dict = field(default_factory=dict)


class HookOrchestrator:
    """Orchestrates hook execution for Claude Code.

    Manages hook lifecycle, execution ordering, and governance integration.
    """

    def __init__(
        self,
        config_file: Path | None = None,
        interceptor: ToolInterceptor | None = None,
    ) -> None:
        """Initialize orchestrator.

        Args:
            config_file: Optional configuration file path.
            interceptor: Optional tool interceptor instance.
        """
        self.config_file = config_file
        self.interceptor = interceptor or ToolInterceptor()
        self._registry: dict[HookCategory, list[HookDescriptor]] = {
            cat: [] for cat in HookCategory
        }
        self._execution_history: list[ExecutionOutcome] = []

        if config_file and config_file.exists():
            self._load_configuration(config_file)

    def _load_configuration(self, path: Path) -> None:
        """Load hook configuration from file.

        Args:
            path: Configuration file path.
        """
        try:
            raw = path.read_text(encoding="utf-8")
            config = json.loads(raw)
            log.info(f"Loaded hook config from {path}")

            for hook_def in config.get("hooks", []):
                category = HookCategory(hook_def["category"])
                identifier = hook_def.get("identifier", "<unknown>")
                spec = hook_def.get("handler")
                if not spec:
                    # No silent no-op: a configured hook with no handler is a
                    # config error — surface it loudly and skip it.
                    log.error(
                        "Hook %r declares no 'handler' (module:callable); "
                        "skipping — configured hooks must resolve to a real "
                        "callable.",
                        identifier,
                    )
                    continue
                try:
                    handler = self._resolve_handler(spec)
                except Exception as exc:
                    log.error(
                        "Hook %r handler %r could not be resolved: %s; skipping.",
                        identifier,
                        spec,
                        exc,
                    )
                    continue
                self._registry[category].append(
                    HookDescriptor(
                        identifier=hook_def["identifier"],
                        category=category,
                        handler=handler,
                        priority=ExecutionPriority[hook_def.get("priority", "MEDIUM")],
                        active=hook_def.get("active", True),
                        tool_filter=hook_def.get("tool_filter"),
                        metadata=hook_def.get("metadata", {}),
                    )
                )
        except Exception as e:
            log.error(f"Failed loading config: {e}", exc_info=True)

    @staticmethod
    def _resolve_handler(spec: str) -> Callable:
        """Resolve a ``'module.path:callable'`` string to a real callable.

        Args:
            spec: Dotted import path of the form ``package.module:function``.

        Returns:
            The resolved callable.

        Raises:
            ValueError: If *spec* is not ``module:callable`` form or the target
                is not callable.
            ImportError / AttributeError: If the module/attribute cannot be
                imported.
        """
        if ":" not in spec:
            raise ValueError(f"hook handler must be 'module:callable', got {spec!r}")
        module_path, _, attr = spec.partition(":")
        module = importlib.import_module(module_path)
        handler = getattr(module, attr)
        if not callable(handler):
            raise ValueError(f"hook handler {spec!r} is not callable")
        return handler

    def register(
        self,
        identifier: str,
        category: HookCategory,
        handler: Callable,
        priority: ExecutionPriority = ExecutionPriority.MEDIUM,
        tool_filter: str | None = None,
    ) -> None:
        """Register new hook.

        Args:
            identifier: Unique hook identifier.
            category: Hook category.
            handler: Callback function.
            priority: Execution priority.
            tool_filter: Optional regex for tool filtering.
        """
        descriptor = HookDescriptor(
            identifier=identifier,
            category=category,
            handler=handler,
            priority=priority,
            tool_filter=tool_filter,
        )
        self._registry[category].append(descriptor)
        self._registry[category].sort(key=lambda h: h.priority)
        log.debug(f"Registered hook: {identifier} ({category.value})")

    def unregister(self, identifier: str, category: HookCategory) -> bool:
        """Remove hook by identifier.

        Args:
            identifier: Hook identifier.
            category: Hook category.

        Returns:
            True if hook was removed.
        """
        hooks = self._registry[category]
        for idx, hook in enumerate(hooks):
            if hook.identifier == identifier:
                hooks.pop(idx)
                log.debug(f"Unregistered hook: {identifier}")
                return True
        return False

    def list_hooks(self, category: HookCategory) -> list[HookDescriptor]:
        """Get hooks for category.

        Args:
            category: Hook category.

        Returns:
            List of hook descriptors.
        """
        return self._registry[category].copy()

    def activate_hook(self, identifier: str, category: HookCategory) -> bool:
        """Activate hook.

        Args:
            identifier: Hook identifier.
            category: Hook category.

        Returns:
            True if found and activated.
        """
        for hook in self._registry[category]:
            if hook.identifier == identifier:
                hook.active = True
                return True
        return False

    def deactivate_hook(self, identifier: str, category: HookCategory) -> bool:
        """Deactivate hook.

        Args:
            identifier: Hook identifier.
            category: Hook category.

        Returns:
            True if found and deactivated.
        """
        for hook in self._registry[category]:
            if hook.identifier == identifier:
                hook.active = False
                return True
        return False

    async def run_pre_tool_hooks(
        self,
        tool: str,
        parameters: dict[str, Any],
    ) -> InterceptorResult:
        """Execute pre-tool hooks.

        Args:
            tool: Tool name.
            parameters: Tool parameters.

        Returns:
            Interceptor result.
        """
        start = datetime.now(UTC)

        # Check interceptor first
        result = await self.interceptor.intercept(tool, parameters)

        if result.decision == InterceptorDecision.BLOCK:
            self._record_outcome(
                "interceptor",
                False,
                result.decision,
                result.message,
                start,
            )
            return result

        return await self._run_pre_tool_hook_loop(tool, parameters, start)

    async def _run_pre_tool_hook_loop(
        self,
        tool: str,
        parameters: dict[str, Any],
        start: datetime,
    ) -> InterceptorResult:
        """Iterate over registered pre-tool hooks and apply each one.

        Args:
            tool: Tool name.
            parameters: Tool parameters.
            start: Start timestamp for elapsed tracking.

        Returns:
            Interceptor result (BLOCK or default ALLOW).
        """
        for hook in self._registry[HookCategory.PRE_TOOL]:
            if not hook.active:
                continue

            if hook.tool_filter and not self._matches_filter(tool, hook.tool_filter):
                continue

            block_result = await self._apply_pre_tool_hook(
                hook, tool, parameters, start
            )
            if block_result is not None:
                return block_result

        return InterceptorResult(
            decision=InterceptorDecision.ALLOW,
            reason="Allowed by default",
            tool_name=tool,
            original_input=parameters,
        )

    async def _apply_pre_tool_hook(
        self,
        hook: HookDescriptor,
        tool: str,
        parameters: dict[str, Any],
        start: datetime,
    ) -> InterceptorResult | None:
        """Execute a single pre-tool hook and return a block result if blocked.

        Args:
            hook: Hook descriptor to execute.
            tool: Tool name.
            parameters: Tool parameters.
            start: Start timestamp for elapsed tracking.

        Returns:
            InterceptorResult if the hook blocks, None otherwise.
        """
        try:
            outcome = await self._execute_hook(hook, tool, parameters)
            self._record_outcome(
                hook.identifier,
                outcome.succeeded,
                outcome.verdict,
                outcome.note,
                start,
            )

            if outcome.verdict == InterceptorDecision.BLOCK:
                return InterceptorResult(
                    decision=InterceptorDecision.BLOCK,
                    reason=outcome.note or "Blocked by hook",
                    tool_name=tool,
                    original_input=parameters,
                    message=outcome.note or "",
                    hook_name=hook.identifier,
                )
        except Exception as e:  # pragma: no cover
            log.error(
                f"Hook {hook.identifier} failed: {e}", exc_info=True
            )  # pragma: no cover
            self._record_outcome(
                hook.identifier, False, InterceptorDecision.ALLOW, str(e), start
            )  # pragma: no cover

        return None

    async def run_post_tool_hooks(
        self,
        tool: str,
        parameters: dict[str, Any],
        output: Any,
    ) -> None:
        """Execute post-tool hooks.

        Args:
            tool: Tool name.
            parameters: Tool parameters.
            output: Tool output.
        """
        for hook in self._registry[HookCategory.POST_TOOL]:
            if not hook.active:
                continue

            if hook.tool_filter and not self._matches_filter(tool, hook.tool_filter):
                continue

            try:
                await self._execute_hook(hook, tool, parameters, output)
            except Exception as e:  # pragma: no cover
                log.error(
                    f"Post-tool hook {hook.identifier} failed: {e}", exc_info=True
                )  # pragma: no cover

    async def run_session_begin_hooks(self, session_id: str) -> None:
        """Execute session begin hooks.

        Args:
            session_id: Session identifier.
        """
        for hook in self._registry[HookCategory.SESSION_BEGIN]:
            if not hook.active:
                continue

            try:
                await self._execute_hook(hook, session_id=session_id)
            except Exception as e:  # pragma: no cover
                log.error(
                    f"Session begin hook {hook.identifier} failed: {e}", exc_info=True
                )  # pragma: no cover

    async def run_session_finish_hooks(self, session_id: str) -> None:
        """Execute session finish hooks.

        Args:
            session_id: Session identifier.
        """
        for hook in self._registry[HookCategory.SESSION_FINISH]:
            if not hook.active:
                continue

            try:
                await self._execute_hook(hook, session_id=session_id)
            except Exception as e:  # pragma: no cover
                log.error(
                    f"Session finish hook {hook.identifier} failed: {e}", exc_info=True
                )  # pragma: no cover

    async def _execute_hook(
        self,
        hook: HookDescriptor,
        *args,
        **kwargs,
    ) -> ExecutionOutcome:
        """Execute single hook.

        Args:
            hook: Hook descriptor.
            *args: Positional arguments.
            **kwargs: Keyword arguments.

        Returns:
            Execution outcome.
        """
        start = datetime.now(UTC)
        try:
            outcome = hook.handler(*args, **kwargs)
            if hasattr(outcome, "__await__"):
                outcome = await outcome

            elapsed = (datetime.now(UTC) - start).total_seconds() * 1000

            if isinstance(outcome, ExecutionOutcome):
                outcome.elapsed_ms = elapsed
                return outcome

            return ExecutionOutcome(
                hook_id=hook.identifier,
                succeeded=True,
                verdict=InterceptorDecision.ALLOW,
                elapsed_ms=elapsed,
            )
        except Exception as e:
            elapsed = (datetime.now(UTC) - start).total_seconds() * 1000
            return ExecutionOutcome(
                hook_id=hook.identifier,
                succeeded=False,
                verdict=InterceptorDecision.ALLOW,
                note=str(e),
                elapsed_ms=elapsed,
            )

    def _matches_filter(self, tool: str, pattern: str) -> bool:
        """Check if tool matches filter pattern.

        Args:
            tool: Tool name.
            pattern: Regex pattern.

        Returns:
            True if matches.
        """
        return bool(re.match(pattern, tool))

    def _record_outcome(
        self,
        hook_id: str,
        succeeded: bool,
        verdict: InterceptorDecision,
        note: str,
        start: datetime,
    ) -> None:
        """Record hook execution outcome.

        Args:
            hook_id: Hook identifier.
            succeeded: Success status.
            verdict: Interceptor verdict.
            note: Outcome message.
            start: Start timestamp.
        """
        elapsed = (datetime.now(UTC) - start).total_seconds() * 1000
        outcome = ExecutionOutcome(
            hook_id=hook_id,
            succeeded=succeeded,
            verdict=verdict,
            note=note,
            elapsed_ms=elapsed,
        )
        self._execution_history.append(outcome)
        if len(self._execution_history) > 1000:
            self._execution_history = self._execution_history[-500:]

    def get_execution_history(self) -> list[ExecutionOutcome]:
        """Get execution history.

        Returns:
            List of outcomes.
        """
        return self._execution_history.copy()

    def clear_history(self) -> None:
        """Clear execution history."""
        self._execution_history.clear()


# Backwards compatibility alias
HookManager = HookOrchestrator