Skip to main content

lifeloop/host_assets/
profiles.rs

1//! Lifecycle integration profile data and command-prefix helpers.
2
3use serde_json::Value;
4
5const 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 ";
6
7// Tombstones for the removed `ccd-renewal` profile. The profile itself is
8// gone, but installs that previously rendered it carry managed hook entries
9// with these command prefixes. Keeping them in `ccd-compat`'s scrub lists
10// lets `asset preview/apply` of `ccd-compat` over a legacy `ccd-renewal`
11// install detect those entries as managed (not user-owned) and remove them,
12// instead of orphaning stale `on-agent-end` / duplicate callback hooks.
13const 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 ";
14const 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 ";
15const 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 ";
16
17const CCD_COMPAT_CODEX_LEGACY_PREFIXES: &[&str] = &[
18    LEGACY_CCD_COMPAT_CODEX_GIT_COMMAND_PREFIX,
19    TOMBSTONE_CCD_RENEWAL_CODEX_PREFIX,
20    TOMBSTONE_CCD_RENEWAL_CODEX_GIT_PREFIX,
21];
22
23// ============================================================================
24// Lifecycle integration profiles
25// ============================================================================
26//
27// A `LifecycleProfile` captures the per-client-profile facts that vary
28// between integration profiles: per-host command prefixes, the legacy
29// substrings the merge logic should scrub for that profile, and the
30// managed event tables Lifeloop installs into each host's hook config
31// for that profile. The renderers and merge logic consult a profile
32// rather than hardcoding any one client's binary or command prefix,
33// so adding a new profile does not require editing core merge logic.
34// See the module rustdoc for the slimdown narrative this enables.
35
36/// Per-client-profile data driving lifecycle integration asset
37/// rendering and merge.
38///
39/// This struct expresses the client-shape of a host integration
40/// profile (e.g. CCD compatibility) without pulling client semantics
41/// into core types. It is a pure data surface: every field is
42/// `'static` and the methods are pure functions of those fields.
43#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
44pub struct LifecycleProfile {
45    /// Stable profile identifier (e.g. `"ccd-compat"`). Used in
46    /// diagnostics; not part of the rendered asset content.
47    pub id: &'static str,
48    /// Command prefix Lifeloop renders into `.claude/settings.json`
49    /// for managed hook entries. The merge logic uses it as a
50    /// managed-entry marker (it scrubs entries whose `command`
51    /// starts with this prefix and rewrites them).
52    pub claude_command_prefix: &'static str,
53    /// Substrings inside `.claude/settings.json` `command` strings
54    /// that the merge logic also treats as managed (legacy/pre-v1
55    /// forms whose shape changed across releases). Always merged
56    /// WITH the prefix scrub, never replacing it. Empty when the
57    /// profile has no legacy shape to scrub.
58    pub claude_legacy_substrings: &'static [&'static str],
59    /// `(claude_event, hook_arg, matcher_pattern)` tuples this
60    /// profile installs into Claude's hook config.
61    pub claude_managed_events: &'static [(&'static str, &'static str, &'static str)],
62    /// Command prefix Lifeloop renders into `.codex/hooks.json` for
63    /// managed hook entries. Merge logic scrubs entries whose
64    /// `command` starts with it.
65    pub codex_command_prefix: &'static str,
66    /// `(codex_event, hook_arg, matcher_pattern, status_message)`
67    /// tuples this profile installs into Codex's hook config.
68    pub codex_managed_events: &'static [(&'static str, &'static str, &'static str, &'static str)],
69}
70
71impl LifecycleProfile {
72    pub fn validate(&self) -> Result<(), &'static str> {
73        if self.id.is_empty() {
74            return Err("profile id must not be empty");
75        }
76        if self.claude_command_prefix.is_empty() {
77            return Err("claude command prefix must not be empty");
78        }
79        if self.codex_command_prefix.is_empty() {
80            return Err("codex command prefix must not be empty");
81        }
82        if self
83            .claude_legacy_substrings
84            .iter()
85            .any(|legacy| legacy.is_empty())
86        {
87            return Err("claude legacy substrings must not be empty");
88        }
89        Ok(())
90    }
91
92    /// Render this profile's `.claude/settings.json` hook command for
93    /// `hook_arg`.
94    pub fn claude_command(&self, hook_arg: &str) -> String {
95        format!("{}{}", self.claude_command_prefix, hook_arg)
96    }
97
98    /// Render this profile's `.codex/hooks.json` hook command for
99    /// `hook_arg`.
100    pub fn codex_command(&self, hook_arg: &str) -> String {
101        format!("{}{}", self.codex_command_prefix, hook_arg)
102    }
103
104    /// True when `entry` is recognized as a managed `.claude/settings.json`
105    /// hook for this profile — either the modern command prefix or any
106    /// of `claude_legacy_substrings`. Used by the merge logic to scrub
107    /// stale managed entries before rewriting them.
108    pub(super) fn claude_entry_is_managed_or_legacy(&self, entry: &Value) -> bool {
109        let cmd = entry.get("command").and_then(Value::as_str).unwrap_or("");
110        (!self.claude_command_prefix.is_empty() && cmd.starts_with(self.claude_command_prefix))
111            || self
112                .claude_legacy_substrings
113                .iter()
114                .any(|legacy| !legacy.is_empty() && cmd.contains(legacy))
115    }
116
117    /// True when `entry` is recognized as a managed `.codex/hooks.json`
118    /// hook for this profile.
119    pub(super) fn codex_entry_is_managed(&self, entry: &Value) -> bool {
120        entry
121            .get("command")
122            .and_then(Value::as_str)
123            .map(|cmd| {
124                !self.codex_command_prefix.is_empty() && cmd.starts_with(self.codex_command_prefix)
125                    || self
126                        .codex_legacy_command_prefixes()
127                        .iter()
128                        .any(|legacy| cmd.starts_with(legacy))
129            })
130            .unwrap_or(false)
131    }
132
133    fn codex_legacy_command_prefixes(&self) -> &'static [&'static str] {
134        match self.id {
135            "ccd-compat" => CCD_COMPAT_CODEX_LEGACY_PREFIXES,
136            _ => &[],
137        }
138    }
139}
140
141// ----------------------------------------------------------------------------
142// Shared event tables
143// ----------------------------------------------------------------------------
144//
145// These tables describe the lifecycle events Lifeloop installs into a
146// host's hook config. They are shared across profiles because the
147// lifecycle event vocabulary is harness-defined, not client-defined —
148// what varies across profiles is the *command prefix* that wraps each
149// event's hook arg, not the (event, hook arg, matcher) triple. A
150// future profile that needs to skip an event or use a different hook
151// arg can simply ship its own table.
152
153/// (claude_event, hook_arg, matcher_pattern). `TaskCompleted` is
154/// intentionally excluded — only `Stop` fires reliably at end-of-turn
155/// in Claude's hook protocol.
156const STANDARD_CLAUDE_MANAGED_EVENTS: &[(&str, &str, &str)] = &[
157    (
158        "SessionStart",
159        "on-session-start",
160        "startup|resume|clear|compact",
161    ),
162    ("UserPromptSubmit", "before-prompt-build", "*"),
163    ("PreCompact", "on-compaction-notice", "*"),
164    ("Stop", "on-agent-end", "*"),
165    ("SessionEnd", "on-session-end", "*"),
166];
167
168/// (codex_event, hook_arg, matcher_pattern, status_message). Codex exposes
169/// `PreCompact` and `PostCompact`; unlike Claude, it does not expose
170/// `SessionEnd`.
171const STANDARD_CODEX_MANAGED_EVENTS: &[(&str, &str, &str, &str)] = &[
172    (
173        "SessionStart",
174        "on-session-start",
175        "startup|resume|clear",
176        "Loading CCD session context",
177    ),
178    (
179        "UserPromptSubmit",
180        "before-prompt-build",
181        "*",
182        "Refreshing CCD prompt context",
183    ),
184    (
185        "PreCompact",
186        "on-compaction-notice",
187        "*",
188        "Recording CCD compaction boundary",
189    ),
190    (
191        "PostCompact",
192        "on-compaction-notice",
193        "*",
194        "Recording CCD compacted context boundary",
195    ),
196    (
197        "Stop",
198        "on-agent-end",
199        "*",
200        "Checking CCD continuation boundary",
201    ),
202];
203
204// ----------------------------------------------------------------------------
205// Built-in profiles
206// ----------------------------------------------------------------------------
207
208/// CCD compatibility profile: the harness invokes `${CCD_BIN:-ccd}
209/// host-hook ...` and CCD acts as the broker that calls back into
210/// Lifeloop. This is Lifeloop's first client and its current
211/// production install shape.
212pub const CCD_COMPAT_PROFILE: LifecycleProfile = LifecycleProfile {
213    id: "ccd-compat",
214    claude_command_prefix: "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook ",
215    // "ccd-hook.py" is the legacy python-hook tombstone; the ccd-renewal
216    // prefix is the removed-profile tombstone (see comment at top of file).
217    claude_legacy_substrings: &["ccd-hook.py", TOMBSTONE_CCD_RENEWAL_CLAUDE_PREFIX],
218    claude_managed_events: STANDARD_CLAUDE_MANAGED_EVENTS,
219    codex_command_prefix: "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"${LIFELOOP_WORKSPACE_DIR:-${CODEX_PROJECT_DIR:-$PWD}}\" --host codex --hook ",
220    codex_managed_events: STANDARD_CODEX_MANAGED_EVENTS,
221};
222
223// ----------------------------------------------------------------------------
224// CCD-compat back-compat aliases
225// ----------------------------------------------------------------------------
226//
227// The constants and helper below name the CCD-compat profile's command
228// prefixes directly, delegating to `CCD_COMPAT_PROFILE`. They exist for
229// the host-asset tests, which assert rendered hook commands start with
230// these prefixes.
231
232/// Command prefix Lifeloop renders into `.claude/settings.json` for
233/// CCD-managed hook entries. Equal to
234/// [`CCD_COMPAT_PROFILE`]`.claude_command_prefix`.
235pub const CCD_COMPAT_CLAUDE_COMMAND_PREFIX: &str = CCD_COMPAT_PROFILE.claude_command_prefix;
236
237/// Command prefix Lifeloop renders into `.codex/hooks.json` for
238/// CCD-managed hook entries. Equal to
239/// [`CCD_COMPAT_PROFILE`]`.codex_command_prefix`.
240pub const CCD_COMPAT_CODEX_COMMAND_PREFIX: &str = CCD_COMPAT_PROFILE.codex_command_prefix;
241
242/// Render a CCD-compat `.claude/settings.json` hook command for `hook_arg`.
243pub fn ccd_compat_claude_command(hook_arg: &str) -> String {
244    CCD_COMPAT_PROFILE.claude_command(hook_arg)
245}