lifeloop-cli 0.2.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Claude hook-protocol rendering.
//!
//! Maps neutral [`LifecycleEventKind`] inputs to the Claude Code hook
//! event vocabulary (`SessionStart`, `UserPromptSubmit`, `PreCompact`,
//! `Stop`, `SessionEnd`) and renders the per-event JSON payload Claude
//! Code consumes on the hook's stdout.
//!
//! Hook event names here are external, harness-defined wire tokens —
//! see the [`super`] module-level docs.

use crate::LifecycleEventKind;
use serde_json::{Value, json};

use super::{
    FrameAdmissionDirective, ProtocolAdapter, RenderError, RenderRequest, RenderedHookPayload,
    render_additional_context,
};

/// The set of Claude hook event tokens this renderer can emit.
///
/// String values mirror the wire tokens Claude Code's hook contract
/// publishes; they are not Lifeloop semantics. Variant order matches
/// Claude's published lifecycle order.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum ClaudeHookEvent {
    SessionStart,
    UserPromptSubmit,
    PreCompact,
    Stop,
    SessionEnd,
}

impl ClaudeHookEvent {
    pub const ALL: &'static [Self] = &[
        Self::SessionStart,
        Self::UserPromptSubmit,
        Self::PreCompact,
        Self::Stop,
        Self::SessionEnd,
    ];

    pub fn as_str(self) -> &'static str {
        match self {
            Self::SessionStart => "SessionStart",
            Self::UserPromptSubmit => "UserPromptSubmit",
            Self::PreCompact => "PreCompact",
            Self::Stop => "Stop",
            Self::SessionEnd => "SessionEnd",
        }
    }
}

/// Map a neutral [`LifecycleEventKind`] to the Claude hook event the
/// adapter surfaces for it. Returns `None` for events Claude's hook
/// protocol does not expose (e.g. `supervisor.tick`).
pub fn claude_hook_event_for(event: LifecycleEventKind) -> Option<ClaudeHookEvent> {
    match event {
        LifecycleEventKind::SessionStarting | LifecycleEventKind::SessionStarted => {
            Some(ClaudeHookEvent::SessionStart)
        }
        LifecycleEventKind::FrameOpening => Some(ClaudeHookEvent::UserPromptSubmit),
        LifecycleEventKind::ContextPressureObserved => Some(ClaudeHookEvent::PreCompact),
        LifecycleEventKind::FrameEnding | LifecycleEventKind::FrameEnded => {
            Some(ClaudeHookEvent::Stop)
        }
        LifecycleEventKind::SessionEnding | LifecycleEventKind::SessionEnded => {
            Some(ClaudeHookEvent::SessionEnd)
        }
        LifecycleEventKind::FrameOpened
        | LifecycleEventKind::ContextCompacted
        | LifecycleEventKind::SupervisorTick
        | LifecycleEventKind::CapabilityDegraded
        | LifecycleEventKind::ReceiptEmitted
        | LifecycleEventKind::ReceiptGapDetected => None,
    }
}

pub(crate) fn render(req: &RenderRequest<'_>) -> Result<RenderedHookPayload, RenderError> {
    let Some(event) = claude_hook_event_for(req.event) else {
        return Err(RenderError::UnsupportedEvent {
            adapter: ProtocolAdapter::Claude,
            event: req.event,
        });
    };
    let body = match event {
        ClaudeHookEvent::SessionStart => session_start_payload(req),
        ClaudeHookEvent::UserPromptSubmit => user_prompt_submit_payload(req),
        ClaudeHookEvent::Stop | ClaudeHookEvent::PreCompact | ClaudeHookEvent::SessionEnd => {
            // Quiet-by-default. The frame-admission directive does not
            // currently apply to these events under Claude's hook
            // protocol; a future Lifeloop revision may map a `Block`
            // directive to a Stop continuation request.
            json!({})
        }
    };
    Ok(RenderedHookPayload {
        hook_event_name: Some(event.as_str()),
        body,
    })
}

fn session_start_payload(req: &RenderRequest<'_>) -> Value {
    json!({
        "hookSpecificOutput": {
            "hookEventName": ClaudeHookEvent::SessionStart.as_str(),
            "additionalContext": render_additional_context(req.payloads),
        }
    })
}

fn user_prompt_submit_payload(req: &RenderRequest<'_>) -> Value {
    let mut payload = json!({
        "hookSpecificOutput": {
            "hookEventName": ClaudeHookEvent::UserPromptSubmit.as_str(),
            "additionalContext": render_additional_context(req.payloads),
        }
    });

    if let Some(FrameAdmissionDirective::Block { reason }) = req.directive {
        let obj = payload
            .as_object_mut()
            .expect("user_prompt_submit payload is a json object");
        // Per Claude Code's hooks contract, `decision` and `reason`
        // sit at the top level (not nested in `hookSpecificOutput`).
        obj.insert("decision".to_string(), Value::String("block".to_string()));
        obj.insert("reason".to_string(), Value::String(reason.clone()));
    }

    payload
}