lifeloop-cli 0.1.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Model instruction source-file rendering and managed-section apply.
//!
//! Lifeloop owns one *managed section* inside each adapter's instruction
//! source file (`AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `HERMES.md`,
//! `OPENCLAW.md`). The user owns the rest of the file. The managed
//! section carries lifecycle integration metadata only — adapter id,
//! integration mode, hook timing summary, host integration asset paths,
//! template version, and one opaque client-supplied slot.
//!
//! # Boundary (issue #16)
//!
//! This module owns:
//! * rendering managed-section bodies for each (adapter, integration
//!   mode) pair,
//! * idempotent apply against an existing file body — create, update,
//!   no-op, stale-section replace,
//! * fail-closed detection of malformed / multiply-managed / unbalanced
//!   marker pairs.
//!
//! This module does **not** own:
//! * filesystem IO. Callers handle reads, writes, and atomic replace.
//!   This module operates on `&str` / `String` exclusively.
//! * client semantics. The client slot is an opaque `String` rendered
//!   verbatim; Lifeloop never parses it.
//! * generated mirrors (e.g. CCD's `CLAUDE.md` mirrored from
//!   `AGENTS.md`). That is a client concern.
//! * host integration assets (`.claude/settings.json`,
//!   `.codex/config.toml`, etc.). Those live in
//!   [`crate::host_assets`].
//!
//! # Sentinel format
//!
//! Managed sections are bracketed by stable markers:
//!
//! ```text
//! <!-- LIFELOOP:BEGIN managed-section v=<N> adapter=<id> -->
//! ...managed body...
//! <!-- LIFELOOP:END managed-section -->
//! ```
//!
//! See [`docs/harness-concepts/source-files.md`] for the boundary and
//! decisions. The marker shape is part of the public source-file
//! contract.

mod adapters;
mod markers;

pub use adapters::{SourceFileAdapter, TEMPLATE_VERSION};

use crate::IntegrationMode;

// ============================================================================
// Public types
// ============================================================================

/// A rendered source file: the path hint Lifeloop owns, the managed
/// section's begin/end marker text, and the body Lifeloop would write
/// inside the markers. Pure data — the caller decides how to write it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenderedSourceFile {
    /// Repo-relative path Lifeloop owns the managed section for. The
    /// caller resolves the absolute path.
    pub relative_path: &'static str,
    /// Adapter id this rendering targets.
    pub adapter_id: &'static str,
    /// Template version — bumped when the body shape changes in a way
    /// that requires stale-section replacement.
    pub template_version: u32,
    /// The begin marker line (no trailing newline).
    pub begin_marker: String,
    /// The end marker line (no trailing newline).
    pub end_marker: String,
    /// The full block, begin marker through end marker, terminated by a
    /// newline. This is what the apply layer compares for no-op
    /// detection and substitutes for stale-section replacement.
    pub managed_block: String,
}

/// Outcome of [`apply`]. Exhaustive; callers match all variants for
/// migration messaging.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ApplyOutcome {
    /// File did not previously exist; the full file was rendered.
    Created,
    /// File existed; managed section was canonical. No write needed.
    NoOp,
    /// File existed; managed section drifted (content differs but
    /// template version matched). Section was rewritten.
    Updated,
    /// File existed; managed section's `template_version` was older
    /// than [`TEMPLATE_VERSION`]. Section was replaced deterministically.
    StaleReplaced,
}

/// Errors apply returns when the existing file body is incompatible
/// with the managed-section invariant. Apply never silently overwrites
/// user-authored content.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ApplyError {
    /// A `LIFELOOP:BEGIN` marker was found without a matching
    /// `LIFELOOP:END` marker downstream.
    UnbalancedMarkers,
    /// More than one managed section was found in the same file. One
    /// managed section per file is the contract.
    MultipleManagedSections,
    /// The begin marker references an adapter id that does not match
    /// the adapter being rendered. Clients should pick a different
    /// adapter or remove the existing section first.
    AdapterMismatch {
        existing: String,
        rendering: &'static str,
    },
    /// The begin marker references a `template_version` newer than
    /// [`TEMPLATE_VERSION`] — the file was written by a newer Lifeloop.
    /// Apply refuses rather than downgrading.
    NewerTemplateVersion { existing: u32, current: u32 },
}

impl std::fmt::Display for ApplyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::UnbalancedMarkers => {
                f.write_str("managed-section begin marker has no matching end marker")
            }
            Self::MultipleManagedSections => {
                f.write_str("more than one Lifeloop managed section in file")
            }
            Self::AdapterMismatch {
                existing,
                rendering,
            } => write!(
                f,
                "existing managed section is for adapter `{existing}`, rendering for `{rendering}`",
            ),
            Self::NewerTemplateVersion { existing, current } => write!(
                f,
                "existing managed section has template version {existing}, this Lifeloop renders v{current}",
            ),
        }
    }
}

impl std::error::Error for ApplyError {}

/// Result of [`apply`]: the new file body the caller should write, plus
/// the outcome classification. `body` is `None` on [`ApplyOutcome::NoOp`]
/// — the caller should skip the write entirely.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ApplyResult {
    pub outcome: ApplyOutcome,
    pub body: Option<String>,
}

// ============================================================================
// Render
// ============================================================================

/// Render the managed source file for `adapter` in `integration_mode`.
/// `client_slot` is an opaque, optional client-supplied body. Lifeloop
/// embeds it verbatim inside the managed section between
/// `<!-- CLIENT-SLOT:BEGIN -->` and `<!-- CLIENT-SLOT:END -->`
/// sub-markers. Lifeloop never parses or interprets the slot.
pub fn render_for(
    adapter: SourceFileAdapter,
    integration_mode: IntegrationMode,
    client_slot: Option<&str>,
) -> RenderedSourceFile {
    let begin_marker = markers::render_begin(adapter.as_str(), TEMPLATE_VERSION);
    let end_marker = markers::END_MARKER.to_owned();

    let mut body = String::new();
    body.push_str(&begin_marker);
    body.push('\n');
    body.push('\n');
    body.push_str("Lifeloop manages this section. Edits between these markers are\n");
    body.push_str("overwritten on the next `lifeloop` apply. Edit anywhere outside\n");
    body.push_str("the markers freely; Lifeloop will not touch user-authored content.\n");
    body.push('\n');
    body.push_str(&format!("- adapter: `{}`\n", adapter.as_str()));
    body.push_str(&format!(
        "- integration_mode: `{}`\n",
        integration_mode_as_str(integration_mode)
    ));
    body.push_str(&format!(
        "- mode_summary: {}\n",
        adapters::describe_integration_mode(integration_mode)
    ));
    body.push_str(&format!("- template_version: {TEMPLATE_VERSION}\n"));
    body.push('\n');
    body.push_str("Lifecycle event timing:\n");
    for line in adapters::lifecycle_timing_summary(adapter, integration_mode) {
        body.push_str(&format!("- {line}\n"));
    }
    body.push('\n');
    let assets = adapters::host_asset_paths(adapter, integration_mode);
    if assets.is_empty() {
        body.push_str("Host integration assets: none for this mode.\n");
    } else {
        body.push_str("Host integration assets:\n");
        for path in assets {
            body.push_str(&format!("- `{path}`\n"));
        }
    }
    body.push('\n');
    body.push_str("<!-- CLIENT-SLOT:BEGIN -->\n");
    if let Some(slot) = client_slot {
        body.push_str(slot);
        if !slot.ends_with('\n') {
            body.push('\n');
        }
    }
    body.push_str("<!-- CLIENT-SLOT:END -->\n");
    body.push('\n');
    body.push_str(&end_marker);
    body.push('\n');

    RenderedSourceFile {
        relative_path: adapter.relative_path(),
        adapter_id: adapter.as_str(),
        template_version: TEMPLATE_VERSION,
        begin_marker,
        end_marker,
        managed_block: body,
    }
}

fn integration_mode_as_str(mode: IntegrationMode) -> &'static str {
    match mode {
        IntegrationMode::ManualSkill => "manual_skill",
        IntegrationMode::LauncherWrapper => "launcher_wrapper",
        IntegrationMode::NativeHook => "native_hook",
        IntegrationMode::ReferenceAdapter => "reference_adapter",
        IntegrationMode::TelemetryOnly => "telemetry_only",
    }
}

// ============================================================================
// Apply
// ============================================================================

/// Apply `rendered` against `existing` (the current file body, or
/// `None` if the file does not exist). Returns the new body to write
/// and a classification of what changed.
///
/// Algorithm:
/// 1. If `existing` is `None`, return `Created` with the managed block
///    as the entire body.
/// 2. Locate one managed-section begin/end pair. Zero pairs -> append
///    the managed block to the file. Multiple pairs -> error.
/// 3. Validate the existing begin marker: adapter id matches and
///    `template_version` is not newer than ours.
/// 4. If the existing block byte-equals `rendered.managed_block`,
///    return `NoOp`.
/// 5. If the existing `template_version` is older, return
///    `StaleReplaced` with the section substituted.
/// 6. Otherwise, return `Updated` with the section substituted.
pub fn apply(
    existing: Option<&str>,
    rendered: &RenderedSourceFile,
) -> Result<ApplyResult, ApplyError> {
    let Some(body) = existing else {
        return Ok(ApplyResult {
            outcome: ApplyOutcome::Created,
            body: Some(rendered.managed_block.clone()),
        });
    };

    let section = locate_managed_section(body)?;

    let Some(section) = section else {
        // No managed section yet — append. Preserve trailing newline
        // discipline: ensure the file ends with a newline before the
        // managed block so we don't run user content into our marker.
        let mut new_body = String::with_capacity(body.len() + rendered.managed_block.len() + 1);
        new_body.push_str(body);
        if !body.is_empty() && !body.ends_with('\n') {
            new_body.push('\n');
        }
        if !body.is_empty() && !body.ends_with("\n\n") {
            new_body.push('\n');
        }
        new_body.push_str(&rendered.managed_block);
        return Ok(ApplyResult {
            outcome: ApplyOutcome::Created,
            body: Some(new_body),
        });
    };

    // Validate adapter id matches.
    if section.meta.adapter_id != rendered.adapter_id {
        return Err(ApplyError::AdapterMismatch {
            existing: section.meta.adapter_id,
            rendering: rendered.adapter_id,
        });
    }

    // Reject newer template versions: refuse to downgrade.
    if section.meta.template_version > rendered.template_version {
        return Err(ApplyError::NewerTemplateVersion {
            existing: section.meta.template_version,
            current: rendered.template_version,
        });
    }

    // No-op short-circuit: existing section text matches what we'd render.
    if section.full_text == rendered.managed_block {
        return Ok(ApplyResult {
            outcome: ApplyOutcome::NoOp,
            body: None,
        });
    }

    let outcome = if section.meta.template_version < rendered.template_version {
        ApplyOutcome::StaleReplaced
    } else {
        ApplyOutcome::Updated
    };

    let mut new_body = String::with_capacity(body.len());
    new_body.push_str(&body[..section.start_byte]);
    new_body.push_str(&rendered.managed_block);
    new_body.push_str(&body[section.end_byte..]);

    Ok(ApplyResult {
        outcome,
        body: Some(new_body),
    })
}

// ============================================================================
// Locate
// ============================================================================

#[derive(Debug)]
struct LocatedSection {
    /// Byte offset of the begin-marker line start in the source body.
    start_byte: usize,
    /// Byte offset one past the trailing newline of the end-marker
    /// line (or end of file when no trailing newline).
    end_byte: usize,
    /// Exact substring [start_byte, end_byte) — used for byte-equal
    /// no-op comparison.
    full_text: String,
    meta: markers::BeginMeta,
}

/// Walk `body` line-by-line and locate exactly zero or one
/// managed-section pair. Multiple pairs are an error.
fn locate_managed_section(body: &str) -> Result<Option<LocatedSection>, ApplyError> {
    let mut found: Option<LocatedSection> = None;
    let mut byte_cursor = 0usize;
    let mut state: Option<(usize, markers::BeginMeta)> = None;

    for line in body.split_inclusive('\n') {
        let line_start = byte_cursor;
        let line_end = byte_cursor + line.len();
        byte_cursor = line_end;

        // Strip the trailing '\n' (if any) for marker matching.
        let content = line.strip_suffix('\n').unwrap_or(line);

        if let Some(meta) = markers::parse_begin(content) {
            if state.is_some() {
                // A second begin before an end — treat as malformed.
                return Err(ApplyError::UnbalancedMarkers);
            }
            state = Some((line_start, meta));
            continue;
        }

        if markers::is_end(content) {
            let Some((begin_start, meta)) = state.take() else {
                // End marker with no matching begin: tolerate as user
                // content (the marker text might be quoted in prose);
                // we keep walking. A stricter policy would error here,
                // but practical files quote our markers in
                // documentation. Apply only fails closed when a *begin*
                // marker is unbalanced.
                continue;
            };
            let full_text = body[begin_start..line_end].to_owned();
            let located = LocatedSection {
                start_byte: begin_start,
                end_byte: line_end,
                full_text,
                meta,
            };
            if found.is_some() {
                return Err(ApplyError::MultipleManagedSections);
            }
            found = Some(located);
        }
    }

    if state.is_some() {
        return Err(ApplyError::UnbalancedMarkers);
    }

    Ok(found)
}