Skip to main content

lifeloop/host_assets/
profiles.rs

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