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}