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}