lifeloop-cli 0.2.0

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

use serde_json::Value;

// ============================================================================
// 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, lifeloop-direct callback) 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"`,
    /// `"lifeloop-direct"`). 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)
            })
            .unwrap_or(false)
    }
}

// ----------------------------------------------------------------------------
// 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's
/// hook surface does not expose `PreCompact` or `SessionEnd`, so the
/// table is shorter than the Claude one.
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",
    ),
];

/// (codex_event, hook_arg, matcher_pattern, status_message) for the
/// post-slimdown lifeloop-direct profile. Status text reads
/// "Lifeloop ..." rather than "CCD ..." so the operator-facing
/// messaging matches the binary actually invoked.
const LIFELOOP_DIRECT_CODEX_MANAGED_EVENTS: &[(&str, &str, &str, &str)] = &[
    (
        "SessionStart",
        "on-session-start",
        "startup|resume|clear",
        "Loading Lifeloop session context",
    ),
    (
        "UserPromptSubmit",
        "before-prompt-build",
        "*",
        "Refreshing Lifeloop prompt context",
    ),
    (
        "Stop",
        "on-agent-end",
        "*",
        "Checking Lifeloop 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 ",
    claude_legacy_substrings: &["ccd-hook.py"],
    claude_managed_events: STANDARD_CLAUDE_MANAGED_EVENTS,
    codex_command_prefix: "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --hook ",
    codex_managed_events: STANDARD_CODEX_MANAGED_EVENTS,
};

/// Lifeloop-direct callback profile: the harness invokes
/// `${LIFELOOP_BIN:-lifeloop} host-hook ...` directly, with no CCD
/// in the loop. This is the post-slimdown shape contemplated by
/// dusk-network/ccd#723 — landing it as a built-in profile lets a
/// non-CCD pilot exercise the full host-asset rendering path before
/// the slimdown work commits to it.
pub const LIFELOOP_DIRECT_PROFILE: LifecycleProfile = LifecycleProfile {
    id: "lifeloop-direct",
    claude_command_prefix: "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook ",
    // The lifeloop-direct profile is the documented successor to the
    // CCD-compat profile (see `docs/decisions/lifecycle-profiles.md`
    // and `docs/release-gates.md` on dusk-network/ccd#723). Treating
    // CCD-compat entries as legacy ensures that an operator who runs
    // a lifeloop-direct merge over an existing CCD-compat
    // settings.json gets a single set of managed hooks in the new
    // shape — not two coexisting sets — which is what "switch
    // profiles" means at the install layer. The pre-v1 Python-bridge
    // substring is also recognized for the same reason. The reverse
    // direction (CCD-compat merge over lifeloop-direct) is
    // intentionally additive, since CCD has no claim to a successor
    // profile's shape; that asymmetry is pinned by tests in
    // `tests/host_assets_profiles.rs`.
    claude_legacy_substrings: &[CCD_COMPAT_PROFILE.claude_command_prefix, "ccd-hook.py"],
    claude_managed_events: STANDARD_CLAUDE_MANAGED_EVENTS,
    codex_command_prefix: "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --hook ",
    codex_managed_events: LIFELOOP_DIRECT_CODEX_MANAGED_EVENTS,
};

// ----------------------------------------------------------------------------
// CCD-compat back-compat aliases
// ----------------------------------------------------------------------------
//
// The constants and helpers below preserve the pre-#26 public API
// while delegating to `CCD_COMPAT_PROFILE`. Keeping them in place
// avoids a churn ripple across in-tree callers and downstream
// consumers (CCD itself imports `CCD_COMPAT_CLAUDE_COMMAND_PREFIX` to
// produce matching strings during host-hook receipts).

/// 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;

/// Substring that identifies the pre-v1 Python bridge entries in
/// `.claude/settings.json`. Merge logic scrubs these even when the
/// modern command prefix has changed.
pub const CCD_COMPAT_CLAUDE_LEGACY_PYTHON_HOOK: &str = "ccd-hook.py";

/// 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)
}

/// Render a CCD-compat `.codex/hooks.json` hook command for `hook_arg`.
pub fn ccd_compat_codex_command(hook_arg: &str) -> String {
    CCD_COMPAT_PROFILE.codex_command(hook_arg)
}