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}