Skip to main content

lifeloop/protocol/
mod.rs

1//! Hook-protocol payload rendering for harness adapters.
2//!
3//! Lifeloop owns the translation from neutral lifecycle inputs (event kind,
4//! adapter identity, integration mode, frame context, opaque payload
5//! envelopes, placement classes, frame-admission directives) into the
6//! adapter-shaped JSON the harness's hook protocol consumes on stdout.
7//!
8//! # Boundary (issue #3)
9//!
10//! This module owns:
11//! * mapping a [`LifecycleEventKind`] plus an adapter id to the harness's
12//!   own hook event name (e.g. Claude's `SessionStart`, Codex's
13//!   `UserPromptSubmit`),
14//! * rendering the per-event JSON payload with optional contextual
15//!   payloads and frame-admission directives,
16//! * the neutral [`FrameAdmissionDirective`] vocabulary that lets a client
17//!   tell a harness "block the next input" or "request a continuation"
18//!   without naming any client-specific session policy.
19//!
20//! This module does **not** own:
21//! * the meaning of those directives (a client decides when to block,
22//!   when to allow);
23//! * memory, recall, promotion, compaction, or any other client
24//!   continuity vocabulary;
25//! * receipt emission (callers wrap the rendered payload in their own
26//!   receipt flow);
27//! * filesystem IO, hook registration, or asset installation (issue #4
28//!   `host_assets` owns that),
29//! * adapter manifest negotiation (issue #6).
30//!
31//! # Compatibility labels
32//!
33//! Hook event names like `"SessionStart"`, `"UserPromptSubmit"`,
34//! `"PreCompact"`, `"Stop"`, `"SessionEnd"` are **harness-defined wire
35//! tokens**, not Lifeloop semantics. They appear here only because the
36//! harness's hook protocol contract requires them as the
37//! `hookSpecificOutput.hookEventName` value. They are documented here
38//! analogously to the `CCD_COMPAT_*` pattern in [`crate::host_assets`]:
39//! external-vocabulary tokens grouped in one auditable place.
40//!
41//! # Format-agnostic rendering
42//!
43//! The payload body is produced as `serde_json::Value` because the
44//! current harness hook protocols (Claude Code, Codex) consume JSON on
45//! stdout. The renderer entry point also exposes a string form
46//! ([`RenderedHookPayload::body_string`]) for direct stdout emission.
47
48use crate::{FrameContext, IntegrationMode, LifecycleEventKind, PayloadEnvelope, PlacementClass};
49use serde::{Deserialize, Serialize};
50use serde_json::Value;
51
52mod claude;
53mod codex;
54
55pub use claude::{ClaudeHookEvent, claude_hook_event_for};
56pub use codex::{CodexHookEvent, codex_hook_event_for};
57
58// ============================================================================
59// Neutral inputs
60// ============================================================================
61
62/// Adapter targeted by a render. Renderer dispatch is keyed on this.
63///
64/// The wire string mirrors the canonical adapter id used elsewhere
65/// (`AdapterManifest::adapter_id`, [`crate::host_assets::HostAdapter`]).
66#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum ProtocolAdapter {
69    Claude,
70    Codex,
71}
72
73impl ProtocolAdapter {
74    pub const ALL: &'static [Self] = &[Self::Claude, Self::Codex];
75
76    pub fn as_str(self) -> &'static str {
77        match self {
78            Self::Claude => "claude",
79            Self::Codex => "codex",
80        }
81    }
82
83    /// Recognizes the canonical adapter id; returns `None` for unknown
84    /// names. Aliases used by host-asset rendering (e.g. `claude-code`)
85    /// are accepted so callers can pass through whatever the harness
86    /// reports.
87    pub fn from_id(value: &str) -> Option<Self> {
88        match value {
89            "claude" | "claude-code" => Some(Self::Claude),
90            "codex" => Some(Self::Codex),
91            _ => None,
92        }
93    }
94}
95
96/// Neutral directive a client can attach to a render request to ask the
97/// harness to admit, block, or request continuation of the next
98/// lifecycle moment.
99///
100/// This vocabulary is intentionally policy-free: it carries the
101/// transport intent (allow/block) and a free-form `reason` the harness
102/// surfaces to the model. Clients decide *why* to block; Lifeloop only
103/// transports the directive.
104#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
105#[serde(tag = "kind", rename_all = "snake_case")]
106pub enum FrameAdmissionDirective {
107    /// Default: do nothing, let the harness proceed.
108    Allow,
109    /// Ask the harness to block the next input/turn and surface `reason`
110    /// to the model. On harnesses where `block` means "request a
111    /// continuation prompt" (Codex `Stop`), the same shape applies.
112    Block { reason: String },
113}
114
115impl FrameAdmissionDirective {
116    pub fn allow() -> Self {
117        Self::Allow
118    }
119
120    pub fn block(reason: impl Into<String>) -> Self {
121        Self::Block {
122            reason: reason.into(),
123        }
124    }
125
126    pub fn is_block(&self) -> bool {
127        matches!(self, Self::Block { .. })
128    }
129}
130
131/// One opaque contextual payload to render into the harness's
132/// per-event payload slot (e.g. Claude's `additionalContext`).
133///
134/// Lifeloop does not parse or rewrite the payload body — it is
135/// delivered as the client supplied it. The `placement` field is the
136/// trust-neutral routing class the client requested for this payload;
137/// the renderer uses it only to decide which payload slot the body goes
138/// into and to skip payloads whose placement is not appropriate for
139/// the target event.
140#[derive(Clone, Debug)]
141pub struct ProtocolPayload<'a> {
142    pub envelope: &'a PayloadEnvelope,
143    pub placement: PlacementClass,
144}
145
146impl<'a> ProtocolPayload<'a> {
147    pub fn new(envelope: &'a PayloadEnvelope, placement: PlacementClass) -> Self {
148        Self {
149            envelope,
150            placement,
151        }
152    }
153}
154
155/// Inputs for a hook-protocol render.
156///
157/// `adapter_id` and `adapter_version` are passed as neutral strings so
158/// the renderer can be invoked without a manifest registry lookup;
159/// callers that have a [`crate::AdapterManifest`] in hand can pass its
160/// fields directly. `integration_mode` and `frame` are accepted but
161/// currently advisory — they enable later renderer variants without
162/// signature churn.
163#[derive(Clone, Debug)]
164pub struct RenderRequest<'a> {
165    pub adapter: ProtocolAdapter,
166    pub adapter_id: &'a str,
167    pub adapter_version: &'a str,
168    pub integration_mode: IntegrationMode,
169    pub event: LifecycleEventKind,
170    pub frame: Option<&'a FrameContext>,
171    pub payloads: &'a [ProtocolPayload<'a>],
172    pub directive: Option<&'a FrameAdmissionDirective>,
173}
174
175impl<'a> RenderRequest<'a> {
176    /// Convenience constructor for the common no-payload, no-directive case.
177    pub fn minimal(
178        adapter: ProtocolAdapter,
179        adapter_id: &'a str,
180        adapter_version: &'a str,
181        integration_mode: IntegrationMode,
182        event: LifecycleEventKind,
183    ) -> Self {
184        Self {
185            adapter,
186            adapter_id,
187            adapter_version,
188            integration_mode,
189            event,
190            frame: None,
191            payloads: &[],
192            directive: None,
193        }
194    }
195}
196
197// ============================================================================
198// Output
199// ============================================================================
200
201/// A rendered harness hook-protocol payload, ready to be emitted on
202/// the harness's hook stdout.
203#[derive(Clone, Debug, Eq, PartialEq)]
204pub struct RenderedHookPayload {
205    /// The harness's wire token for the event (e.g. Claude's
206    /// `SessionStart`). May be `None` for events the adapter does not
207    /// surface a hook payload for under its own protocol.
208    pub hook_event_name: Option<&'static str>,
209    /// The JSON payload body the harness expects on stdout. For events
210    /// the adapter does not carry, this is `{}` (the harness's
211    /// quiet-default contract).
212    pub body: Value,
213}
214
215impl RenderedHookPayload {
216    /// Compact JSON suitable for emission on stdout.
217    pub fn body_string(&self) -> String {
218        serde_json::to_string(&self.body).unwrap_or_else(|_| "{}".to_string())
219    }
220}
221
222// ============================================================================
223// Render errors
224// ============================================================================
225
226/// Why a hook-protocol render failed.
227#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
228#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
229pub enum RenderError {
230    /// The adapter does not surface a hook payload for the requested
231    /// lifecycle event under its native hook protocol.
232    UnsupportedEvent {
233        adapter: ProtocolAdapter,
234        event: LifecycleEventKind,
235    },
236    /// The adapter id passed in does not match the requested adapter.
237    /// Renderer is paranoid about this so a client passing the wrong
238    /// adapter manifest cannot silently produce a Claude payload while
239    /// claiming Codex.
240    AdapterIdMismatch {
241        adapter: ProtocolAdapter,
242        adapter_id: String,
243    },
244    /// The frame-admission directive is invalid (e.g. a `Block` with an
245    /// empty `reason`).
246    InvalidDirective(String),
247}
248
249impl std::fmt::Display for RenderError {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        match self {
252            Self::UnsupportedEvent { adapter, event } => {
253                write!(
254                    f,
255                    "adapter `{}` does not surface a hook payload for lifecycle event `{:?}`",
256                    adapter.as_str(),
257                    event
258                )
259            }
260            Self::AdapterIdMismatch {
261                adapter,
262                adapter_id,
263            } => write!(
264                f,
265                "adapter id `{}` does not match requested adapter `{}`",
266                adapter_id,
267                adapter.as_str()
268            ),
269            Self::InvalidDirective(msg) => write!(f, "invalid frame-admission directive: {msg}"),
270        }
271    }
272}
273
274impl std::error::Error for RenderError {}
275
276// ============================================================================
277// Entry point
278// ============================================================================
279
280/// Render a harness hook-protocol payload from neutral lifecycle inputs.
281///
282/// Dispatches on [`RenderRequest::adapter`]. Returns
283/// [`RenderError::UnsupportedEvent`] when the adapter's hook protocol
284/// does not surface a payload for the requested
285/// [`LifecycleEventKind`]; callers in routing layers may treat that as
286/// "skip emission" rather than an error. The future router (issue #7)
287/// is the expected primary caller of this entry point.
288pub fn render_hook_payload(req: &RenderRequest<'_>) -> Result<RenderedHookPayload, RenderError> {
289    if let Some(d) = req.directive {
290        validate_directive(d)?;
291    }
292    if !matches_adapter_id(req.adapter, req.adapter_id) {
293        return Err(RenderError::AdapterIdMismatch {
294            adapter: req.adapter,
295            adapter_id: req.adapter_id.to_string(),
296        });
297    }
298    match req.adapter {
299        ProtocolAdapter::Claude => claude::render(req),
300        ProtocolAdapter::Codex => codex::render(req),
301    }
302}
303
304fn validate_directive(d: &FrameAdmissionDirective) -> Result<(), RenderError> {
305    if let FrameAdmissionDirective::Block { reason } = d
306        && reason.trim().is_empty()
307    {
308        return Err(RenderError::InvalidDirective(
309            "Block directive requires a non-empty reason".into(),
310        ));
311    }
312    Ok(())
313}
314
315fn matches_adapter_id(adapter: ProtocolAdapter, id: &str) -> bool {
316    ProtocolAdapter::from_id(id) == Some(adapter)
317}
318
319// ============================================================================
320// Shared rendering helpers (used by claude.rs and codex.rs)
321// ============================================================================
322
323/// The set of payload placements that flow into the per-event
324/// "additional context" slot the Claude/Codex hook protocols expose
325/// inside `hookSpecificOutput.additionalContext`.
326///
327/// This is a routing decision local to the renderer: payloads whose
328/// placement is one of these classes are concatenated into the
329/// additional-context slot; others are ignored by the renderer (they
330/// flow through other Lifeloop transports).
331pub(crate) fn placement_targets_pre_prompt(p: PlacementClass) -> bool {
332    matches!(
333        p,
334        PlacementClass::PrePromptFrame | PlacementClass::DeveloperEquivalentFrame
335    )
336}
337
338/// Build the harness `additionalContext` string from the eligible
339/// payloads in the request.
340///
341/// # Lifeloop transport envelope
342///
343/// For harness hook protocols (Claude Code, Codex) that surface a
344/// single `hookSpecificOutput.additionalContext` slot per event,
345/// Lifeloop renders a transport envelope of the form:
346///
347/// ```json
348/// {
349///   "payloads": [
350///     { "payload_id": "...", "payload_kind": "...", "body": "<verbatim string>" },
351///     { "payload_id": "...", "payload_kind": "...", "body_ref": "..." }
352///   ]
353/// }
354/// ```
355///
356/// Each payload whose [`PlacementClass`] passes
357/// [`placement_targets_pre_prompt`] becomes exactly one object in the
358/// `payloads` array, in input order.
359///
360/// # Body opacity (issue #21, spec body.md line 385)
361///
362/// `body` and `body_ref` are passed through verbatim:
363///
364/// * If [`PayloadEnvelope::body`] is `Some(s)`, it is emitted as a
365///   JSON **string** under the key `body`. Lifeloop never calls
366///   `serde_json::from_str` on it — a body that happens to be a JSON
367///   object literal is carried as a string, preserving opacity and
368///   keeping overlapping JSON keys across payloads distinguishable.
369/// * Else if [`PayloadEnvelope::body_ref`] is `Some(r)`, it is emitted
370///   under the key `body_ref` and is never dereferenced here.
371/// * If both are `None`, the payload is skipped entirely (no eligible
372///   body to transport).
373///
374/// `payloads` is the only Lifeloop-reserved key in the wrapper.
375///
376/// If no eligible payloads are present, returns the literal `"{}"` to
377/// match the harness's quiet-default empty-context shape.
378pub(crate) fn render_additional_context(payloads: &[ProtocolPayload<'_>]) -> String {
379    let mut entries: Vec<Value> = Vec::new();
380
381    for p in payloads {
382        if !placement_targets_pre_prompt(p.placement) {
383            continue;
384        }
385        let mut entry = serde_json::Map::new();
386        entry.insert(
387            "payload_id".to_string(),
388            Value::String(p.envelope.payload_id.clone()),
389        );
390        entry.insert(
391            "payload_kind".to_string(),
392            Value::String(p.envelope.payload_kind.clone()),
393        );
394        match (&p.envelope.body, &p.envelope.body_ref) {
395            (Some(b), _) => {
396                // Verbatim body string. Never parsed — preserves opacity
397                // and keeps overlapping keys across payloads distinguishable.
398                entry.insert("body".to_string(), Value::String(b.clone()));
399            }
400            (None, Some(r)) => {
401                entry.insert("body_ref".to_string(), Value::String(r.clone()));
402            }
403            (None, None) => continue,
404        }
405        entries.push(Value::Object(entry));
406    }
407
408    if entries.is_empty() {
409        return "{}".to_string();
410    }
411
412    let mut wrapper = serde_json::Map::new();
413    wrapper.insert("payloads".to_string(), Value::Array(entries));
414    serde_json::to_string_pretty(&Value::Object(wrapper)).unwrap_or_else(|_| "{}".to_string())
415}