lifeloop-cli 0.1.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
//! Lifecycle integration profile abstraction tests (#26).
//!
//! These tests pin two things:
//!
//! 1. **Parity** — the back-compat free functions
//!    (`render_applied_assets`, `merge_claude_settings`,
//!    `merge_codex_hooks`, ...) produce byte-identical output to the
//!    explicit `*_with_profile(..., &CCD_COMPAT_PROFILE)` form. This
//!    is the safety net for the refactor: any in-tree caller that
//!    used the legacy API gets exactly the same bytes after #26 as
//!    before.
//!
//! 2. **Separation** — a non-CCD profile
//!    (`LIFELOOP_DIRECT_PROFILE`) renders distinct output that does
//!    NOT contain CCD-flavored command strings, AND the CCD-compat
//!    merge logic refuses to recognize a non-CCD profile's hook
//!    entries as managed (so the two profiles do not silently
//!    interfere when an installation is migrated between profiles).

use lifeloop::host_assets::{
    self as ha, AssetStatus, CCD_COMPAT_PROFILE, HostAdapter, IntegrationMode,
    LIFELOOP_DIRECT_PROFILE, LifecycleProfile, claude_settings_status_with_profile,
    codex_hooks_contain_managed_lifecycle_with_profile, codex_hooks_status_with_profile,
    merge_claude_settings_text_with_profile, merge_claude_settings_with_profile,
    merge_codex_hooks_with_profile, render_applied_assets_with_profile,
    render_source_assets_with_profile,
};
use serde_json::{Value, json};

// ============================================================================
// CCD-compat parity: the back-compat API equals the profile-explicit form
// ============================================================================

#[test]
fn render_applied_assets_matches_ccd_compat_profile_byte_for_byte() {
    for host in HostAdapter::ALL.iter().copied() {
        for mode in IntegrationMode::ALL.iter().copied() {
            let legacy = ha::render_applied_assets(host, mode);
            let explicit = render_applied_assets_with_profile(host, mode, &CCD_COMPAT_PROFILE);
            assert_eq!(
                legacy, explicit,
                "render_applied_assets({host:?}, {mode:?}) must match CCD_COMPAT_PROFILE"
            );
        }
    }
}

#[test]
fn render_source_assets_matches_ccd_compat_profile_byte_for_byte() {
    for host in HostAdapter::ALL.iter().copied() {
        let legacy = ha::render_source_assets(host);
        let explicit = render_source_assets_with_profile(host, &CCD_COMPAT_PROFILE);
        assert_eq!(
            legacy, explicit,
            "render_source_assets({host:?}) must match CCD_COMPAT_PROFILE"
        );
    }
}

#[test]
fn merge_claude_settings_matches_ccd_compat_profile_byte_for_byte() {
    let input = json!({
        "theme": "dark",
        "hooks": {
            "Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "echo user" }] }]
        }
    });
    let legacy = ha::merge_claude_settings(input.clone()).expect("legacy merge");
    let explicit =
        merge_claude_settings_with_profile(input, &CCD_COMPAT_PROFILE).expect("explicit merge");
    assert_eq!(legacy, explicit);
}

#[test]
fn merge_codex_hooks_matches_ccd_compat_profile_byte_for_byte() {
    let input = json!({
        "hooks": {
            "Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "echo user" }] }]
        }
    });
    let legacy = ha::merge_codex_hooks(input.clone()).expect("legacy merge");
    let explicit =
        merge_codex_hooks_with_profile(input, &CCD_COMPAT_PROFILE).expect("explicit merge");
    assert_eq!(legacy, explicit);
}

// ============================================================================
// Profile struct — invariants
// ============================================================================

#[test]
fn lifecycle_profile_id_is_stable_for_each_built_in() {
    assert_eq!(CCD_COMPAT_PROFILE.id, "ccd-compat");
    assert_eq!(LIFELOOP_DIRECT_PROFILE.id, "lifeloop-direct");
}

#[test]
fn lifecycle_profile_command_helpers_concat_prefix_and_arg() {
    let cmd = CCD_COMPAT_PROFILE.claude_command("on-session-start");
    assert!(cmd.starts_with(CCD_COMPAT_PROFILE.claude_command_prefix));
    assert!(cmd.ends_with("on-session-start"));

    let cmd = LIFELOOP_DIRECT_PROFILE.codex_command("on-agent-end");
    assert!(cmd.starts_with(LIFELOOP_DIRECT_PROFILE.codex_command_prefix));
    assert!(cmd.ends_with("on-agent-end"));
}

#[test]
fn lifeloop_direct_profile_recognizes_ccd_compat_as_legacy() {
    // The lifeloop-direct profile is the documented successor to
    // CCD-compat (see `docs/decisions/lifecycle-profiles.md`). It
    // recognizes CCD-compat's command prefix as a legacy substring
    // so that a lifeloop-direct merge over an existing CCD-compat
    // settings.json scrubs the old CCD entries during migration —
    // see the `lifeloop_direct_merge_scrubs_ccd_compat_entries...`
    // test below for the migration behavior itself.
    let legacy = LIFELOOP_DIRECT_PROFILE.claude_legacy_substrings;
    assert!(
        legacy.contains(&CCD_COMPAT_PROFILE.claude_command_prefix),
        "lifeloop-direct must scrub CCD-compat entries during in-place migration"
    );
    assert!(
        legacy.iter().any(|s| s.contains("ccd-hook.py")),
        "lifeloop-direct must also scrub the pre-v1 Python bridge legacy form"
    );
}

// ============================================================================
// Separation: lifeloop-direct produces non-CCD strings
// ============================================================================

#[test]
fn lifeloop_direct_claude_command_does_not_contain_ccd_binary_reference() {
    let cmd = LIFELOOP_DIRECT_PROFILE.claude_command("on-session-start");
    assert!(
        !cmd.contains("CCD_BIN") && !cmd.contains("\"ccd\""),
        "lifeloop-direct command must not embed CCD binary references: {cmd}"
    );
    assert!(
        cmd.contains("LIFELOOP_BIN") && cmd.contains("lifeloop"),
        "lifeloop-direct command must invoke the lifeloop binary: {cmd}"
    );
}

#[test]
fn lifeloop_direct_renders_plausible_claude_settings_without_ccd_strings() {
    let assets = render_applied_assets_with_profile(
        HostAdapter::Claude,
        IntegrationMode::NativeHook,
        &LIFELOOP_DIRECT_PROFILE,
    );
    assert_eq!(assets.len(), 1);
    let settings = &assets[0];
    assert_eq!(settings.relative_path, ha::CLAUDE_TARGET_SETTINGS);
    let parsed: Value = serde_json::from_str(&settings.contents).expect("valid json");
    let session_start_cmd = parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"]
        .as_str()
        .expect("session_start command");
    assert!(
        session_start_cmd.starts_with(LIFELOOP_DIRECT_PROFILE.claude_command_prefix),
        "must use lifeloop-direct prefix"
    );
    assert!(
        !session_start_cmd.contains("CCD_BIN"),
        "must not embed CCD_BIN: {session_start_cmd}"
    );
    // Every Claude managed event in the profile must appear in the
    // rendered settings.
    for (event, _, _) in LIFELOOP_DIRECT_PROFILE.claude_managed_events {
        assert!(
            parsed["hooks"].get(event).is_some(),
            "rendered settings missing managed event {event}"
        );
    }
}

#[test]
fn lifeloop_direct_renders_plausible_codex_hooks_without_ccd_strings() {
    let assets = render_applied_assets_with_profile(
        HostAdapter::Codex,
        IntegrationMode::NativeHook,
        &LIFELOOP_DIRECT_PROFILE,
    );
    let hooks_asset = assets
        .iter()
        .find(|a| a.relative_path == ha::CODEX_TARGET_HOOKS)
        .expect("hooks.json present");
    let parsed: Value = serde_json::from_str(&hooks_asset.contents).expect("valid json");
    assert!(
        codex_hooks_contain_managed_lifecycle_with_profile(&parsed, &LIFELOOP_DIRECT_PROFILE),
        "lifeloop-direct hooks must satisfy its own managed-lifecycle predicate"
    );
    // CCD-flavored predicate must NOT match — they are different shapes.
    assert!(
        !codex_hooks_contain_managed_lifecycle_with_profile(&parsed, &CCD_COMPAT_PROFILE),
        "CCD predicate must reject lifeloop-direct rendered hooks"
    );
}

#[test]
fn lifeloop_direct_codex_status_text_is_lifeloop_flavored() {
    let assets = render_applied_assets_with_profile(
        HostAdapter::Codex,
        IntegrationMode::NativeHook,
        &LIFELOOP_DIRECT_PROFILE,
    );
    let hooks_asset = assets
        .iter()
        .find(|a| a.relative_path == ha::CODEX_TARGET_HOOKS)
        .expect("hooks.json present");
    let parsed: Value = serde_json::from_str(&hooks_asset.contents).expect("valid json");
    let session_start = &parsed["hooks"]["SessionStart"][0]["hooks"][0];
    assert_eq!(
        session_start["statusMessage"],
        Value::String("Loading Lifeloop session context".to_owned()),
        "status text must reflect the binary the harness is actually invoking"
    );
}

// ============================================================================
// Cross-profile non-interference
// ============================================================================

#[test]
fn ccd_compat_merge_does_not_recognize_lifeloop_direct_managed_entries() {
    // Render a settings.json under lifeloop-direct, then run the
    // CCD-compat merge over it. The CCD merge must NOT treat the
    // lifeloop-direct entries as its own managed entries (i.e. it
    // must not scrub them); it must additively append its own
    // CCD-compat entries alongside.
    let assets = render_applied_assets_with_profile(
        HostAdapter::Claude,
        IntegrationMode::NativeHook,
        &LIFELOOP_DIRECT_PROFILE,
    );
    let lifeloop_direct_settings = &assets[0].contents;
    let merged = merge_claude_settings_text_with_profile(
        Some(lifeloop_direct_settings),
        false,
        &CCD_COMPAT_PROFILE,
    )
    .expect("merge should succeed")
    .expect("rendered");

    let parsed: Value = serde_json::from_str(&merged.rendered).expect("valid json");
    let session_start_entries = parsed["hooks"]["SessionStart"][0]["hooks"]
        .as_array()
        .expect("array");
    // Both profiles' SessionStart entries must coexist after the
    // CCD-compat merge.
    let lifeloop_direct_present = session_start_entries.iter().any(|e| {
        e.get("command")
            .and_then(Value::as_str)
            .map(|s| s.starts_with(LIFELOOP_DIRECT_PROFILE.claude_command_prefix))
            .unwrap_or(false)
    });
    let ccd_compat_present = session_start_entries.iter().any(|e| {
        e.get("command")
            .and_then(Value::as_str)
            .map(|s| s.starts_with(CCD_COMPAT_PROFILE.claude_command_prefix))
            .unwrap_or(false)
    });
    assert!(
        lifeloop_direct_present,
        "CCD merge must preserve lifeloop-direct entry: {session_start_entries:?}"
    );
    assert!(
        ccd_compat_present,
        "CCD merge must add its own entry: {session_start_entries:?}"
    );
}

#[test]
fn lifeloop_direct_merge_scrubs_ccd_compat_entries_for_clean_migration() {
    // The slimdown narrative (docs/decisions/lifecycle-profiles.md,
    // dusk-network/ccd#723) says: switching the active install
    // profile from CCD_COMPAT to LIFELOOP_DIRECT yields a single
    // set of managed hooks in the new shape — not two coexisting
    // sets. Render a CCD-compat settings.json, merge it with the
    // lifeloop-direct profile, and assert the result has only the
    // lifeloop-direct managed entry per event (plus any user-owned
    // entries the merge preserves).
    let ccd_assets = render_applied_assets_with_profile(
        HostAdapter::Claude,
        IntegrationMode::NativeHook,
        &CCD_COMPAT_PROFILE,
    );
    let ccd_compat_settings = &ccd_assets[0].contents;

    let merged = merge_claude_settings_text_with_profile(
        Some(ccd_compat_settings),
        false,
        &LIFELOOP_DIRECT_PROFILE,
    )
    .expect("merge ok")
    .expect("rendered");

    let parsed: Value = serde_json::from_str(&merged.rendered).expect("valid json");
    let session_start_entries = parsed["hooks"]["SessionStart"][0]["hooks"]
        .as_array()
        .expect("array");
    // Exactly one managed hook entry: the lifeloop-direct one.
    assert_eq!(
        session_start_entries.len(),
        1,
        "migration must collapse to a single managed entry, found: {session_start_entries:?}"
    );
    let cmd = session_start_entries[0]["command"]
        .as_str()
        .expect("command string");
    assert!(
        cmd.starts_with(LIFELOOP_DIRECT_PROFILE.claude_command_prefix),
        "post-migration entry must use lifeloop-direct prefix, got: {cmd}"
    );
    assert!(
        !cmd.contains("CCD_BIN"),
        "post-migration entry must not retain CCD binary references, got: {cmd}"
    );
}

#[test]
fn lifeloop_direct_merge_preserves_user_owned_entries_during_migration() {
    // Migration scrub must NOT touch entries that are not part of
    // CCD-compat's managed shape — user-owned hooks survive a
    // profile switch.
    let input = json!({
        "hooks": {
            "SessionStart": [{
                "matcher": "startup|resume|clear|compact",
                "hooks": [
                    { "type": "command", "command": "\"${CCD_BIN:-ccd}\" --output hook-protocol host-hook --path \"$CLAUDE_PROJECT_DIR\" --host claude --hook on-session-start" },
                    { "type": "command", "command": "echo user-owned" }
                ]
            }]
        }
    });

    let merged =
        merge_claude_settings_with_profile(input, &LIFELOOP_DIRECT_PROFILE).expect("merge ok");
    let entries = merged["hooks"]["SessionStart"][0]["hooks"]
        .as_array()
        .expect("array");
    assert_eq!(
        entries.len(),
        2,
        "expected user entry + lifeloop-direct entry, got: {entries:?}"
    );
    let user_present = entries.iter().any(|e| {
        e.get("command")
            .and_then(Value::as_str)
            .map(|s| s == "echo user-owned")
            .unwrap_or(false)
    });
    assert!(
        user_present,
        "user-owned entry must be preserved through migration"
    );
    let lifeloop_direct_present = entries.iter().any(|e| {
        e.get("command")
            .and_then(Value::as_str)
            .map(|s| s.starts_with(LIFELOOP_DIRECT_PROFILE.claude_command_prefix))
            .unwrap_or(false)
    });
    assert!(
        lifeloop_direct_present,
        "lifeloop-direct managed entry must be added"
    );
}

#[test]
fn lifeloop_direct_merge_replaces_its_own_stale_entry_idempotently() {
    let assets = render_applied_assets_with_profile(
        HostAdapter::Claude,
        IntegrationMode::NativeHook,
        &LIFELOOP_DIRECT_PROFILE,
    );
    let body = &assets[0].contents;
    let once = merge_claude_settings_text_with_profile(Some(body), false, &LIFELOOP_DIRECT_PROFILE)
        .expect("ok")
        .expect("rendered");
    assert_eq!(
        once.rendered, *body,
        "rendered settings.json must be a fixed point of its own profile's merge"
    );
}

// ============================================================================
// Status reporting honors the profile
// ============================================================================

#[test]
fn claude_settings_status_with_profile_separates_profiles() {
    let assets = render_applied_assets_with_profile(
        HostAdapter::Claude,
        IntegrationMode::NativeHook,
        &LIFELOOP_DIRECT_PROFILE,
    );
    let body = &assets[0].contents;
    assert_eq!(
        claude_settings_status_with_profile(Some(body), &LIFELOOP_DIRECT_PROFILE),
        AssetStatus::Present,
        "lifeloop-direct's own rendered output must be Present under that profile"
    );
    // CCD-compat would re-render the file by ADDING its own managed
    // entries (per the cross-profile non-interference test), so the
    // CCD-compat status reads as Drifted — exactly what we want when
    // the operator is migrating from one profile to the other.
    assert_eq!(
        claude_settings_status_with_profile(Some(body), &CCD_COMPAT_PROFILE),
        AssetStatus::Drifted,
        "lifeloop-direct rendered output must NOT be Present under CCD_COMPAT_PROFILE"
    );
}

#[test]
fn codex_hooks_status_with_profile_separates_profiles() {
    let assets = render_applied_assets_with_profile(
        HostAdapter::Codex,
        IntegrationMode::NativeHook,
        &LIFELOOP_DIRECT_PROFILE,
    );
    let hooks = assets
        .iter()
        .find(|a| a.relative_path == ha::CODEX_TARGET_HOOKS)
        .expect("hooks.json present");
    assert_eq!(
        codex_hooks_status_with_profile(Some(&hooks.contents), &LIFELOOP_DIRECT_PROFILE),
        AssetStatus::Present
    );
    assert_eq!(
        codex_hooks_status_with_profile(Some(&hooks.contents), &CCD_COMPAT_PROFILE),
        AssetStatus::Drifted
    );
}

// ============================================================================
// Custom profiles
// ============================================================================

#[test]
fn custom_profile_renders_with_its_own_command_prefix() {
    // A consumer-defined profile (not built in) must round-trip
    // through the same renderers without editing core code.
    static CUSTOM_CLAUDE_EVENTS: &[(&str, &str, &str)] = &[
        ("SessionStart", "on-session-start", "*"),
        ("Stop", "on-agent-end", "*"),
    ];
    static CUSTOM_CODEX_EVENTS: &[(&str, &str, &str, &str)] = &[(
        "SessionStart",
        "on-session-start",
        "*",
        "Custom client status",
    )];
    let custom = LifecycleProfile {
        id: "custom-test",
        claude_command_prefix: "custom-broker --hook ",
        claude_legacy_substrings: &[],
        claude_managed_events: CUSTOM_CLAUDE_EVENTS,
        codex_command_prefix: "custom-broker --codex --hook ",
        codex_managed_events: CUSTOM_CODEX_EVENTS,
    };

    let assets = render_applied_assets_with_profile(
        HostAdapter::Claude,
        IntegrationMode::NativeHook,
        &custom,
    );
    let parsed: Value = serde_json::from_str(&assets[0].contents).expect("valid json");
    let cmd = parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"]
        .as_str()
        .expect("command");
    assert_eq!(cmd, "custom-broker --hook on-session-start");
    // The custom profile only declares two events — the CCD-compat
    // managed event count (5) must NOT leak through.
    assert_eq!(
        parsed["hooks"].as_object().expect("object").len(),
        2,
        "custom profile must drive the event set, not leak CCD-compat events"
    );
}