lifeloop-cli 0.5.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Lifecycle integration profile data and command-prefix helpers.

use serde_json::Value;

const LEGACY_CCD_COMPAT_CODEX_GIT_COMMAND_PREFIX: &str = "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --hook ";

// Tombstones for the removed `ccd-renewal` profile. The profile itself is
// gone, but installs that previously rendered it carry managed hook entries
// with these command prefixes. Keeping them in `ccd-compat`'s scrub lists
// lets `asset preview/apply` of `ccd-compat` over a legacy `ccd-renewal`
// install detect those entries as managed (not user-owned) and remove them,
// instead of orphaning stale `on-agent-end` / duplicate callback hooks.
const TOMBSTONE_CCD_RENEWAL_CODEX_PREFIX: &str = "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"${LIFELOOP_WORKSPACE_DIR:-${CODEX_PROJECT_DIR:-$PWD}}\" --host codex --client-cmd \"${CCD_BIN:-ccd}\" --hook ";
const TOMBSTONE_CCD_RENEWAL_CODEX_GIT_PREFIX: &str = "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --client-cmd \"${CCD_BIN:-ccd}\" --hook ";
const TOMBSTONE_CCD_RENEWAL_CLAUDE_PREFIX: &str = "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --client-cmd \"${CCD_BIN:-ccd}\" --hook ";

const CCD_COMPAT_CODEX_LEGACY_PREFIXES: &[&str] = &[
    LEGACY_CCD_COMPAT_CODEX_GIT_COMMAND_PREFIX,
    TOMBSTONE_CCD_RENEWAL_CODEX_PREFIX,
    TOMBSTONE_CCD_RENEWAL_CODEX_GIT_PREFIX,
];

// ============================================================================
// Lifecycle integration profiles
// ============================================================================
//
// A `LifecycleProfile` captures the per-client-profile facts that vary
// between integration profiles: per-host command prefixes, the legacy
// substrings the merge logic should scrub for that profile, and the
// managed event tables Lifeloop installs into each host's hook config
// for that profile. The renderers and merge logic consult a profile
// rather than hardcoding any one client's binary or command prefix,
// so adding a new profile does not require editing core merge logic.
// See the module rustdoc for the slimdown narrative this enables.

/// Per-client-profile data driving lifecycle integration asset
/// rendering and merge.
///
/// This struct expresses the client-shape of a host integration
/// profile (e.g. CCD compatibility) without pulling client semantics
/// into core types. It is a pure data surface: every field is
/// `'static` and the methods are pure functions of those fields.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct LifecycleProfile {
    /// Stable profile identifier (e.g. `"ccd-compat"`). Used in
    /// diagnostics; not part of the rendered asset content.
    pub id: &'static str,
    /// Command prefix Lifeloop renders into `.claude/settings.json`
    /// for managed hook entries. The merge logic uses it as a
    /// managed-entry marker (it scrubs entries whose `command`
    /// starts with this prefix and rewrites them).
    pub claude_command_prefix: &'static str,
    /// Substrings inside `.claude/settings.json` `command` strings
    /// that the merge logic also treats as managed (legacy/pre-v1
    /// forms whose shape changed across releases). Always merged
    /// WITH the prefix scrub, never replacing it. Empty when the
    /// profile has no legacy shape to scrub.
    pub claude_legacy_substrings: &'static [&'static str],
    /// `(claude_event, hook_arg, matcher_pattern)` tuples this
    /// profile installs into Claude's hook config.
    pub claude_managed_events: &'static [(&'static str, &'static str, &'static str)],
    /// Command prefix Lifeloop renders into `.codex/hooks.json` for
    /// managed hook entries. Merge logic scrubs entries whose
    /// `command` starts with it.
    pub codex_command_prefix: &'static str,
    /// `(codex_event, hook_arg, matcher_pattern, status_message)`
    /// tuples this profile installs into Codex's hook config.
    pub codex_managed_events: &'static [(&'static str, &'static str, &'static str, &'static str)],
}

impl LifecycleProfile {
    pub fn validate(&self) -> Result<(), &'static str> {
        if self.id.is_empty() {
            return Err("profile id must not be empty");
        }
        if self.claude_command_prefix.is_empty() {
            return Err("claude command prefix must not be empty");
        }
        if self.codex_command_prefix.is_empty() {
            return Err("codex command prefix must not be empty");
        }
        if self
            .claude_legacy_substrings
            .iter()
            .any(|legacy| legacy.is_empty())
        {
            return Err("claude legacy substrings must not be empty");
        }
        Ok(())
    }

    /// Render this profile's `.claude/settings.json` hook command for
    /// `hook_arg`.
    pub fn claude_command(&self, hook_arg: &str) -> String {
        format!("{}{}", self.claude_command_prefix, hook_arg)
    }

    /// Render this profile's `.codex/hooks.json` hook command for
    /// `hook_arg`.
    pub fn codex_command(&self, hook_arg: &str) -> String {
        format!("{}{}", self.codex_command_prefix, hook_arg)
    }

    /// True when `entry` is recognized as a managed `.claude/settings.json`
    /// hook for this profile — either the modern command prefix or any
    /// of `claude_legacy_substrings`. Used by the merge logic to scrub
    /// stale managed entries before rewriting them.
    pub(super) fn claude_entry_is_managed_or_legacy(&self, entry: &Value) -> bool {
        let cmd = entry.get("command").and_then(Value::as_str).unwrap_or("");
        (!self.claude_command_prefix.is_empty() && cmd.starts_with(self.claude_command_prefix))
            || self
                .claude_legacy_substrings
                .iter()
                .any(|legacy| !legacy.is_empty() && cmd.contains(legacy))
    }

    /// True when `entry` is recognized as a managed `.codex/hooks.json`
    /// hook for this profile.
    pub(super) fn codex_entry_is_managed(&self, entry: &Value) -> bool {
        entry
            .get("command")
            .and_then(Value::as_str)
            .map(|cmd| {
                !self.codex_command_prefix.is_empty() && cmd.starts_with(self.codex_command_prefix)
                    || self
                        .codex_legacy_command_prefixes()
                        .iter()
                        .any(|legacy| cmd.starts_with(legacy))
            })
            .unwrap_or(false)
    }

    fn codex_legacy_command_prefixes(&self) -> &'static [&'static str] {
        match self.id {
            "ccd-compat" => CCD_COMPAT_CODEX_LEGACY_PREFIXES,
            _ => &[],
        }
    }
}

// ----------------------------------------------------------------------------
// Shared event tables
// ----------------------------------------------------------------------------
//
// These tables describe the lifecycle events Lifeloop installs into a
// host's hook config. They are shared across profiles because the
// lifecycle event vocabulary is harness-defined, not client-defined —
// what varies across profiles is the *command prefix* that wraps each
// event's hook arg, not the (event, hook arg, matcher) triple. A
// future profile that needs to skip an event or use a different hook
// arg can simply ship its own table.

/// (claude_event, hook_arg, matcher_pattern). `TaskCompleted` is
/// intentionally excluded — only `Stop` fires reliably at end-of-turn
/// in Claude's hook protocol.
const STANDARD_CLAUDE_MANAGED_EVENTS: &[(&str, &str, &str)] = &[
    (
        "SessionStart",
        "on-session-start",
        "startup|resume|clear|compact",
    ),
    ("UserPromptSubmit", "before-prompt-build", "*"),
    ("PreCompact", "on-compaction-notice", "*"),
    ("Stop", "on-agent-end", "*"),
    ("SessionEnd", "on-session-end", "*"),
];

/// (codex_event, hook_arg, matcher_pattern, status_message). Codex exposes
/// `PreCompact` and `PostCompact`; unlike Claude, it does not expose
/// `SessionEnd`.
const STANDARD_CODEX_MANAGED_EVENTS: &[(&str, &str, &str, &str)] = &[
    (
        "SessionStart",
        "on-session-start",
        "startup|resume|clear",
        "Loading CCD session context",
    ),
    (
        "UserPromptSubmit",
        "before-prompt-build",
        "*",
        "Refreshing CCD prompt context",
    ),
    (
        "PreCompact",
        "on-compaction-notice",
        "*",
        "Recording CCD compaction boundary",
    ),
    (
        "PostCompact",
        "on-compaction-notice",
        "*",
        "Recording CCD compacted context boundary",
    ),
    (
        "Stop",
        "on-agent-end",
        "*",
        "Checking CCD continuation boundary",
    ),
];

// ----------------------------------------------------------------------------
// Built-in profiles
// ----------------------------------------------------------------------------

/// CCD compatibility profile: the harness invokes `${CCD_BIN:-ccd}
/// host-hook ...` and CCD acts as the broker that calls back into
/// Lifeloop. This is Lifeloop's first client and its current
/// production install shape.
pub const CCD_COMPAT_PROFILE: LifecycleProfile = LifecycleProfile {
    id: "ccd-compat",
    claude_command_prefix: "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook ",
    // "ccd-hook.py" is the legacy python-hook tombstone; the ccd-renewal
    // prefix is the removed-profile tombstone (see comment at top of file).
    claude_legacy_substrings: &["ccd-hook.py", TOMBSTONE_CCD_RENEWAL_CLAUDE_PREFIX],
    claude_managed_events: STANDARD_CLAUDE_MANAGED_EVENTS,
    codex_command_prefix: "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"${LIFELOOP_WORKSPACE_DIR:-${CODEX_PROJECT_DIR:-$PWD}}\" --host codex --hook ",
    codex_managed_events: STANDARD_CODEX_MANAGED_EVENTS,
};

// ----------------------------------------------------------------------------
// CCD-compat back-compat aliases
// ----------------------------------------------------------------------------
//
// The constants and helper below name the CCD-compat profile's command
// prefixes directly, delegating to `CCD_COMPAT_PROFILE`. They exist for
// the host-asset tests, which assert rendered hook commands start with
// these prefixes.

/// Command prefix Lifeloop renders into `.claude/settings.json` for
/// CCD-managed hook entries. Equal to
/// [`CCD_COMPAT_PROFILE`]`.claude_command_prefix`.
pub const CCD_COMPAT_CLAUDE_COMMAND_PREFIX: &str = CCD_COMPAT_PROFILE.claude_command_prefix;

/// Command prefix Lifeloop renders into `.codex/hooks.json` for
/// CCD-managed hook entries. Equal to
/// [`CCD_COMPAT_PROFILE`]`.codex_command_prefix`.
pub const CCD_COMPAT_CODEX_COMMAND_PREFIX: &str = CCD_COMPAT_PROFILE.codex_command_prefix;

/// Render a CCD-compat `.claude/settings.json` hook command for `hook_arg`.
pub fn ccd_compat_claude_command(hook_arg: &str) -> String {
    CCD_COMPAT_PROFILE.claude_command(hook_arg)
}