Skip to main content

lifeloop/source_files/
mod.rs

1//! Model instruction source-file rendering and managed-section apply.
2//!
3//! Lifeloop owns one *managed section* inside each adapter's instruction
4//! source file (`AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `HERMES.md`,
5//! `OPENCLAW.md`). The user owns the rest of the file. The managed
6//! section carries lifecycle integration metadata only — adapter id,
7//! integration mode, hook timing summary, host integration asset paths,
8//! template version, and one opaque client-supplied slot.
9//!
10//! # Boundary (issue #16)
11//!
12//! This module owns:
13//! * rendering managed-section bodies for each (adapter, integration
14//!   mode) pair,
15//! * idempotent apply against an existing file body — create, update,
16//!   no-op, stale-section replace,
17//! * fail-closed detection of malformed / multiply-managed / unbalanced
18//!   marker pairs.
19//!
20//! This module does **not** own:
21//! * filesystem IO. Callers handle reads, writes, and atomic replace.
22//!   This module operates on `&str` / `String` exclusively.
23//! * client semantics. The client slot is an opaque `String` rendered
24//!   verbatim; Lifeloop never parses it.
25//! * generated mirrors (e.g. CCD's `CLAUDE.md` mirrored from
26//!   `AGENTS.md`). That is a client concern.
27//! * host integration assets (`.claude/settings.json`,
28//!   `.codex/config.toml`, etc.). Those live in
29//!   [`crate::host_assets`].
30//!
31//! # Sentinel format
32//!
33//! Managed sections are bracketed by stable markers:
34//!
35//! ```text
36//! <!-- LIFELOOP:BEGIN managed-section v=<N> adapter=<id> -->
37//! ...managed body...
38//! <!-- LIFELOOP:END managed-section -->
39//! ```
40//!
41//! See [`docs/harness-concepts/source-files.md`] for the boundary and
42//! decisions. The marker shape is part of the public source-file
43//! contract.
44
45mod adapters;
46mod markers;
47
48pub use adapters::{SourceFileAdapter, TEMPLATE_VERSION};
49
50use crate::IntegrationMode;
51
52// ============================================================================
53// Public types
54// ============================================================================
55
56/// A rendered source file: the path hint Lifeloop owns, the managed
57/// section's begin/end marker text, and the body Lifeloop would write
58/// inside the markers. Pure data — the caller decides how to write it.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct RenderedSourceFile {
61    /// Repo-relative path Lifeloop owns the managed section for. The
62    /// caller resolves the absolute path.
63    pub relative_path: &'static str,
64    /// Adapter id this rendering targets.
65    pub adapter_id: &'static str,
66    /// Template version — bumped when the body shape changes in a way
67    /// that requires stale-section replacement.
68    pub template_version: u32,
69    /// The begin marker line (no trailing newline).
70    pub begin_marker: String,
71    /// The end marker line (no trailing newline).
72    pub end_marker: String,
73    /// The full block, begin marker through end marker, terminated by a
74    /// newline. This is what the apply layer compares for no-op
75    /// detection and substitutes for stale-section replacement.
76    pub managed_block: String,
77}
78
79/// Outcome of [`apply`]. Exhaustive; callers match all variants for
80/// migration messaging.
81#[derive(Clone, Copy, Debug, Eq, PartialEq)]
82pub enum ApplyOutcome {
83    /// File did not previously exist; the full file was rendered.
84    Created,
85    /// File existed; managed section was canonical. No write needed.
86    NoOp,
87    /// File existed; managed section drifted (content differs but
88    /// template version matched). Section was rewritten.
89    Updated,
90    /// File existed; managed section's `template_version` was older
91    /// than [`TEMPLATE_VERSION`]. Section was replaced deterministically.
92    StaleReplaced,
93}
94
95/// Errors apply returns when the existing file body is incompatible
96/// with the managed-section invariant. Apply never silently overwrites
97/// user-authored content.
98#[derive(Clone, Debug, PartialEq, Eq)]
99pub enum ApplyError {
100    /// A `LIFELOOP:BEGIN` marker was found without a matching
101    /// `LIFELOOP:END` marker downstream.
102    UnbalancedMarkers,
103    /// More than one managed section was found in the same file. One
104    /// managed section per file is the contract.
105    MultipleManagedSections,
106    /// The begin marker references an adapter id that does not match
107    /// the adapter being rendered. Clients should pick a different
108    /// adapter or remove the existing section first.
109    AdapterMismatch {
110        existing: String,
111        rendering: &'static str,
112    },
113    /// The begin marker references a `template_version` newer than
114    /// [`TEMPLATE_VERSION`] — the file was written by a newer Lifeloop.
115    /// Apply refuses rather than downgrading.
116    NewerTemplateVersion { existing: u32, current: u32 },
117}
118
119impl std::fmt::Display for ApplyError {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            Self::UnbalancedMarkers => {
123                f.write_str("managed-section begin marker has no matching end marker")
124            }
125            Self::MultipleManagedSections => {
126                f.write_str("more than one Lifeloop managed section in file")
127            }
128            Self::AdapterMismatch {
129                existing,
130                rendering,
131            } => write!(
132                f,
133                "existing managed section is for adapter `{existing}`, rendering for `{rendering}`",
134            ),
135            Self::NewerTemplateVersion { existing, current } => write!(
136                f,
137                "existing managed section has template version {existing}, this Lifeloop renders v{current}",
138            ),
139        }
140    }
141}
142
143impl std::error::Error for ApplyError {}
144
145/// Result of [`apply`]: the new file body the caller should write, plus
146/// the outcome classification. `body` is `None` on [`ApplyOutcome::NoOp`]
147/// — the caller should skip the write entirely.
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct ApplyResult {
150    pub outcome: ApplyOutcome,
151    pub body: Option<String>,
152}
153
154// ============================================================================
155// Render
156// ============================================================================
157
158/// Render the managed source file for `adapter` in `integration_mode`.
159/// `client_slot` is an opaque, optional client-supplied body. Lifeloop
160/// embeds it verbatim inside the managed section between
161/// `<!-- CLIENT-SLOT:BEGIN -->` and `<!-- CLIENT-SLOT:END -->`
162/// sub-markers. Lifeloop never parses or interprets the slot.
163pub fn render_for(
164    adapter: SourceFileAdapter,
165    integration_mode: IntegrationMode,
166    client_slot: Option<&str>,
167) -> RenderedSourceFile {
168    let begin_marker = markers::render_begin(adapter.as_str(), TEMPLATE_VERSION);
169    let end_marker = markers::END_MARKER.to_owned();
170
171    let mut body = String::new();
172    body.push_str(&begin_marker);
173    body.push('\n');
174    body.push('\n');
175    body.push_str("Lifeloop manages this section. Edits between these markers are\n");
176    body.push_str("overwritten on the next `lifeloop` apply. Edit anywhere outside\n");
177    body.push_str("the markers freely; Lifeloop will not touch user-authored content.\n");
178    body.push('\n');
179    body.push_str(&format!("- adapter: `{}`\n", adapter.as_str()));
180    body.push_str(&format!(
181        "- integration_mode: `{}`\n",
182        integration_mode_as_str(integration_mode)
183    ));
184    body.push_str(&format!(
185        "- mode_summary: {}\n",
186        adapters::describe_integration_mode(integration_mode)
187    ));
188    body.push_str(&format!("- template_version: {TEMPLATE_VERSION}\n"));
189    body.push('\n');
190    body.push_str("Lifecycle event timing:\n");
191    for line in adapters::lifecycle_timing_summary(adapter, integration_mode) {
192        body.push_str(&format!("- {line}\n"));
193    }
194    body.push('\n');
195    let assets = adapters::host_asset_paths(adapter, integration_mode);
196    if assets.is_empty() {
197        body.push_str("Host integration assets: none for this mode.\n");
198    } else {
199        body.push_str("Host integration assets:\n");
200        for path in assets {
201            body.push_str(&format!("- `{path}`\n"));
202        }
203    }
204    body.push('\n');
205    body.push_str("<!-- CLIENT-SLOT:BEGIN -->\n");
206    if let Some(slot) = client_slot {
207        body.push_str(slot);
208        if !slot.ends_with('\n') {
209            body.push('\n');
210        }
211    }
212    body.push_str("<!-- CLIENT-SLOT:END -->\n");
213    body.push('\n');
214    body.push_str(&end_marker);
215    body.push('\n');
216
217    RenderedSourceFile {
218        relative_path: adapter.relative_path(),
219        adapter_id: adapter.as_str(),
220        template_version: TEMPLATE_VERSION,
221        begin_marker,
222        end_marker,
223        managed_block: body,
224    }
225}
226
227fn integration_mode_as_str(mode: IntegrationMode) -> &'static str {
228    match mode {
229        IntegrationMode::ManualSkill => "manual_skill",
230        IntegrationMode::LauncherWrapper => "launcher_wrapper",
231        IntegrationMode::NativeHook => "native_hook",
232        IntegrationMode::ReferenceAdapter => "reference_adapter",
233        IntegrationMode::TelemetryOnly => "telemetry_only",
234    }
235}
236
237// ============================================================================
238// Apply
239// ============================================================================
240
241/// Apply `rendered` against `existing` (the current file body, or
242/// `None` if the file does not exist). Returns the new body to write
243/// and a classification of what changed.
244///
245/// Algorithm:
246/// 1. If `existing` is `None`, return `Created` with the managed block
247///    as the entire body.
248/// 2. Locate one managed-section begin/end pair. Zero pairs -> append
249///    the managed block to the file. Multiple pairs -> error.
250/// 3. Validate the existing begin marker: adapter id matches and
251///    `template_version` is not newer than ours.
252/// 4. If the existing block byte-equals `rendered.managed_block`,
253///    return `NoOp`.
254/// 5. If the existing `template_version` is older, return
255///    `StaleReplaced` with the section substituted.
256/// 6. Otherwise, return `Updated` with the section substituted.
257pub fn apply(
258    existing: Option<&str>,
259    rendered: &RenderedSourceFile,
260) -> Result<ApplyResult, ApplyError> {
261    let Some(body) = existing else {
262        return Ok(ApplyResult {
263            outcome: ApplyOutcome::Created,
264            body: Some(rendered.managed_block.clone()),
265        });
266    };
267
268    let section = locate_managed_section(body)?;
269
270    let Some(section) = section else {
271        // No managed section yet — append. Preserve trailing newline
272        // discipline: ensure the file ends with a newline before the
273        // managed block so we don't run user content into our marker.
274        let mut new_body = String::with_capacity(body.len() + rendered.managed_block.len() + 1);
275        new_body.push_str(body);
276        if !body.is_empty() && !body.ends_with('\n') {
277            new_body.push('\n');
278        }
279        if !body.is_empty() && !body.ends_with("\n\n") {
280            new_body.push('\n');
281        }
282        new_body.push_str(&rendered.managed_block);
283        return Ok(ApplyResult {
284            outcome: ApplyOutcome::Created,
285            body: Some(new_body),
286        });
287    };
288
289    // Validate adapter id matches.
290    if section.meta.adapter_id != rendered.adapter_id {
291        return Err(ApplyError::AdapterMismatch {
292            existing: section.meta.adapter_id,
293            rendering: rendered.adapter_id,
294        });
295    }
296
297    // Reject newer template versions: refuse to downgrade.
298    if section.meta.template_version > rendered.template_version {
299        return Err(ApplyError::NewerTemplateVersion {
300            existing: section.meta.template_version,
301            current: rendered.template_version,
302        });
303    }
304
305    // No-op short-circuit: existing section text matches what we'd render.
306    if section.full_text == rendered.managed_block {
307        return Ok(ApplyResult {
308            outcome: ApplyOutcome::NoOp,
309            body: None,
310        });
311    }
312
313    let outcome = if section.meta.template_version < rendered.template_version {
314        ApplyOutcome::StaleReplaced
315    } else {
316        ApplyOutcome::Updated
317    };
318
319    let mut new_body = String::with_capacity(body.len());
320    new_body.push_str(&body[..section.start_byte]);
321    new_body.push_str(&rendered.managed_block);
322    new_body.push_str(&body[section.end_byte..]);
323
324    Ok(ApplyResult {
325        outcome,
326        body: Some(new_body),
327    })
328}
329
330// ============================================================================
331// Locate
332// ============================================================================
333
334#[derive(Debug)]
335struct LocatedSection {
336    /// Byte offset of the begin-marker line start in the source body.
337    start_byte: usize,
338    /// Byte offset one past the trailing newline of the end-marker
339    /// line (or end of file when no trailing newline).
340    end_byte: usize,
341    /// Exact substring [start_byte, end_byte) — used for byte-equal
342    /// no-op comparison.
343    full_text: String,
344    meta: markers::BeginMeta,
345}
346
347/// Walk `body` line-by-line and locate exactly zero or one
348/// managed-section pair. Multiple pairs are an error.
349fn locate_managed_section(body: &str) -> Result<Option<LocatedSection>, ApplyError> {
350    let mut found: Option<LocatedSection> = None;
351    let mut byte_cursor = 0usize;
352    let mut state: Option<(usize, markers::BeginMeta)> = None;
353
354    for line in body.split_inclusive('\n') {
355        let line_start = byte_cursor;
356        let line_end = byte_cursor + line.len();
357        byte_cursor = line_end;
358
359        // Strip the trailing '\n' (if any) for marker matching.
360        let content = line.strip_suffix('\n').unwrap_or(line);
361
362        if let Some(meta) = markers::parse_begin(content) {
363            if state.is_some() {
364                // A second begin before an end — treat as malformed.
365                return Err(ApplyError::UnbalancedMarkers);
366            }
367            state = Some((line_start, meta));
368            continue;
369        }
370
371        if markers::is_end(content) {
372            let Some((begin_start, meta)) = state.take() else {
373                // End marker with no matching begin: tolerate as user
374                // content (the marker text might be quoted in prose);
375                // we keep walking. A stricter policy would error here,
376                // but practical files quote our markers in
377                // documentation. Apply only fails closed when a *begin*
378                // marker is unbalanced.
379                continue;
380            };
381            let full_text = body[begin_start..line_end].to_owned();
382            let located = LocatedSection {
383                start_byte: begin_start,
384                end_byte: line_end,
385                full_text,
386                meta,
387            };
388            if found.is_some() {
389                return Err(ApplyError::MultipleManagedSections);
390            }
391            found = Some(located);
392        }
393    }
394
395    if state.is_some() {
396        return Err(ApplyError::UnbalancedMarkers);
397    }
398
399    Ok(found)
400}