lifeloop-cli 0.2.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
//! Hook-protocol payload rendering for harness adapters.
//!
//! Lifeloop owns the translation from neutral lifecycle inputs (event kind,
//! adapter identity, integration mode, frame context, opaque payload
//! envelopes, placement classes, frame-admission directives) into the
//! adapter-shaped JSON the harness's hook protocol consumes on stdout.
//!
//! # Boundary (issue #3)
//!
//! This module owns:
//! * mapping a [`LifecycleEventKind`] plus an adapter id to the harness's
//!   own hook event name (e.g. Claude's `SessionStart`, Codex's
//!   `UserPromptSubmit`),
//! * rendering the per-event JSON payload with optional contextual
//!   payloads and frame-admission directives,
//! * the neutral [`FrameAdmissionDirective`] vocabulary that lets a client
//!   tell a harness "block the next input" or "request a continuation"
//!   without naming any client-specific session policy.
//!
//! This module does **not** own:
//! * the meaning of those directives (a client decides when to block,
//!   when to allow);
//! * memory, recall, promotion, compaction, or any other client
//!   continuity vocabulary;
//! * receipt emission (callers wrap the rendered payload in their own
//!   receipt flow);
//! * filesystem IO, hook registration, or asset installation (issue #4
//!   `host_assets` owns that),
//! * adapter manifest negotiation (issue #6).
//!
//! # Compatibility labels
//!
//! Hook event names like `"SessionStart"`, `"UserPromptSubmit"`,
//! `"PreCompact"`, `"Stop"`, `"SessionEnd"` are **harness-defined wire
//! tokens**, not Lifeloop semantics. They appear here only because the
//! harness's hook protocol contract requires them as the
//! `hookSpecificOutput.hookEventName` value. They are documented here
//! analogously to the `CCD_COMPAT_*` pattern in [`crate::host_assets`]:
//! external-vocabulary tokens grouped in one auditable place.
//!
//! # Format-agnostic rendering
//!
//! The payload body is produced as `serde_json::Value` because the
//! current harness hook protocols (Claude Code, Codex) consume JSON on
//! stdout. The renderer entry point also exposes a string form
//! ([`RenderedHookPayload::body_string`]) for direct stdout emission.

use crate::{FrameContext, IntegrationMode, LifecycleEventKind, PayloadEnvelope, PlacementClass};
use serde::{Deserialize, Serialize};
use serde_json::Value;

mod claude;
mod codex;

pub use claude::{ClaudeHookEvent, claude_hook_event_for};
pub use codex::{CodexHookEvent, codex_hook_event_for};

// ============================================================================
// Neutral inputs
// ============================================================================

/// Adapter targeted by a render. Renderer dispatch is keyed on this.
///
/// The wire string mirrors the canonical adapter id used elsewhere
/// (`AdapterManifest::adapter_id`, [`crate::host_assets::HostAdapter`]).
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProtocolAdapter {
    Claude,
    Codex,
}

impl ProtocolAdapter {
    pub const ALL: &'static [Self] = &[Self::Claude, Self::Codex];

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Claude => "claude",
            Self::Codex => "codex",
        }
    }

    /// Recognizes the canonical adapter id; returns `None` for unknown
    /// names. Aliases used by host-asset rendering (e.g. `claude-code`)
    /// are accepted so callers can pass through whatever the harness
    /// reports.
    pub fn from_id(value: &str) -> Option<Self> {
        match value {
            "claude" | "claude-code" => Some(Self::Claude),
            "codex" => Some(Self::Codex),
            _ => None,
        }
    }
}

/// Neutral directive a client can attach to a render request to ask the
/// harness to admit, block, or request continuation of the next
/// lifecycle moment.
///
/// This vocabulary is intentionally policy-free: it carries the
/// transport intent (allow/block) and a free-form `reason` the harness
/// surfaces to the model. Clients decide *why* to block; Lifeloop only
/// transports the directive.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum FrameAdmissionDirective {
    /// Default: do nothing, let the harness proceed.
    Allow,
    /// Ask the harness to block the next input/turn and surface `reason`
    /// to the model. On harnesses where `block` means "request a
    /// continuation prompt" (Codex `Stop`), the same shape applies.
    Block { reason: String },
}

impl FrameAdmissionDirective {
    pub fn allow() -> Self {
        Self::Allow
    }

    pub fn block(reason: impl Into<String>) -> Self {
        Self::Block {
            reason: reason.into(),
        }
    }

    pub fn is_block(&self) -> bool {
        matches!(self, Self::Block { .. })
    }
}

/// One opaque contextual payload to render into the harness's
/// per-event payload slot (e.g. Claude's `additionalContext`).
///
/// Lifeloop does not parse or rewrite the payload body — it is
/// delivered as the client supplied it. The `placement` field is the
/// trust-neutral routing class the client requested for this payload;
/// the renderer uses it only to decide which payload slot the body goes
/// into and to skip payloads whose placement is not appropriate for
/// the target event.
#[derive(Clone, Debug)]
pub struct ProtocolPayload<'a> {
    pub envelope: &'a PayloadEnvelope,
    pub placement: PlacementClass,
}

impl<'a> ProtocolPayload<'a> {
    pub fn new(envelope: &'a PayloadEnvelope, placement: PlacementClass) -> Self {
        Self {
            envelope,
            placement,
        }
    }
}

/// Inputs for a hook-protocol render.
///
/// `adapter_id` and `adapter_version` are passed as neutral strings so
/// the renderer can be invoked without a manifest registry lookup;
/// callers that have a [`crate::AdapterManifest`] in hand can pass its
/// fields directly. `integration_mode` and `frame` are accepted but
/// currently advisory — they enable later renderer variants without
/// signature churn.
#[derive(Clone, Debug)]
pub struct RenderRequest<'a> {
    pub adapter: ProtocolAdapter,
    pub adapter_id: &'a str,
    pub adapter_version: &'a str,
    pub integration_mode: IntegrationMode,
    pub event: LifecycleEventKind,
    pub frame: Option<&'a FrameContext>,
    pub payloads: &'a [ProtocolPayload<'a>],
    pub directive: Option<&'a FrameAdmissionDirective>,
}

impl<'a> RenderRequest<'a> {
    /// Convenience constructor for the common no-payload, no-directive case.
    pub fn minimal(
        adapter: ProtocolAdapter,
        adapter_id: &'a str,
        adapter_version: &'a str,
        integration_mode: IntegrationMode,
        event: LifecycleEventKind,
    ) -> Self {
        Self {
            adapter,
            adapter_id,
            adapter_version,
            integration_mode,
            event,
            frame: None,
            payloads: &[],
            directive: None,
        }
    }
}

// ============================================================================
// Output
// ============================================================================

/// A rendered harness hook-protocol payload, ready to be emitted on
/// the harness's hook stdout.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RenderedHookPayload {
    /// The harness's wire token for the event (e.g. Claude's
    /// `SessionStart`). May be `None` for events the adapter does not
    /// surface a hook payload for under its own protocol.
    pub hook_event_name: Option<&'static str>,
    /// The JSON payload body the harness expects on stdout. For events
    /// the adapter does not carry, this is `{}` (the harness's
    /// quiet-default contract).
    pub body: Value,
}

impl RenderedHookPayload {
    /// Compact JSON suitable for emission on stdout.
    pub fn body_string(&self) -> String {
        serde_json::to_string(&self.body).unwrap_or_else(|_| "{}".to_string())
    }
}

// ============================================================================
// Render errors
// ============================================================================

/// Why a hook-protocol render failed.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
pub enum RenderError {
    /// The adapter does not surface a hook payload for the requested
    /// lifecycle event under its native hook protocol.
    UnsupportedEvent {
        adapter: ProtocolAdapter,
        event: LifecycleEventKind,
    },
    /// The adapter id passed in does not match the requested adapter.
    /// Renderer is paranoid about this so a client passing the wrong
    /// adapter manifest cannot silently produce a Claude payload while
    /// claiming Codex.
    AdapterIdMismatch {
        adapter: ProtocolAdapter,
        adapter_id: String,
    },
    /// The frame-admission directive is invalid (e.g. a `Block` with an
    /// empty `reason`).
    InvalidDirective(String),
}

impl std::fmt::Display for RenderError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::UnsupportedEvent { adapter, event } => {
                write!(
                    f,
                    "adapter `{}` does not surface a hook payload for lifecycle event `{:?}`",
                    adapter.as_str(),
                    event
                )
            }
            Self::AdapterIdMismatch {
                adapter,
                adapter_id,
            } => write!(
                f,
                "adapter id `{}` does not match requested adapter `{}`",
                adapter_id,
                adapter.as_str()
            ),
            Self::InvalidDirective(msg) => write!(f, "invalid frame-admission directive: {msg}"),
        }
    }
}

impl std::error::Error for RenderError {}

// ============================================================================
// Entry point
// ============================================================================

/// Render a harness hook-protocol payload from neutral lifecycle inputs.
///
/// Dispatches on [`RenderRequest::adapter`]. Returns
/// [`RenderError::UnsupportedEvent`] when the adapter's hook protocol
/// does not surface a payload for the requested
/// [`LifecycleEventKind`]; callers in routing layers may treat that as
/// "skip emission" rather than an error. The future router (issue #7)
/// is the expected primary caller of this entry point.
pub fn render_hook_payload(req: &RenderRequest<'_>) -> Result<RenderedHookPayload, RenderError> {
    if let Some(d) = req.directive {
        validate_directive(d)?;
    }
    if !matches_adapter_id(req.adapter, req.adapter_id) {
        return Err(RenderError::AdapterIdMismatch {
            adapter: req.adapter,
            adapter_id: req.adapter_id.to_string(),
        });
    }
    match req.adapter {
        ProtocolAdapter::Claude => claude::render(req),
        ProtocolAdapter::Codex => codex::render(req),
    }
}

fn validate_directive(d: &FrameAdmissionDirective) -> Result<(), RenderError> {
    if let FrameAdmissionDirective::Block { reason } = d
        && reason.trim().is_empty()
    {
        return Err(RenderError::InvalidDirective(
            "Block directive requires a non-empty reason".into(),
        ));
    }
    Ok(())
}

fn matches_adapter_id(adapter: ProtocolAdapter, id: &str) -> bool {
    ProtocolAdapter::from_id(id) == Some(adapter)
}

// ============================================================================
// Shared rendering helpers (used by claude.rs and codex.rs)
// ============================================================================

/// The set of payload placements that flow into the per-event
/// "additional context" slot the Claude/Codex hook protocols expose
/// inside `hookSpecificOutput.additionalContext`.
///
/// This is a routing decision local to the renderer: payloads whose
/// placement is one of these classes are concatenated into the
/// additional-context slot; others are ignored by the renderer (they
/// flow through other Lifeloop transports).
pub(crate) fn placement_targets_pre_prompt(p: PlacementClass) -> bool {
    matches!(
        p,
        PlacementClass::PrePromptFrame | PlacementClass::DeveloperEquivalentFrame
    )
}

/// Build the harness `additionalContext` string from the eligible
/// payloads in the request.
///
/// # Lifeloop transport envelope
///
/// For harness hook protocols (Claude Code, Codex) that surface a
/// single `hookSpecificOutput.additionalContext` slot per event,
/// Lifeloop renders a transport envelope of the form:
///
/// ```json
/// {
///   "payloads": [
///     { "payload_id": "...", "payload_kind": "...", "body": "<verbatim string>" },
///     { "payload_id": "...", "payload_kind": "...", "body_ref": "..." }
///   ]
/// }
/// ```
///
/// Each payload whose [`PlacementClass`] passes
/// [`placement_targets_pre_prompt`] becomes exactly one object in the
/// `payloads` array, in input order.
///
/// # Body opacity (issue #21, spec body.md line 385)
///
/// `body` and `body_ref` are passed through verbatim:
///
/// * If [`PayloadEnvelope::body`] is `Some(s)`, it is emitted as a
///   JSON **string** under the key `body`. Lifeloop never calls
///   `serde_json::from_str` on it — a body that happens to be a JSON
///   object literal is carried as a string, preserving opacity and
///   keeping overlapping JSON keys across payloads distinguishable.
/// * Else if [`PayloadEnvelope::body_ref`] is `Some(r)`, it is emitted
///   under the key `body_ref` and is never dereferenced here.
/// * If both are `None`, the payload is skipped entirely (no eligible
///   body to transport).
///
/// `payloads` is the only Lifeloop-reserved key in the wrapper.
///
/// If no eligible payloads are present, returns the literal `"{}"` to
/// match the harness's quiet-default empty-context shape.
pub(crate) fn render_additional_context(payloads: &[ProtocolPayload<'_>]) -> String {
    let mut entries: Vec<Value> = Vec::new();

    for p in payloads {
        if !placement_targets_pre_prompt(p.placement) {
            continue;
        }
        let mut entry = serde_json::Map::new();
        entry.insert(
            "payload_id".to_string(),
            Value::String(p.envelope.payload_id.clone()),
        );
        entry.insert(
            "payload_kind".to_string(),
            Value::String(p.envelope.payload_kind.clone()),
        );
        match (&p.envelope.body, &p.envelope.body_ref) {
            (Some(b), _) => {
                // Verbatim body string. Never parsed — preserves opacity
                // and keeps overlapping keys across payloads distinguishable.
                entry.insert("body".to_string(), Value::String(b.clone()));
            }
            (None, Some(r)) => {
                entry.insert("body_ref".to_string(), Value::String(r.clone()));
            }
            (None, None) => continue,
        }
        entries.push(Value::Object(entry));
    }

    if entries.is_empty() {
        return "{}".to_string();
    }

    let mut wrapper = serde_json::Map::new();
    wrapper.insert("payloads".to_string(), Value::Array(entries));
    serde_json::to_string_pretty(&Value::Object(wrapper)).unwrap_or_else(|_| "{}".to_string())
}