Skip to main content

lifeloop/
host_assets.rs

1//! Host integration asset rendering and merge behavior.
2//!
3//! Lifeloop owns the file shape, merge safety, and status reporting for
4//! lifecycle integration assets installed into harness host directories
5//! (`.claude/`, `.codex/`, `.hermes/`, `.openclaw/`). The `host apply`
6//! and `host inspect` compatibility commands route here.
7//!
8//! # Boundary (issue #4)
9//!
10//! This module owns:
11//! * rendering source/applied asset content as in-memory data,
12//! * additive-merge logic that preserves user-owned entries,
13//! * asset status reporting (`Present`/`Missing`/`Drifted`/`InvalidMode`/
14//!   `NotApplicable`),
15//! * supported-mode rules for each adapter.
16//!
17//! This module does **not** own:
18//! * the hook protocol command strings themselves (those are issue #3 —
19//!   the strings appear here only as opaque compatibility labels that the
20//!   merge logic must recognize so it can scrub stale entries);
21//! * the full adapter manifest registry (issue #6);
22//! * lifecycle routing (issue #7);
23//! * telemetry parsing (issue #5);
24//! * filesystem IO. Callers handle reads, writes, mode bits, and atomic
25//!   replace. This module is pure: it operates on `serde_json::Value`,
26//!   strings, and byte slices.
27//!
28//! # CCD compatibility
29//!
30//! The command-prefix constants and legacy recognizer patterns
31//! (`CCD_COMPAT_*`) are CCD compatibility labels — Lifeloop's first
32//! client wires its own binary into harness hooks via these prefixes.
33//! They are *not* core Lifeloop semantics: a future non-CCD client
34//! gets its own profile in the same shape. Keeping them in one place
35//! makes the compat surface auditable.
36//!
37//! # Lifecycle integration profiles (issue #26)
38//!
39//! [`LifecycleProfile`] generalizes the CCD-shaped command-prefix /
40//! managed-event surface into a per-client-profile struct. The free
41//! functions exported from this module keep their CCD-compat default
42//! behavior (they delegate to [`CCD_COMPAT_PROFILE`]); paired
43//! `*_with_profile` variants accept any profile so a non-CCD client
44//! (e.g. [`LIFELOOP_DIRECT_PROFILE`], the post-slimdown shape where
45//! the harness invokes the `lifeloop` CLI directly without CCD as
46//! broker) can render and merge its own lifecycle hook assets without
47//! editing core merge logic. This is the bridge contemplated by
48//! `docs/release-gates.md` for the CCD slimdown
49//! (dusk-network/ccd#723) — the slimdown lands by switching active
50//! installs from `CCD_COMPAT_PROFILE` to a non-CCD profile, not by
51//! rewriting the renderer.
52
53use serde_json::{Value, json};
54
55// ============================================================================
56// Adapters and modes
57// ============================================================================
58
59/// Host adapters Lifeloop ships asset rendering and merge support for.
60///
61/// The on-the-wire identifier (`as_str()`) is the same string an
62/// `AdapterManifest::adapter_id` would carry for the same harness; issue
63/// #6 lands the full manifest registry that consumes these ids.
64#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
65pub enum HostAdapter {
66    Claude,
67    Codex,
68    Hermes,
69    OpenClaw,
70}
71
72impl HostAdapter {
73    pub const ALL: &'static [Self] = &[Self::Claude, Self::Codex, Self::Hermes, Self::OpenClaw];
74
75    pub fn as_str(self) -> &'static str {
76        match self {
77            Self::Claude => "claude",
78            Self::Codex => "codex",
79            Self::Hermes => "hermes",
80            Self::OpenClaw => "openclaw",
81        }
82    }
83
84    /// Recognizes both canonical ids and the historical CCD aliases (e.g.
85    /// `claude-code`). Returns `None` for unknown names.
86    pub fn from_id(name: &str) -> Option<Self> {
87        match name {
88            "claude" | "claude-code" => Some(Self::Claude),
89            "codex" => Some(Self::Codex),
90            "hermes" => Some(Self::Hermes),
91            "openclaw" => Some(Self::OpenClaw),
92            _ => None,
93        }
94    }
95
96    /// Default integration mode when the host has no explicit declaration.
97    pub fn default_mode(self) -> IntegrationMode {
98        match self {
99            Self::Claude => IntegrationMode::NativeHook,
100            Self::Codex => IntegrationMode::ManualSkill,
101            Self::Hermes | Self::OpenClaw => IntegrationMode::ReferenceAdapter,
102        }
103    }
104}
105
106/// Integration modes supported by host asset rendering.
107///
108/// Mirrors the subset of [`crate::IntegrationMode`] that maps to actual
109/// installable assets. `TelemetryOnly` does not produce assets and is
110/// therefore not represented here; callers that have a full
111/// `IntegrationMode` can convert with [`Self::from_lifecycle_mode`].
112#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
113pub enum IntegrationMode {
114    ManualSkill,
115    LauncherWrapper,
116    NativeHook,
117    ReferenceAdapter,
118}
119
120impl IntegrationMode {
121    pub const ALL: &'static [Self] = &[
122        Self::ManualSkill,
123        Self::LauncherWrapper,
124        Self::NativeHook,
125        Self::ReferenceAdapter,
126    ];
127
128    pub fn as_str(self) -> &'static str {
129        match self {
130            Self::ManualSkill => "manual_skill",
131            Self::LauncherWrapper => "launcher_wrapper",
132            Self::NativeHook => "native_hook",
133            Self::ReferenceAdapter => "reference_adapter",
134        }
135    }
136
137    pub fn from_id(value: &str) -> Option<Self> {
138        match value {
139            "manual_skill" => Some(Self::ManualSkill),
140            "launcher_wrapper" => Some(Self::LauncherWrapper),
141            "native_hook" => Some(Self::NativeHook),
142            "reference_adapter" => Some(Self::ReferenceAdapter),
143            _ => None,
144        }
145    }
146
147    /// Convert from the broader [`crate::IntegrationMode`]. Returns `None`
148    /// for `TelemetryOnly`, which has no asset surface.
149    pub fn from_lifecycle_mode(mode: crate::IntegrationMode) -> Option<Self> {
150        match mode {
151            crate::IntegrationMode::ManualSkill => Some(Self::ManualSkill),
152            crate::IntegrationMode::LauncherWrapper => Some(Self::LauncherWrapper),
153            crate::IntegrationMode::NativeHook => Some(Self::NativeHook),
154            crate::IntegrationMode::ReferenceAdapter => Some(Self::ReferenceAdapter),
155            crate::IntegrationMode::TelemetryOnly => None,
156        }
157    }
158}
159
160/// Whether `host` supports the requested install mode. Gates `apply` so a
161/// caller can refuse before mutating files.
162pub fn supports_mode(host: HostAdapter, mode: IntegrationMode) -> bool {
163    match host {
164        HostAdapter::Claude => mode == IntegrationMode::NativeHook,
165        HostAdapter::Codex => matches!(
166            mode,
167            IntegrationMode::ManualSkill
168                | IntegrationMode::LauncherWrapper
169                | IntegrationMode::NativeHook
170        ),
171        HostAdapter::Hermes | HostAdapter::OpenClaw => mode == IntegrationMode::ReferenceAdapter,
172    }
173}
174
175/// Modes the adapter accepts, in declaration order, for diagnostic
176/// messaging. Order is informational.
177pub fn supported_modes(host: HostAdapter) -> &'static [IntegrationMode] {
178    match host {
179        HostAdapter::Claude => &[IntegrationMode::NativeHook],
180        HostAdapter::Codex => &[
181            IntegrationMode::ManualSkill,
182            IntegrationMode::LauncherWrapper,
183            IntegrationMode::NativeHook,
184        ],
185        HostAdapter::Hermes | HostAdapter::OpenClaw => &[IntegrationMode::ReferenceAdapter],
186    }
187}
188
189// ============================================================================
190// Asset descriptor and status
191// ============================================================================
192
193/// One file Lifeloop renders for a host adapter. `relative_path` is
194/// relative to the repository root the caller is rendering into. `mode`
195/// is a Unix permission bitmask when present; callers on non-Unix
196/// targets may ignore it, but cross-checking is still meaningful for
197/// status reporting.
198#[derive(Clone, Debug, PartialEq, Eq)]
199pub struct RenderedAsset {
200    pub relative_path: &'static str,
201    pub contents: String,
202    pub mode: Option<u32>,
203}
204
205/// Status of an installed asset relative to the rendered template.
206#[derive(Clone, Copy, Debug, Eq, PartialEq)]
207pub enum AssetStatus {
208    /// The on-disk asset matches the rendered shape.
209    Present,
210    /// The asset path does not exist.
211    Missing,
212    /// The asset exists but its content (or permission mode) differs from
213    /// what Lifeloop would render. For merge-aware assets this means the
214    /// merge result no longer matches the file.
215    Drifted,
216    /// The (host, mode) pair is not a supported install combination.
217    InvalidMode,
218    /// No asset is expected for this (host, mode) pair.
219    NotApplicable,
220}
221
222/// Action taken for one asset during apply.
223#[derive(Clone, Copy, Debug, Eq, PartialEq)]
224pub enum FileAction {
225    /// The asset was created.
226    Installed,
227    /// The asset existed and was rewritten with new content.
228    Updated,
229    /// The asset matched the rendered template; no write was needed.
230    AlreadyPresent,
231}
232
233/// Combine `current` with `next` so a multi-file apply reports the
234/// strongest action seen. `Updated` dominates `Installed` dominates
235/// `AlreadyPresent`.
236pub fn combine_actions(current: FileAction, next: FileAction) -> FileAction {
237    match (current, next) {
238        (FileAction::Updated, _) | (_, FileAction::Updated) => FileAction::Updated,
239        (FileAction::Installed, _) | (_, FileAction::Installed) => FileAction::Installed,
240        _ => FileAction::AlreadyPresent,
241    }
242}
243
244/// Outcome of merging managed entries into an existing settings/hooks
245/// file. `existing` is the prior file content (`None` if the file did
246/// not exist); `rendered` is the merged content the caller should
247/// write.
248#[derive(Clone, Debug, PartialEq, Eq)]
249pub struct MergedFile {
250    pub existing: Option<String>,
251    pub rendered: String,
252}
253
254/// Errors returned by merge or render entry points. Callers convert as
255/// needed; the variants are pinned so downstream tests can match.
256#[derive(Debug)]
257pub enum HostAssetError {
258    /// The (host, mode) pair is not a supported install combination.
259    UnsupportedMode {
260        host: HostAdapter,
261        mode: IntegrationMode,
262    },
263    /// The existing file's top-level shape conflicts with the merge
264    /// invariants (e.g. `hooks` is an array instead of an object).
265    Malformed { reason: String },
266    /// The existing file is invalid JSON or TOML.
267    Parse { reason: String },
268    /// Re-serialization failed (should be unreachable for well-formed
269    /// inputs; surfaced rather than panicked).
270    Serialize { reason: String },
271}
272
273impl std::fmt::Display for HostAssetError {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        match self {
276            Self::UnsupportedMode { host, mode } => write!(
277                f,
278                "unsupported mode `{}` for {} (supported: {})",
279                mode.as_str(),
280                host.as_str(),
281                supported_modes(*host)
282                    .iter()
283                    .map(|m| m.as_str())
284                    .collect::<Vec<_>>()
285                    .join(", ")
286            ),
287            Self::Malformed { reason } => write!(f, "malformed managed file: {reason}"),
288            Self::Parse { reason } => write!(f, "parse error: {reason}"),
289            Self::Serialize { reason } => write!(f, "serialize error: {reason}"),
290        }
291    }
292}
293
294impl std::error::Error for HostAssetError {}
295
296// ============================================================================
297// Path constants
298// ============================================================================
299
300pub const CLAUDE_SOURCE_SETTINGS: &str = ".ccd-hosts/claude/settings.json";
301pub const CLAUDE_TARGET_SETTINGS: &str = ".claude/settings.json";
302
303pub const CODEX_SOURCE_README: &str = ".ccd-hosts/codex/README.md";
304pub const CODEX_SOURCE_LAUNCHER: &str = ".ccd-hosts/codex/launcher.sh";
305pub const CODEX_SOURCE_CONFIG: &str = ".ccd-hosts/codex/config.toml";
306pub const CODEX_SOURCE_HOOKS: &str = ".ccd-hosts/codex/hooks.json";
307pub const CODEX_TARGET_LAUNCHER: &str = ".codex/ccd-launch.sh";
308pub const CODEX_TARGET_CONFIG: &str = ".codex/config.toml";
309pub const CODEX_TARGET_HOOKS: &str = ".codex/hooks.json";
310
311pub const OPENCLAW_SOURCE_ADAPTER: &str = ".ccd-hosts/openclaw/adapter.json";
312pub const OPENCLAW_TARGET_ADAPTER: &str = ".openclaw/ccd.json";
313
314pub const HERMES_SOURCE_ADAPTER: &str = ".ccd-hosts/hermes/adapter.json";
315pub const HERMES_TARGET_ADAPTER: &str = ".hermes/ccd.json";
316
317// ============================================================================
318// Lifecycle integration profiles
319// ============================================================================
320//
321// A `LifecycleProfile` captures the per-client-profile facts that vary
322// between integration profiles: per-host command prefixes, the legacy
323// substrings the merge logic should scrub for that profile, and the
324// managed event tables Lifeloop installs into each host's hook config
325// for that profile. The renderers and merge logic consult a profile
326// rather than hardcoding any one client's binary or command prefix,
327// so adding a new profile does not require editing core merge logic.
328// See the module rustdoc for the slimdown narrative this enables.
329
330/// Per-client-profile data driving lifecycle integration asset
331/// rendering and merge.
332///
333/// This struct expresses the client-shape of a host integration
334/// profile (e.g. CCD compatibility, lifeloop-direct callback) without
335/// pulling client semantics into core types. It is a pure data
336/// surface: every field is `'static` and the methods are pure
337/// functions of those fields.
338#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
339pub struct LifecycleProfile {
340    /// Stable profile identifier (e.g. `"ccd-compat"`,
341    /// `"lifeloop-direct"`). Used in diagnostics; not part of the
342    /// rendered asset content.
343    pub id: &'static str,
344    /// Command prefix Lifeloop renders into `.claude/settings.json`
345    /// for managed hook entries. The merge logic uses it as a
346    /// managed-entry marker (it scrubs entries whose `command`
347    /// starts with this prefix and rewrites them).
348    pub claude_command_prefix: &'static str,
349    /// Substrings inside `.claude/settings.json` `command` strings
350    /// that the merge logic also treats as managed (legacy/pre-v1
351    /// forms whose shape changed across releases). Always merged
352    /// WITH the prefix scrub, never replacing it. Empty when the
353    /// profile has no legacy shape to scrub.
354    pub claude_legacy_substrings: &'static [&'static str],
355    /// `(claude_event, hook_arg, matcher_pattern)` tuples this
356    /// profile installs into Claude's hook config.
357    pub claude_managed_events: &'static [(&'static str, &'static str, &'static str)],
358    /// Command prefix Lifeloop renders into `.codex/hooks.json` for
359    /// managed hook entries. Merge logic scrubs entries whose
360    /// `command` starts with it.
361    pub codex_command_prefix: &'static str,
362    /// `(codex_event, hook_arg, matcher_pattern, status_message)`
363    /// tuples this profile installs into Codex's hook config.
364    pub codex_managed_events: &'static [(&'static str, &'static str, &'static str, &'static str)],
365}
366
367impl LifecycleProfile {
368    /// Render this profile's `.claude/settings.json` hook command for
369    /// `hook_arg`.
370    pub fn claude_command(&self, hook_arg: &str) -> String {
371        format!("{}{}", self.claude_command_prefix, hook_arg)
372    }
373
374    /// Render this profile's `.codex/hooks.json` hook command for
375    /// `hook_arg`.
376    pub fn codex_command(&self, hook_arg: &str) -> String {
377        format!("{}{}", self.codex_command_prefix, hook_arg)
378    }
379
380    /// True when `entry` is recognized as a managed `.claude/settings.json`
381    /// hook for this profile — either the modern command prefix or any
382    /// of `claude_legacy_substrings`. Used by the merge logic to scrub
383    /// stale managed entries before rewriting them.
384    fn claude_entry_is_managed_or_legacy(&self, entry: &Value) -> bool {
385        let cmd = entry.get("command").and_then(Value::as_str).unwrap_or("");
386        cmd.starts_with(self.claude_command_prefix)
387            || self
388                .claude_legacy_substrings
389                .iter()
390                .any(|legacy| cmd.contains(legacy))
391    }
392
393    /// True when `entry` is recognized as a managed `.codex/hooks.json`
394    /// hook for this profile.
395    fn codex_entry_is_managed(&self, entry: &Value) -> bool {
396        entry
397            .get("command")
398            .and_then(Value::as_str)
399            .map(|cmd| cmd.starts_with(self.codex_command_prefix))
400            .unwrap_or(false)
401    }
402}
403
404// ----------------------------------------------------------------------------
405// Shared event tables
406// ----------------------------------------------------------------------------
407//
408// These tables describe the lifecycle events Lifeloop installs into a
409// host's hook config. They are shared across profiles because the
410// lifecycle event vocabulary is harness-defined, not client-defined —
411// what varies across profiles is the *command prefix* that wraps each
412// event's hook arg, not the (event, hook arg, matcher) triple. A
413// future profile that needs to skip an event or use a different hook
414// arg can simply ship its own table.
415
416/// (claude_event, hook_arg, matcher_pattern). `TaskCompleted` is
417/// intentionally excluded — only `Stop` fires reliably at end-of-turn
418/// in Claude's hook protocol.
419const STANDARD_CLAUDE_MANAGED_EVENTS: &[(&str, &str, &str)] = &[
420    (
421        "SessionStart",
422        "on-session-start",
423        "startup|resume|clear|compact",
424    ),
425    ("UserPromptSubmit", "before-prompt-build", "*"),
426    ("PreCompact", "on-compaction-notice", "*"),
427    ("Stop", "on-agent-end", "*"),
428    ("SessionEnd", "on-session-end", "*"),
429];
430
431/// (codex_event, hook_arg, matcher_pattern, status_message). Codex's
432/// hook surface does not expose `PreCompact` or `SessionEnd`, so the
433/// table is shorter than the Claude one.
434const STANDARD_CODEX_MANAGED_EVENTS: &[(&str, &str, &str, &str)] = &[
435    (
436        "SessionStart",
437        "on-session-start",
438        "startup|resume|clear",
439        "Loading CCD session context",
440    ),
441    (
442        "UserPromptSubmit",
443        "before-prompt-build",
444        "*",
445        "Refreshing CCD prompt context",
446    ),
447    (
448        "PreCompact",
449        "on-compaction-notice",
450        "*",
451        "Recording CCD compaction boundary",
452    ),
453    (
454        "PostCompact",
455        "on-compaction-notice",
456        "*",
457        "Recording CCD compacted context boundary",
458    ),
459    (
460        "Stop",
461        "on-agent-end",
462        "*",
463        "Checking CCD continuation boundary",
464    ),
465];
466
467/// (codex_event, hook_arg, matcher_pattern, status_message) for the
468/// post-slimdown lifeloop-direct profile. Status text reads
469/// "Lifeloop ..." rather than "CCD ..." so the operator-facing
470/// messaging matches the binary actually invoked.
471const LIFELOOP_DIRECT_CODEX_MANAGED_EVENTS: &[(&str, &str, &str, &str)] = &[
472    (
473        "SessionStart",
474        "on-session-start",
475        "startup|resume|clear",
476        "Loading Lifeloop session context",
477    ),
478    (
479        "UserPromptSubmit",
480        "before-prompt-build",
481        "*",
482        "Refreshing Lifeloop prompt context",
483    ),
484    (
485        "Stop",
486        "on-agent-end",
487        "*",
488        "Checking Lifeloop continuation boundary",
489    ),
490];
491
492// ----------------------------------------------------------------------------
493// Built-in profiles
494// ----------------------------------------------------------------------------
495
496/// CCD compatibility profile: the harness invokes `${CCD_BIN:-ccd}
497/// host-hook ...` and CCD acts as the broker that calls back into
498/// Lifeloop. This is Lifeloop's first client and its current
499/// production install shape.
500pub const CCD_COMPAT_PROFILE: LifecycleProfile = LifecycleProfile {
501    id: "ccd-compat",
502    claude_command_prefix: "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook ",
503    claude_legacy_substrings: &["ccd-hook.py"],
504    claude_managed_events: STANDARD_CLAUDE_MANAGED_EVENTS,
505    codex_command_prefix: "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --hook ",
506    codex_managed_events: STANDARD_CODEX_MANAGED_EVENTS,
507};
508
509/// Lifeloop-direct callback profile: the harness invokes
510/// `${LIFELOOP_BIN:-lifeloop} host-hook ...` directly, with no CCD
511/// in the loop. This is the post-slimdown shape contemplated by
512/// dusk-network/ccd#723 — landing it as a built-in profile lets a
513/// non-CCD pilot exercise the full host-asset rendering path before
514/// the slimdown work commits to it.
515pub const LIFELOOP_DIRECT_PROFILE: LifecycleProfile = LifecycleProfile {
516    id: "lifeloop-direct",
517    claude_command_prefix: "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook ",
518    // The lifeloop-direct profile is the documented successor to the
519    // CCD-compat profile (see `docs/decisions/lifecycle-profiles.md`
520    // and `docs/release-gates.md` on dusk-network/ccd#723). Treating
521    // CCD-compat entries as legacy ensures that an operator who runs
522    // a lifeloop-direct merge over an existing CCD-compat
523    // settings.json gets a single set of managed hooks in the new
524    // shape — not two coexisting sets — which is what "switch
525    // profiles" means at the install layer. The pre-v1 Python-bridge
526    // substring is also recognized for the same reason. The reverse
527    // direction (CCD-compat merge over lifeloop-direct) is
528    // intentionally additive, since CCD has no claim to a successor
529    // profile's shape; that asymmetry is pinned by tests in
530    // `tests/host_assets_profiles.rs`.
531    claude_legacy_substrings: &[CCD_COMPAT_PROFILE.claude_command_prefix, "ccd-hook.py"],
532    claude_managed_events: STANDARD_CLAUDE_MANAGED_EVENTS,
533    codex_command_prefix: "\"${LIFELOOP_BIN:-lifeloop}\" --output hook-protocol host-hook --path \"$(git rev-parse --show-toplevel)\" --host codex --hook ",
534    codex_managed_events: LIFELOOP_DIRECT_CODEX_MANAGED_EVENTS,
535};
536
537// ----------------------------------------------------------------------------
538// CCD-compat back-compat aliases
539// ----------------------------------------------------------------------------
540//
541// The constants and helpers below preserve the pre-#26 public API
542// while delegating to `CCD_COMPAT_PROFILE`. Keeping them in place
543// avoids a churn ripple across in-tree callers and downstream
544// consumers (CCD itself imports `CCD_COMPAT_CLAUDE_COMMAND_PREFIX` to
545// produce matching strings during host-hook receipts).
546
547/// Command prefix Lifeloop renders into `.claude/settings.json` for
548/// CCD-managed hook entries. Equal to
549/// [`CCD_COMPAT_PROFILE`]`.claude_command_prefix`.
550pub const CCD_COMPAT_CLAUDE_COMMAND_PREFIX: &str = CCD_COMPAT_PROFILE.claude_command_prefix;
551
552/// Command prefix Lifeloop renders into `.codex/hooks.json` for
553/// CCD-managed hook entries. Equal to
554/// [`CCD_COMPAT_PROFILE`]`.codex_command_prefix`.
555pub const CCD_COMPAT_CODEX_COMMAND_PREFIX: &str = CCD_COMPAT_PROFILE.codex_command_prefix;
556
557/// Substring that identifies the pre-v1 Python bridge entries in
558/// `.claude/settings.json`. Merge logic scrubs these even when the
559/// modern command prefix has changed.
560pub const CCD_COMPAT_CLAUDE_LEGACY_PYTHON_HOOK: &str = "ccd-hook.py";
561
562/// Render a CCD-compat `.claude/settings.json` hook command for `hook_arg`.
563pub fn ccd_compat_claude_command(hook_arg: &str) -> String {
564    CCD_COMPAT_PROFILE.claude_command(hook_arg)
565}
566
567/// Render a CCD-compat `.codex/hooks.json` hook command for `hook_arg`.
568pub fn ccd_compat_codex_command(hook_arg: &str) -> String {
569    CCD_COMPAT_PROFILE.codex_command(hook_arg)
570}
571
572// ============================================================================
573// Asset rendering
574// ============================================================================
575
576/// Full set of source-tree assets for a host using the default
577/// [`CCD_COMPAT_PROFILE`]. These are the templates that land under
578/// `.ccd-hosts/<host>/`.
579pub fn render_source_assets(host: HostAdapter) -> Vec<RenderedAsset> {
580    render_source_assets_with_profile(host, &CCD_COMPAT_PROFILE)
581}
582
583/// Full set of source-tree assets for a host rendered with
584/// `profile`'s command prefixes and managed event tables. See
585/// [`render_applied_assets_with_profile`] for the per-profile vs.
586/// per-host scope split.
587pub fn render_source_assets_with_profile(
588    host: HostAdapter,
589    profile: &LifecycleProfile,
590) -> Vec<RenderedAsset> {
591    match host {
592        HostAdapter::Claude => vec![RenderedAsset {
593            relative_path: CLAUDE_SOURCE_SETTINGS,
594            contents: claude_settings_json_for(profile),
595            mode: None,
596        }],
597        HostAdapter::Codex => vec![
598            RenderedAsset {
599                relative_path: CODEX_SOURCE_README,
600                contents: codex_guidance_readme(),
601                mode: None,
602            },
603            RenderedAsset {
604                relative_path: CODEX_SOURCE_CONFIG,
605                contents: codex_config_toml(),
606                mode: None,
607            },
608            RenderedAsset {
609                relative_path: CODEX_SOURCE_HOOKS,
610                contents: codex_hooks_json_for(profile),
611                mode: None,
612            },
613            RenderedAsset {
614                relative_path: CODEX_SOURCE_LAUNCHER,
615                contents: codex_launcher_script(),
616                mode: Some(0o755),
617            },
618        ],
619        HostAdapter::Hermes => vec![RenderedAsset {
620            relative_path: HERMES_SOURCE_ADAPTER,
621            contents: hermes_adapter_json(),
622            mode: None,
623        }],
624        HostAdapter::OpenClaw => vec![RenderedAsset {
625            relative_path: OPENCLAW_SOURCE_ADAPTER,
626            contents: openclaw_adapter_json(),
627            mode: None,
628        }],
629    }
630}
631
632/// Subset of [`render_source_assets`] required for the given mode.
633/// Modes that pin only a few of a host's source files (Codex
634/// manual-skill, launcher-wrapper) trim the list so absent files don't
635/// trigger spurious "missing scaffold" errors.
636pub fn render_required_source_assets(
637    host: HostAdapter,
638    mode: IntegrationMode,
639) -> Vec<RenderedAsset> {
640    let assets = render_source_assets(host);
641    let required_paths: &[&str] = match (host, mode) {
642        (HostAdapter::Codex, IntegrationMode::ManualSkill) => &[CODEX_SOURCE_README],
643        (HostAdapter::Codex, IntegrationMode::LauncherWrapper) => {
644            &[CODEX_SOURCE_README, CODEX_SOURCE_LAUNCHER]
645        }
646        (HostAdapter::Codex, IntegrationMode::NativeHook) => {
647            &[CODEX_SOURCE_README, CODEX_SOURCE_CONFIG, CODEX_SOURCE_HOOKS]
648        }
649        _ => return assets,
650    };
651    assets
652        .into_iter()
653        .filter(|asset| required_paths.contains(&asset.relative_path))
654        .collect()
655}
656
657/// Assets to apply into the host's runtime directories (`.claude/`,
658/// `.codex/`, `.hermes/`, `.openclaw/`) using the default
659/// [`CCD_COMPAT_PROFILE`]. Empty when the (host, mode) pair has no
660/// installable files (e.g. Codex manual-skill mode is scaffold-only).
661///
662/// This is a thin wrapper over [`render_applied_assets_with_profile`];
663/// callers that want non-CCD command prefixes pass an explicit
664/// profile.
665pub fn render_applied_assets(host: HostAdapter, mode: IntegrationMode) -> Vec<RenderedAsset> {
666    render_applied_assets_with_profile(host, mode, &CCD_COMPAT_PROFILE)
667}
668
669/// Assets to apply into the host's runtime directories rendered with
670/// `profile`'s command prefixes and managed event tables.
671///
672/// Hermes and OpenClaw reference adapters currently render with a
673/// CCD-flavored adapter JSON regardless of `profile`: those files are
674/// per-host illustrative documentation, not active command surfaces,
675/// and graduating them to per-profile rendering is a follow-up scoped
676/// outside #26.
677pub fn render_applied_assets_with_profile(
678    host: HostAdapter,
679    mode: IntegrationMode,
680    profile: &LifecycleProfile,
681) -> Vec<RenderedAsset> {
682    match (host, mode) {
683        (HostAdapter::Claude, IntegrationMode::NativeHook) => vec![RenderedAsset {
684            relative_path: CLAUDE_TARGET_SETTINGS,
685            contents: claude_settings_json_for(profile),
686            mode: None,
687        }],
688        (HostAdapter::Codex, IntegrationMode::LauncherWrapper) => vec![RenderedAsset {
689            relative_path: CODEX_TARGET_LAUNCHER,
690            contents: codex_launcher_script(),
691            mode: Some(0o755),
692        }],
693        (HostAdapter::Codex, IntegrationMode::NativeHook) => vec![
694            RenderedAsset {
695                relative_path: CODEX_TARGET_CONFIG,
696                contents: codex_config_toml(),
697                mode: None,
698            },
699            RenderedAsset {
700                relative_path: CODEX_TARGET_HOOKS,
701                contents: codex_hooks_json_for(profile),
702                mode: None,
703            },
704        ],
705        (HostAdapter::Codex, IntegrationMode::ManualSkill) => Vec::new(),
706        (HostAdapter::Hermes, IntegrationMode::ReferenceAdapter) => vec![RenderedAsset {
707            relative_path: HERMES_TARGET_ADAPTER,
708            contents: hermes_adapter_json(),
709            mode: None,
710        }],
711        (HostAdapter::OpenClaw, IntegrationMode::ReferenceAdapter) => vec![RenderedAsset {
712            relative_path: OPENCLAW_TARGET_ADAPTER,
713            contents: openclaw_adapter_json(),
714            mode: None,
715        }],
716        _ => Vec::new(),
717    }
718}
719
720// ============================================================================
721// Asset content (private renderers)
722// ============================================================================
723
724fn claude_settings_json_for(profile: &LifecycleProfile) -> String {
725    let mut hooks = serde_json::Map::new();
726    for (event, hook_arg, matcher) in profile.claude_managed_events {
727        hooks.insert(
728            (*event).to_string(),
729            json!([{
730                "matcher": matcher,
731                "hooks": [{
732                    "type": "command",
733                    "command": profile.claude_command(hook_arg),
734                }]
735            }]),
736        );
737    }
738    let value = json!({ "hooks": Value::Object(hooks) });
739    serde_json::to_string_pretty(&value).expect("claude settings json")
740}
741
742fn codex_guidance_readme() -> String {
743    format!(
744        r#"<!-- CCD-MANAGED -->
745# Codex Host Guidance
746
747Codex supports native repo-local hooks when `hooks` is enabled in
748`.codex/config.toml` and `.codex/hooks.json` maps lifecycle events into CCD.
749
750CCD installs a minimal native mapping:
751
752- `SessionStart` -> `ccd host-hook --hook on-session-start`
753- `UserPromptSubmit` -> `ccd host-hook --hook before-prompt-build`
754- `PreCompact` -> `ccd host-hook --hook on-compaction-notice`
755- `PostCompact` -> `ccd host-hook --hook on-compaction-notice`
756- `Stop` -> `ccd host-hook --hook on-agent-end`
757
758Human-driven Codex can still fall back to the manual CCD startup path:
759
760- `/ccd-start`
761- `ccd start --activate --path .`
762
763That fallback is tracked as `manual_skill`, not as a product failure.
764
765If you want the optional zero-ritual launcher/eval harness instead, run:
766
767```bash
768ccd host apply --host codex --with-launcher --path .
769```
770
771That applies the launcher wrapper at `./{CODEX_TARGET_LAUNCHER}`.
772"#
773    )
774}
775
776fn codex_config_toml() -> String {
777    "[features]\nhooks = true\n".to_owned()
778}
779
780fn codex_hooks_json_for(profile: &LifecycleProfile) -> String {
781    let merged = merge_codex_hooks_with_profile(json!({}), profile)
782        .expect("empty object is a valid Codex hooks base for managed events");
783    serde_json::to_string_pretty(&merged).expect("codex hooks json")
784}
785
786fn codex_launcher_script() -> String {
787    r#"#!/bin/sh
788# CCD-MANAGED
789# Optional Codex launcher/eval harness.
790
791set -e
792
793if [ -n "$CCD_BIN" ] && [ -x "$CCD_BIN" ]; then
794    CCD="$CCD_BIN"
795elif command -v ccd >/dev/null 2>&1; then
796    CCD=ccd
797elif [ -x "$HOME/.ccd/bin/ccd" ]; then
798    CCD="$HOME/.ccd/bin/ccd"
799elif [ -x "$HOME/.cargo/bin/ccd" ]; then
800    CCD="$HOME/.cargo/bin/ccd"
801else
802    CCD=""
803fi
804
805if [ -n "$CCD" ]; then
806    "$CCD" host-hook --output json --path . --host codex --hook on-session-start >/dev/null 2>&1 || true
807fi
808
809exec codex "$@"
810"#
811    .to_owned()
812}
813
814fn openclaw_adapter_json() -> String {
815    serde_json::to_string_pretty(&json!({
816        "host": "openclaw",
817        "integration_mode": "reference_adapter",
818        "commands": {
819            "session_start": "ccd --output json host-hook --path . --host openclaw --hook on-session-start --mode implement --lifecycle autonomous --owner-kind runtime-worker --actor-id runtime/openclaw-agent-1 --lease-seconds 900 --host-session-id acp-session-42 --host-run-id acp-run-42 --host-task-id req-openclaw-42",
820            "before_prompt_build": "ccd host-hook --path . --host openclaw --hook before-prompt-build",
821            "on_compaction_notice": "ccd host-hook --path . --host openclaw --hook on-compaction-notice",
822            "on_agent_end": "ccd host-hook --path . --host openclaw --hook on-agent-end",
823            "on_session_end": "ccd host-hook --path . --host openclaw --hook on-session-end"
824        },
825        "notes": [
826            "Inject only the top-level context payload into prompt-build.",
827            "Keep runtime transcript history outside CCD durable state.",
828            "Use separate worktrees for parallel writers."
829        ]
830    }))
831    .expect("openclaw adapter json")
832}
833
834fn hermes_adapter_json() -> String {
835    serde_json::to_string_pretty(&json!({
836        "host": "hermes",
837        "integration_mode": "reference_adapter",
838        "commands": {
839            "session_start": "ccd host-hook --output json --path . --host hermes --hook on-session-start --mode implement --lifecycle autonomous --actor-id runtime/hermes-worker-1 --supervisor-id runtime/hermes-supervisor-1 --lease-seconds 900 --host-session-id hermes-channel-42 --host-run-id hermes-run-42 --host-task-id hermes-task-42",
840            "before_prompt_build": "ccd host-hook --path . --host hermes --hook before-prompt-build",
841            "on_compaction_notice": "ccd host-hook --path . --host hermes --hook on-compaction-notice",
842            "on_agent_end": "ccd host-hook --path . --host hermes --hook on-agent-end",
843            "on_session_end": "ccd host-hook --path . --host hermes --hook on-session-end",
844            "supervisor_tick": "ccd host-hook --path . --host hermes --hook supervisor-tick"
845        },
846        "notes": [
847            "Honor the top-level session_boundary before unattended continuation.",
848            "Use supervisor_tick when the runtime can refresh lease ownership.",
849            "Treat CCD outputs as control-plane truth rather than prompt folklore."
850        ]
851    }))
852    .expect("hermes adapter json")
853}
854
855// ============================================================================
856// Merge: .claude/settings.json
857// ============================================================================
858
859/// Additive merge of CCD-managed lifecycle hooks into a Claude
860/// `settings.json` value, using the default [`CCD_COMPAT_PROFILE`].
861///
862/// See [`merge_claude_settings_with_profile`] for the algorithm. This
863/// is the back-compat entry point preserved for callers that pre-date
864/// the profile abstraction.
865pub fn merge_claude_settings(settings: Value) -> Option<Value> {
866    merge_claude_settings_with_profile(settings, &CCD_COMPAT_PROFILE)
867}
868
869/// Additive merge of `profile`'s managed lifecycle hooks into a Claude
870/// `settings.json` value.
871///
872/// Algorithm:
873/// 1. Ensure a top-level `hooks` object. Preserve every other top-level
874///    key.
875/// 2. For each managed event in `profile.claude_managed_events`,
876///    ensure a matcher entry exists. Within its `hooks` array, drop
877///    any entry whose `command` is recognized as a stale managed
878///    entry for `profile` (current prefix or any of
879///    `profile.claude_legacy_substrings`), then append the current
880///    managed entry rendered through `profile.claude_command`.
881/// 3. Non-managed entries within the same matcher are preserved in
882///    original order.
883/// 4. The retired `TaskCompleted` event is removed when its only
884///    entries are managed by `profile`; user-owned `TaskCompleted`
885///    hooks are preserved.
886///
887/// Returns `None` when the existing `hooks` shape is incompatible:
888/// `hooks` is not an object, or a managed event's value is not an array,
889/// or a matcher's inner `hooks` is not an array. Callers treat `None` as
890/// malformed input.
891pub fn merge_claude_settings_with_profile(
892    mut settings: Value,
893    profile: &LifecycleProfile,
894) -> Option<Value> {
895    let root_obj = settings.as_object_mut()?;
896    let hooks_entry = root_obj
897        .entry("hooks")
898        .or_insert_with(|| Value::Object(Default::default()));
899    let hooks_obj = hooks_entry.as_object_mut()?;
900    scrub_retired_task_completed_event(hooks_obj, profile);
901
902    for (event, hook_arg, matcher) in profile.claude_managed_events {
903        let event_entry = hooks_obj
904            .entry(*event)
905            .or_insert_with(|| Value::Array(Vec::new()));
906        let event_array = event_entry.as_array_mut()?;
907
908        let matcher_idx = event_array.iter().position(|entry| {
909            entry
910                .get("matcher")
911                .and_then(Value::as_str)
912                .map(|s| s == *matcher)
913                .unwrap_or(false)
914        });
915        let matcher_entry = match matcher_idx {
916            Some(idx) => &mut event_array[idx],
917            None => {
918                event_array.push(json!({ "matcher": matcher, "hooks": [] }));
919                event_array.last_mut().unwrap()
920            }
921        };
922
923        let hooks_inside = matcher_entry
924            .get_mut("hooks")
925            .and_then(Value::as_array_mut)?;
926        hooks_inside.retain(|entry| !profile.claude_entry_is_managed_or_legacy(entry));
927        hooks_inside.push(json!({
928            "type": "command",
929            "command": profile.claude_command(hook_arg),
930        }));
931    }
932
933    Some(settings)
934}
935
936fn scrub_retired_task_completed_event(
937    hooks_obj: &mut serde_json::Map<String, Value>,
938    profile: &LifecycleProfile,
939) {
940    let Some(task_completed) = hooks_obj.get_mut("TaskCompleted") else {
941        return;
942    };
943    let Some(event_array) = task_completed.as_array_mut() else {
944        return;
945    };
946
947    event_array.retain_mut(|entry| {
948        let Some(hooks_inside) = entry.get_mut("hooks").and_then(Value::as_array_mut) else {
949            return true;
950        };
951        hooks_inside.retain(|hook| !profile.claude_entry_is_managed_or_legacy(hook));
952        !hooks_inside.is_empty()
953    });
954
955    if event_array.is_empty() {
956        hooks_obj.remove("TaskCompleted");
957    }
958}
959
960/// Merge an existing serialized `.claude/settings.json` body into the
961/// CCD-managed shape (default [`CCD_COMPAT_PROFILE`]). See
962/// [`merge_claude_settings_text_with_profile`] for the contract.
963pub fn merge_claude_settings_text(
964    existing: Option<&str>,
965    force: bool,
966) -> Result<Option<MergedFile>, HostAssetError> {
967    merge_claude_settings_text_with_profile(existing, force, &CCD_COMPAT_PROFILE)
968}
969
970/// Merge an existing serialized `.claude/settings.json` body into
971/// `profile`'s managed shape. Returns:
972/// * `Ok(Some(merged))` on success;
973/// * `Ok(None)` when the existing body is malformed and `force` was
974///   not requested (caller should warn and skip);
975/// * `Err(_)` for parse errors that `force` cannot recover from
976///   without reset.
977///
978/// When `force` is true, parse errors and shape mismatches fall back
979/// to a fresh merge against an empty object.
980pub fn merge_claude_settings_text_with_profile(
981    existing: Option<&str>,
982    force: bool,
983    profile: &LifecycleProfile,
984) -> Result<Option<MergedFile>, HostAssetError> {
985    let parsed = match existing {
986        None => Value::Object(Default::default()),
987        Some(body) => match serde_json::from_str::<Value>(body) {
988            Ok(v) => v,
989            Err(_) if force => Value::Object(Default::default()),
990            Err(_) => return Ok(None),
991        },
992    };
993
994    let root = if parsed.is_object() {
995        parsed
996    } else if force {
997        Value::Object(Default::default())
998    } else {
999        return Ok(None);
1000    };
1001
1002    let merged = match merge_claude_settings_with_profile(root, profile) {
1003        Some(v) => v,
1004        None if force => {
1005            merge_claude_settings_with_profile(Value::Object(Default::default()), profile)
1006                .expect("empty object is always a valid base")
1007        }
1008        None => return Ok(None),
1009    };
1010    let rendered =
1011        serde_json::to_string_pretty(&merged).map_err(|err| HostAssetError::Serialize {
1012            reason: err.to_string(),
1013        })?;
1014    Ok(Some(MergedFile {
1015        existing: existing.map(str::to_owned),
1016        rendered,
1017    }))
1018}
1019
1020// ============================================================================
1021// Merge: .codex/hooks.json
1022// ============================================================================
1023
1024/// Additive merge of CCD-managed lifecycle hooks into a Codex
1025/// `hooks.json` value (default [`CCD_COMPAT_PROFILE`]). See
1026/// [`merge_codex_hooks_with_profile`] for the algorithm.
1027pub fn merge_codex_hooks(hooks_doc: Value) -> Option<Value> {
1028    merge_codex_hooks_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
1029}
1030
1031/// Additive merge of `profile`'s managed lifecycle hooks into a Codex
1032/// `hooks.json` value. See [`merge_claude_settings_with_profile`] for
1033/// the algorithm; the only differences are the managed event table
1034/// and the per-entry `timeout`/`statusMessage` fields Codex carries.
1035pub fn merge_codex_hooks_with_profile(
1036    mut hooks_doc: Value,
1037    profile: &LifecycleProfile,
1038) -> Option<Value> {
1039    let hooks_entry = hooks_doc
1040        .as_object_mut()?
1041        .entry("hooks")
1042        .or_insert_with(|| Value::Object(Default::default()));
1043    let hooks_obj = hooks_entry.as_object_mut()?;
1044
1045    for (event, hook_arg, matcher, status_message) in profile.codex_managed_events {
1046        let event_entry = hooks_obj
1047            .entry(*event)
1048            .or_insert_with(|| Value::Array(Vec::new()));
1049        let event_array = event_entry.as_array_mut()?;
1050
1051        let matcher_idx = event_array.iter().position(|entry| {
1052            entry
1053                .get("matcher")
1054                .and_then(Value::as_str)
1055                .map(|value| value == *matcher)
1056                .unwrap_or(false)
1057        });
1058        let matcher_entry = match matcher_idx {
1059            Some(idx) => &mut event_array[idx],
1060            None => {
1061                event_array.push(json!({ "matcher": matcher, "hooks": [] }));
1062                event_array.last_mut().unwrap()
1063            }
1064        };
1065
1066        let hooks_inside = matcher_entry
1067            .get_mut("hooks")
1068            .and_then(Value::as_array_mut)?;
1069        hooks_inside.retain(|entry| !profile.codex_entry_is_managed(entry));
1070        hooks_inside.push(json!({
1071            "type": "command",
1072            "command": profile.codex_command(hook_arg),
1073            "timeout": 30,
1074            "statusMessage": status_message,
1075        }));
1076    }
1077
1078    Some(hooks_doc)
1079}
1080
1081/// True when `hooks_doc` already contains the full CCD-managed Codex
1082/// lifecycle hook set (default [`CCD_COMPAT_PROFILE`]). Used by status
1083/// reporting to detect "Codex hooks installed externally" vs.
1084/// "needs apply".
1085pub fn codex_hooks_contain_managed_lifecycle(hooks_doc: &Value) -> bool {
1086    codex_hooks_contain_managed_lifecycle_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
1087}
1088
1089/// True when `hooks_doc` already contains the full set of `profile`'s
1090/// managed Codex lifecycle hooks.
1091pub fn codex_hooks_contain_managed_lifecycle_with_profile(
1092    hooks_doc: &Value,
1093    profile: &LifecycleProfile,
1094) -> bool {
1095    profile
1096        .codex_managed_events
1097        .iter()
1098        .all(|(event, hook_arg, _, _)| {
1099            codex_event_contains_managed_hook(hooks_doc, event, hook_arg, profile)
1100        })
1101}
1102
1103fn codex_event_contains_managed_hook(
1104    hooks_doc: &Value,
1105    event: &str,
1106    hook_arg: &str,
1107    profile: &LifecycleProfile,
1108) -> bool {
1109    let expected = profile.codex_command(hook_arg);
1110    hooks_doc
1111        .get("hooks")
1112        .and_then(|hooks| hooks.get(event))
1113        .and_then(Value::as_array)
1114        .into_iter()
1115        .flatten()
1116        .filter_map(|matcher| matcher.get("hooks").and_then(Value::as_array))
1117        .flatten()
1118        .any(|hook| {
1119            hook.get("command")
1120                .and_then(Value::as_str)
1121                .map(|command| {
1122                    command == expected
1123                        || (command.starts_with(profile.codex_command_prefix)
1124                            && command.contains(hook_arg))
1125                })
1126                .unwrap_or(false)
1127        })
1128}
1129
1130/// Merge an existing serialized `.codex/hooks.json` body using the
1131/// default [`CCD_COMPAT_PROFILE`]. See
1132/// [`merge_codex_hooks_text_with_profile`] for the contract.
1133pub fn merge_codex_hooks_text(
1134    existing: Option<&str>,
1135    force: bool,
1136) -> Result<Option<MergedFile>, HostAssetError> {
1137    merge_codex_hooks_text_with_profile(existing, force, &CCD_COMPAT_PROFILE)
1138}
1139
1140/// Merge an existing serialized `.codex/hooks.json` body using
1141/// `profile`. Returns `Ok(None)` when the existing body is invalid
1142/// JSON or has an incompatible top-level shape; `_force` is reserved
1143/// for symmetry with [`merge_claude_settings_text_with_profile`] and
1144/// currently behaves the same regardless of value (the previous CCD
1145/// implementation never branched on it).
1146pub fn merge_codex_hooks_text_with_profile(
1147    existing: Option<&str>,
1148    _force: bool,
1149    profile: &LifecycleProfile,
1150) -> Result<Option<MergedFile>, HostAssetError> {
1151    let parsed = match existing {
1152        None => Value::Object(Default::default()),
1153        Some(body) => match serde_json::from_str::<Value>(body) {
1154            Ok(value) => value,
1155            Err(_) => return Ok(None),
1156        },
1157    };
1158
1159    let root = if parsed.is_object() {
1160        parsed
1161    } else {
1162        return Ok(None);
1163    };
1164
1165    let merged = match merge_codex_hooks_with_profile(root, profile) {
1166        Some(value) => value,
1167        None => return Ok(None),
1168    };
1169    let rendered =
1170        serde_json::to_string_pretty(&merged).map_err(|err| HostAssetError::Serialize {
1171            reason: err.to_string(),
1172        })?;
1173    Ok(Some(MergedFile {
1174        existing: existing.map(str::to_owned),
1175        rendered,
1176    }))
1177}
1178
1179// ============================================================================
1180// Merge: .codex/config.toml
1181// ============================================================================
1182
1183/// Merge `[features].hooks = true` into an existing Codex
1184/// `config.toml`, preserving every other key.
1185pub fn merge_codex_config_text(existing: Option<&str>) -> Result<MergedFile, HostAssetError> {
1186    let mut root = match existing {
1187        None => toml::Table::new(),
1188        Some(raw) if raw.trim().is_empty() => toml::Table::new(),
1189        Some(raw) => match raw.parse::<toml::Value>() {
1190            Ok(value) => value
1191                .as_table()
1192                .cloned()
1193                .ok_or_else(|| HostAssetError::Malformed {
1194                    reason: "codex config.toml must be a TOML table".into(),
1195                })?,
1196            Err(err) => {
1197                return Err(HostAssetError::Parse {
1198                    reason: err.to_string(),
1199                });
1200            }
1201        },
1202    };
1203
1204    let features_entry = root
1205        .entry("features".to_owned())
1206        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1207    let features = features_entry
1208        .as_table_mut()
1209        .ok_or_else(|| HostAssetError::Malformed {
1210            reason: "[features] must be a TOML table".into(),
1211        })?;
1212    features.insert("hooks".to_owned(), toml::Value::Boolean(true));
1213
1214    let rendered = toml::to_string_pretty(&root).map_err(|err| HostAssetError::Serialize {
1215        reason: err.to_string(),
1216    })?;
1217    Ok(MergedFile {
1218        existing: existing.map(str::to_owned),
1219        rendered,
1220    })
1221}
1222
1223/// True when the parsed TOML enables `[features].hooks`.
1224pub fn codex_hooks_feature_is_enabled(config: &toml::Value) -> bool {
1225    config
1226        .get("features")
1227        .and_then(|features| features.get("hooks"))
1228        .and_then(toml::Value::as_bool)
1229        == Some(true)
1230}
1231
1232// ============================================================================
1233// Asset status
1234// ============================================================================
1235
1236/// Status of an installed asset that uses byte-equal comparison (every
1237/// asset *except* the merge-aware Claude settings, Codex hooks, and
1238/// Codex config files).
1239///
1240/// `existing_content` and `existing_mode` describe the on-disk state:
1241/// `None` means the file does not exist. The caller is responsible for
1242/// reading them.
1243pub fn byte_equal_asset_status(
1244    asset: &RenderedAsset,
1245    existing_content: Option<&str>,
1246    existing_mode: Option<u32>,
1247) -> AssetStatus {
1248    let Some(content) = existing_content else {
1249        return AssetStatus::Missing;
1250    };
1251    if content != asset.contents {
1252        return AssetStatus::Drifted;
1253    }
1254    if let Some(expected) = asset.mode {
1255        match existing_mode {
1256            Some(actual) if actual == expected => {}
1257            _ => return AssetStatus::Drifted,
1258        }
1259    }
1260    AssetStatus::Present
1261}
1262
1263/// Status of `.claude/settings.json` against the rendered template
1264/// for the default [`CCD_COMPAT_PROFILE`]. `existing` is the file
1265/// body (`None` means missing). The merge is re-run and compared
1266/// byte-for-byte to detect drift.
1267pub fn claude_settings_status(existing: Option<&str>) -> AssetStatus {
1268    claude_settings_status_with_profile(existing, &CCD_COMPAT_PROFILE)
1269}
1270
1271/// Status of `.claude/settings.json` against the rendered template
1272/// for `profile`.
1273pub fn claude_settings_status_with_profile(
1274    existing: Option<&str>,
1275    profile: &LifecycleProfile,
1276) -> AssetStatus {
1277    let Some(content) = existing else {
1278        return AssetStatus::Missing;
1279    };
1280    match merge_claude_settings_text_with_profile(Some(content), false, profile) {
1281        Ok(Some(merged)) if merged.rendered == content => AssetStatus::Present,
1282        Ok(_) | Err(_) => AssetStatus::Drifted,
1283    }
1284}
1285
1286/// Status of `.codex/hooks.json` against the rendered template for
1287/// the default [`CCD_COMPAT_PROFILE`].
1288pub fn codex_hooks_status(existing: Option<&str>) -> AssetStatus {
1289    codex_hooks_status_with_profile(existing, &CCD_COMPAT_PROFILE)
1290}
1291
1292/// Status of `.codex/hooks.json` against the rendered template for
1293/// `profile`.
1294pub fn codex_hooks_status_with_profile(
1295    existing: Option<&str>,
1296    profile: &LifecycleProfile,
1297) -> AssetStatus {
1298    let Some(content) = existing else {
1299        return AssetStatus::Missing;
1300    };
1301    match merge_codex_hooks_text_with_profile(Some(content), false, profile) {
1302        Ok(Some(merged)) if merged.rendered == content => AssetStatus::Present,
1303        Ok(_) | Err(_) => AssetStatus::Drifted,
1304    }
1305}
1306
1307/// Status of `.codex/config.toml`. Returns `Present` only when the
1308/// `[features].hooks = true` flag is set; the rest of the file
1309/// is ignored because users are free to add their own config.
1310pub fn codex_config_status(existing: Option<&str>) -> AssetStatus {
1311    let Some(content) = existing else {
1312        return AssetStatus::Missing;
1313    };
1314    match content.parse::<toml::Value>() {
1315        Ok(parsed) if codex_hooks_feature_is_enabled(&parsed) => AssetStatus::Present,
1316        Ok(_) | Err(_) => AssetStatus::Drifted,
1317    }
1318}
1319
1320/// Compute status for one rendered asset by dispatching to the
1321/// appropriate per-path comparison. Falls back to byte-equal
1322/// comparison for any path that doesn't match a merge-aware target.
1323pub fn asset_status(
1324    asset: &RenderedAsset,
1325    existing_content: Option<&str>,
1326    existing_mode: Option<u32>,
1327) -> AssetStatus {
1328    match asset.relative_path {
1329        CLAUDE_TARGET_SETTINGS => claude_settings_status(existing_content),
1330        CODEX_TARGET_CONFIG => codex_config_status(existing_content),
1331        CODEX_TARGET_HOOKS => codex_hooks_status(existing_content),
1332        _ => byte_equal_asset_status(asset, existing_content, existing_mode),
1333    }
1334}
1335
1336/// Combine per-asset statuses into a single bundle status. `Drifted`
1337/// dominates `Missing` dominates `Present`; an empty input is
1338/// `NotApplicable`.
1339pub fn aggregate_status<I: IntoIterator<Item = AssetStatus>>(statuses: I) -> AssetStatus {
1340    let mut saw_missing = false;
1341    let mut saw_drift = false;
1342    let mut saw_any = false;
1343    for s in statuses {
1344        saw_any = true;
1345        match s {
1346            AssetStatus::Drifted => saw_drift = true,
1347            AssetStatus::Missing => saw_missing = true,
1348            AssetStatus::InvalidMode => return AssetStatus::InvalidMode,
1349            AssetStatus::Present | AssetStatus::NotApplicable => {}
1350        }
1351    }
1352    if !saw_any {
1353        return AssetStatus::NotApplicable;
1354    }
1355    if saw_drift {
1356        AssetStatus::Drifted
1357    } else if saw_missing {
1358        AssetStatus::Missing
1359    } else {
1360        AssetStatus::Present
1361    }
1362}