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}