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 ";
6const LEGACY_LIFELOOP_DIRECT_CODEX_GIT_COMMAND_PREFIX: &str = "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --hook ";
7const LEGACY_CCD_RENEWAL_CODEX_GIT_COMMAND_PREFIX: &str = "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --client-cmd \"${CCD_BIN:-ccd}\" --hook ";
8const CCD_COMPAT_CODEX_LEGACY_PREFIXES: &[&str] = &[LEGACY_CCD_COMPAT_CODEX_GIT_COMMAND_PREFIX];
9const LIFELOOP_DIRECT_CODEX_LEGACY_PREFIXES: &[&str] =
10    &[LEGACY_LIFELOOP_DIRECT_CODEX_GIT_COMMAND_PREFIX];
11const CCD_RENEWAL_CODEX_LEGACY_PREFIXES: &[&str] = &[LEGACY_CCD_RENEWAL_CODEX_GIT_COMMAND_PREFIX];
12
13// ============================================================================
14// Lifecycle integration profiles
15// ============================================================================
16//
17// A `LifecycleProfile` captures the per-client-profile facts that vary
18// between integration profiles: per-host command prefixes, the legacy
19// substrings the merge logic should scrub for that profile, and the
20// managed event tables Lifeloop installs into each host's hook config
21// for that profile. The renderers and merge logic consult a profile
22// rather than hardcoding any one client's binary or command prefix,
23// so adding a new profile does not require editing core merge logic.
24// See the module rustdoc for the slimdown narrative this enables.
25
26/// Per-client-profile data driving lifecycle integration asset
27/// rendering and merge.
28///
29/// This struct expresses the client-shape of a host integration
30/// profile (e.g. CCD compatibility, lifeloop-direct callback) without
31/// pulling client semantics into core types. It is a pure data
32/// surface: every field is `'static` and the methods are pure
33/// functions of those fields.
34#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
35pub struct LifecycleProfile {
36    /// Stable profile identifier (e.g. `"ccd-compat"`,
37    /// `"lifeloop-direct"`). Used in diagnostics; not part of the
38    /// rendered asset content.
39    pub id: &'static str,
40    /// Command prefix Lifeloop renders into `.claude/settings.json`
41    /// for managed hook entries. The merge logic uses it as a
42    /// managed-entry marker (it scrubs entries whose `command`
43    /// starts with this prefix and rewrites them).
44    pub claude_command_prefix: &'static str,
45    /// Substrings inside `.claude/settings.json` `command` strings
46    /// that the merge logic also treats as managed (legacy/pre-v1
47    /// forms whose shape changed across releases). Always merged
48    /// WITH the prefix scrub, never replacing it. Empty when the
49    /// profile has no legacy shape to scrub.
50    pub claude_legacy_substrings: &'static [&'static str],
51    /// `(claude_event, hook_arg, matcher_pattern)` tuples this
52    /// profile installs into Claude's hook config.
53    pub claude_managed_events: &'static [(&'static str, &'static str, &'static str)],
54    /// Command prefix Lifeloop renders into `.codex/hooks.json` for
55    /// managed hook entries. Merge logic scrubs entries whose
56    /// `command` starts with it.
57    pub codex_command_prefix: &'static str,
58    /// `(codex_event, hook_arg, matcher_pattern, status_message)`
59    /// tuples this profile installs into Codex's hook config.
60    pub codex_managed_events: &'static [(&'static str, &'static str, &'static str, &'static str)],
61}
62
63impl LifecycleProfile {
64    pub fn validate(&self) -> Result<(), &'static str> {
65        if self.id.is_empty() {
66            return Err("profile id must not be empty");
67        }
68        if self.claude_command_prefix.is_empty() {
69            return Err("claude command prefix must not be empty");
70        }
71        if self.codex_command_prefix.is_empty() {
72            return Err("codex command prefix must not be empty");
73        }
74        if self
75            .claude_legacy_substrings
76            .iter()
77            .any(|legacy| legacy.is_empty())
78        {
79            return Err("claude legacy substrings must not be empty");
80        }
81        Ok(())
82    }
83
84    /// Render this profile's `.claude/settings.json` hook command for
85    /// `hook_arg`.
86    pub fn claude_command(&self, hook_arg: &str) -> String {
87        format!("{}{}", self.claude_command_prefix, hook_arg)
88    }
89
90    /// Render this profile's `.codex/hooks.json` hook command for
91    /// `hook_arg`.
92    pub fn codex_command(&self, hook_arg: &str) -> String {
93        format!("{}{}", self.codex_command_prefix, hook_arg)
94    }
95
96    /// True when `entry` is recognized as a managed `.claude/settings.json`
97    /// hook for this profile — either the modern command prefix or any
98    /// of `claude_legacy_substrings`. Used by the merge logic to scrub
99    /// stale managed entries before rewriting them.
100    pub(super) fn claude_entry_is_managed_or_legacy(&self, entry: &Value) -> bool {
101        let cmd = entry.get("command").and_then(Value::as_str).unwrap_or("");
102        (!self.claude_command_prefix.is_empty() && cmd.starts_with(self.claude_command_prefix))
103            || self
104                .claude_legacy_substrings
105                .iter()
106                .any(|legacy| !legacy.is_empty() && cmd.contains(legacy))
107    }
108
109    /// True when `entry` is recognized as a managed `.codex/hooks.json`
110    /// hook for this profile.
111    pub(super) fn codex_entry_is_managed(&self, entry: &Value) -> bool {
112        entry
113            .get("command")
114            .and_then(Value::as_str)
115            .map(|cmd| {
116                !self.codex_command_prefix.is_empty() && cmd.starts_with(self.codex_command_prefix)
117                    || self
118                        .codex_legacy_command_prefixes()
119                        .iter()
120                        .any(|legacy| cmd.starts_with(legacy))
121            })
122            .unwrap_or(false)
123    }
124
125    fn codex_legacy_command_prefixes(&self) -> &'static [&'static str] {
126        match self.id {
127            "ccd-compat" => CCD_COMPAT_CODEX_LEGACY_PREFIXES,
128            "lifeloop-direct" => LIFELOOP_DIRECT_CODEX_LEGACY_PREFIXES,
129            "ccd-renewal" => CCD_RENEWAL_CODEX_LEGACY_PREFIXES,
130            _ => &[],
131        }
132    }
133}
134
135// ----------------------------------------------------------------------------
136// Shared event tables
137// ----------------------------------------------------------------------------
138//
139// These tables describe the lifecycle events Lifeloop installs into a
140// host's hook config. They are shared across profiles because the
141// lifecycle event vocabulary is harness-defined, not client-defined —
142// what varies across profiles is the *command prefix* that wraps each
143// event's hook arg, not the (event, hook arg, matcher) triple. A
144// future profile that needs to skip an event or use a different hook
145// arg can simply ship its own table.
146
147/// (claude_event, hook_arg, matcher_pattern). `TaskCompleted` is
148/// intentionally excluded — only `Stop` fires reliably at end-of-turn
149/// in Claude's hook protocol.
150const STANDARD_CLAUDE_MANAGED_EVENTS: &[(&str, &str, &str)] = &[
151    (
152        "SessionStart",
153        "on-session-start",
154        "startup|resume|clear|compact",
155    ),
156    ("UserPromptSubmit", "before-prompt-build", "*"),
157    ("PreCompact", "on-compaction-notice", "*"),
158    ("Stop", "on-agent-end", "*"),
159    ("SessionEnd", "on-session-end", "*"),
160];
161
162/// (codex_event, hook_arg, matcher_pattern, status_message). Codex's
163/// hook surface does not expose `PreCompact` or `SessionEnd`, so the
164/// table is shorter than the Claude one.
165const STANDARD_CODEX_MANAGED_EVENTS: &[(&str, &str, &str, &str)] = &[
166    (
167        "SessionStart",
168        "on-session-start",
169        "startup|resume|clear",
170        "Loading CCD session context",
171    ),
172    (
173        "UserPromptSubmit",
174        "before-prompt-build",
175        "*",
176        "Refreshing CCD prompt context",
177    ),
178    (
179        "PreCompact",
180        "on-compaction-notice",
181        "*",
182        "Recording CCD compaction boundary",
183    ),
184    (
185        "PostCompact",
186        "on-compaction-notice",
187        "*",
188        "Recording CCD compacted context boundary",
189    ),
190    (
191        "Stop",
192        "on-agent-end",
193        "*",
194        "Checking CCD continuation boundary",
195    ),
196];
197
198/// (codex_event, hook_arg, matcher_pattern, status_message) for the
199/// post-slimdown lifeloop-direct profile. Status text reads
200/// "Lifeloop ..." rather than "CCD ..." so the operator-facing
201/// messaging matches the binary actually invoked.
202const LIFELOOP_DIRECT_CODEX_MANAGED_EVENTS: &[(&str, &str, &str, &str)] = &[
203    (
204        "SessionStart",
205        "on-session-start",
206        "startup|resume|clear",
207        "Loading Lifeloop session context",
208    ),
209    (
210        "UserPromptSubmit",
211        "before-prompt-build",
212        "*",
213        "Refreshing Lifeloop prompt context",
214    ),
215    (
216        "Stop",
217        "on-agent-end",
218        "*",
219        "Checking Lifeloop continuation boundary",
220    ),
221];
222
223// ----------------------------------------------------------------------------
224// Built-in profiles
225// ----------------------------------------------------------------------------
226
227/// CCD compatibility profile: the harness invokes `${CCD_BIN:-ccd}
228/// host-hook ...` and CCD acts as the broker that calls back into
229/// Lifeloop. This is Lifeloop's first client and its current
230/// production install shape.
231pub const CCD_COMPAT_PROFILE: LifecycleProfile = LifecycleProfile {
232    id: "ccd-compat",
233    claude_command_prefix: "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook ",
234    claude_legacy_substrings: &["ccd-hook.py"],
235    claude_managed_events: STANDARD_CLAUDE_MANAGED_EVENTS,
236    codex_command_prefix: "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"${LIFELOOP_WORKSPACE_DIR:-${CODEX_PROJECT_DIR:-$PWD}}\" --host codex --hook ",
237    codex_managed_events: STANDARD_CODEX_MANAGED_EVENTS,
238};
239
240/// Lifeloop-direct callback profile: the harness invokes
241/// `${LIFELOOP_BIN:-lifeloop} host-hook ...` directly, with no CCD
242/// in the loop. This is the post-slimdown shape contemplated by
243/// dusk-network/ccd#723 — landing it as a built-in profile lets a
244/// non-CCD pilot exercise the full host-asset rendering path before
245/// the slimdown work commits to it.
246pub const LIFELOOP_DIRECT_PROFILE: LifecycleProfile = LifecycleProfile {
247    id: "lifeloop-direct",
248    claude_command_prefix: "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook ",
249    // The lifeloop-direct profile is the documented successor to the
250    // CCD-compat profile (see `docs/decisions/lifecycle-profiles.md`
251    // and `docs/release-gates.md` on dusk-network/ccd#723). Treating
252    // CCD-compat entries as legacy ensures that an operator who runs
253    // a lifeloop-direct merge over an existing CCD-compat
254    // settings.json gets a single set of managed hooks in the new
255    // shape — not two coexisting sets — which is what "switch
256    // profiles" means at the install layer. The pre-v1 Python-bridge
257    // substring is also recognized for the same reason. The reverse
258    // direction (CCD-compat merge over lifeloop-direct) is
259    // intentionally additive, since CCD has no claim to a successor
260    // profile's shape; that asymmetry is pinned by tests in
261    // `tests/host_assets_profiles.rs`.
262    claude_legacy_substrings: &[CCD_COMPAT_PROFILE.claude_command_prefix, "ccd-hook.py"],
263    claude_managed_events: STANDARD_CLAUDE_MANAGED_EVENTS,
264    codex_command_prefix: "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"${LIFELOOP_WORKSPACE_DIR:-${CODEX_PROJECT_DIR:-$PWD}}\" --host codex --hook ",
265    codex_managed_events: LIFELOOP_DIRECT_CODEX_MANAGED_EVENTS,
266};
267
268/// CCD renewal profile: the harness invokes Lifeloop's host-hook
269/// broker and Lifeloop mediates CCD as a client through its public CLI
270/// (`ccd start`, `ccd session renew prepare`, continuation start).
271///
272/// This is distinct from [`CCD_COMPAT_PROFILE`]. CCD-compat preserves
273/// the historical direct `ccd host-hook` shape; CCD renewal is the
274/// opt-in post-host-hook shape for CCD builds that no longer expose
275/// that command.
276pub const CCD_RENEWAL_PROFILE: LifecycleProfile = LifecycleProfile {
277    id: "ccd-renewal",
278    claude_command_prefix: "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --client-cmd \"${CCD_BIN:-ccd}\" --hook ",
279    claude_legacy_substrings: &[CCD_COMPAT_PROFILE.claude_command_prefix, "ccd-hook.py"],
280    claude_managed_events: STANDARD_CLAUDE_MANAGED_EVENTS,
281    codex_command_prefix: "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"${LIFELOOP_WORKSPACE_DIR:-${CODEX_PROJECT_DIR:-$PWD}}\" --host codex --client-cmd \"${CCD_BIN:-ccd}\" --hook ",
282    codex_managed_events: STANDARD_CODEX_MANAGED_EVENTS,
283};
284
285// ----------------------------------------------------------------------------
286// CCD-compat back-compat aliases
287// ----------------------------------------------------------------------------
288//
289// The constants and helpers below preserve the pre-#26 public API
290// while delegating to `CCD_COMPAT_PROFILE`. Keeping them in place
291// avoids a churn ripple across in-tree callers and downstream
292// consumers (CCD itself imports `CCD_COMPAT_CLAUDE_COMMAND_PREFIX` to
293// produce matching strings during host-hook receipts).
294
295/// Command prefix Lifeloop renders into `.claude/settings.json` for
296/// CCD-managed hook entries. Equal to
297/// [`CCD_COMPAT_PROFILE`]`.claude_command_prefix`.
298pub const CCD_COMPAT_CLAUDE_COMMAND_PREFIX: &str = CCD_COMPAT_PROFILE.claude_command_prefix;
299
300/// Command prefix Lifeloop renders into `.codex/hooks.json` for
301/// CCD-managed hook entries. Equal to
302/// [`CCD_COMPAT_PROFILE`]`.codex_command_prefix`.
303pub const CCD_COMPAT_CODEX_COMMAND_PREFIX: &str = CCD_COMPAT_PROFILE.codex_command_prefix;
304
305/// Substring that identifies the pre-v1 Python bridge entries in
306/// `.claude/settings.json`. Merge logic scrubs these even when the
307/// modern command prefix has changed.
308pub const CCD_COMPAT_CLAUDE_LEGACY_PYTHON_HOOK: &str = "ccd-hook.py";
309
310/// Render a CCD-compat `.claude/settings.json` hook command for `hook_arg`.
311pub fn ccd_compat_claude_command(hook_arg: &str) -> String {
312    CCD_COMPAT_PROFILE.claude_command(hook_arg)
313}
314
315/// Render a CCD-compat `.codex/hooks.json` hook command for `hook_arg`.
316pub fn ccd_compat_codex_command(hook_arg: &str) -> String {
317    CCD_COMPAT_PROFILE.codex_command(hook_arg)
318}