Skip to main content

ralph_workflow/files/io/
agent_files.rs

1//! Agent file management for the `.agent/` directory.
2//!
3//! This module handles creation, modification, and cleanup of files
4//! in the `.agent/` directory that are used during pipeline execution.
5
6use std::fs;
7use std::io::{self, BufRead};
8use std::path::Path;
9
10use crate::workspace::Workspace;
11
12use super::{
13    context::overwrite_one_liner, context::VAGUE_ISSUES_LINE, context::VAGUE_NOTES_LINE,
14    context::VAGUE_STATUS_LINE, integrity, recovery,
15};
16
17/// XSD schemas for XML validation - included at compile time.
18/// These are written to `.agent/xsd/` at pipeline start for agent self-validation.
19const PLAN_XSD_SCHEMA: &str = include_str!("../llm_output_extraction/plan.xsd");
20const DEVELOPMENT_RESULT_XSD_SCHEMA: &str =
21    include_str!("../llm_output_extraction/development_result.xsd");
22const ISSUES_XSD_SCHEMA: &str = include_str!("../llm_output_extraction/issues.xsd");
23const FIX_RESULT_XSD_SCHEMA: &str = include_str!("../llm_output_extraction/fix_result.xsd");
24const COMMIT_MESSAGE_XSD_SCHEMA: &str = include_str!("../llm_output_extraction/commit_message.xsd");
25
26/// Files that Ralph generates during a run and should clean up.
27pub const GENERATED_FILES: &[&str] = &[
28    ".no_agent_commit",
29    ".agent/PLAN.md",
30    ".agent/commit-message.txt",
31    ".agent/checkpoint.json.tmp",
32];
33
34/// Check if a file contains a specific marker string.
35///
36/// Useful for detecting specific content patterns in files without
37/// loading the entire file into memory.
38///
39/// # Arguments
40///
41/// * `file_path` - Path to the file to check
42/// * `marker` - String to search for
43///
44/// # Returns
45///
46/// `Ok(true)` if the marker is found, `Ok(false)` if not found or file doesn't exist.
47pub fn file_contains_marker(file_path: &Path, marker: &str) -> io::Result<bool> {
48    if !file_path.exists() {
49        return Ok(false);
50    }
51
52    let file = fs::File::open(file_path)?;
53    let reader = io::BufReader::new(file);
54
55    for line in reader.lines().map_while(Result::ok) {
56        if line.contains(marker) {
57            return Ok(true);
58        }
59    }
60
61    Ok(false)
62}
63
64/// Ensure required files and directories exist.
65///
66/// Creates the `.agent/logs` and `.agent/tmp` directories if they don't exist.
67/// Also writes XSD schemas to `.agent/tmp/` for agent self-validation.
68///
69/// When `isolation_mode` is true (the default), STATUS.md, NOTES.md and ISSUES.md
70/// are NOT created. This prevents context contamination from previous runs.
71///
72/// **Note:** This function uses the current working directory for paths.
73/// For explicit path control, use [`ensure_files_at`] instead.
74pub fn ensure_files(isolation_mode: bool) -> io::Result<()> {
75    ensure_files_at(Path::new("."), isolation_mode)
76}
77
78/// Ensure required files and directories exist at a specific repository path.
79///
80/// Creates the `.agent/logs` and `.agent/tmp` directories if they don't exist.
81/// Also writes XSD schemas to `.agent/tmp/` for agent self-validation.
82///
83/// When `isolation_mode` is true (the default), STATUS.md, NOTES.md and ISSUES.md
84/// are NOT created. This prevents context contamination from previous runs.
85///
86/// # Arguments
87///
88/// * `repo_root` - Path to the repository root
89/// * `isolation_mode` - If true, skip creating STATUS.md, NOTES.md, ISSUES.md
90pub fn ensure_files_at(repo_root: &Path, isolation_mode: bool) -> io::Result<()> {
91    let agent_dir = repo_root.join(".agent");
92
93    // Best-effort state repair before we start touching `.agent/` contents.
94    // If the state is unrecoverable, fail early with a clear error.
95    if let recovery::RecoveryStatus::Unrecoverable(msg) = recovery::auto_repair(&agent_dir)? {
96        return Err(io::Error::other(format!(
97            "Failed to repair .agent state: {msg}"
98        )));
99    }
100
101    integrity::check_filesystem_ready(&agent_dir)?;
102    fs::create_dir_all(agent_dir.join("logs"))?;
103    fs::create_dir_all(agent_dir.join("tmp"))?;
104
105    // Clean up any stale XML files from previous runs that might be locked
106    // This prevents permission errors when agents try to write to these files
107    let tmp_dir = agent_dir.join("tmp");
108    let _ = integrity::cleanup_stale_xml_files(&tmp_dir, false);
109    // Note: cleanup is best-effort, failures are not fatal
110
111    // Write XSD schemas to .agent/tmp/ for agent self-validation
112    setup_xsd_schemas_at(repo_root)?;
113
114    // Only create STATUS.md, NOTES.md and ISSUES.md when NOT in isolation mode
115    if !isolation_mode {
116        // Always overwrite/truncate these files to a single vague sentence to
117        // avoid detailed context persisting across runs.
118        overwrite_one_liner(&agent_dir.join("STATUS.md"), VAGUE_STATUS_LINE)?;
119        overwrite_one_liner(&agent_dir.join("NOTES.md"), VAGUE_NOTES_LINE)?;
120        overwrite_one_liner(&agent_dir.join("ISSUES.md"), VAGUE_ISSUES_LINE)?;
121    }
122
123    Ok(())
124}
125
126/// Write all XSD schemas to `.agent/xsd/` directory.
127///
128/// This is called at pipeline startup so agents can use `xmllint` for self-validation
129/// during XML generation. The schemas are the authoritative definitions of valid XML
130/// structure for each phase.
131///
132/// # Schema Files
133///
134/// - `plan.xsd` - Planning phase XML structure
135/// - `development_result.xsd` - Development iteration result structure
136/// - `issues.xsd` - Review phase issues structure
137/// - `fix_result.xsd` - Fix phase result structure
138/// - `commit_message.xsd` - Commit message structure
139///
140/// **Note:** This function uses the current working directory for paths.
141/// For explicit path control, use [`setup_xsd_schemas_at`] instead.
142pub fn setup_xsd_schemas() -> io::Result<()> {
143    setup_xsd_schemas_at(Path::new("."))
144}
145
146/// Write all XSD schemas to `.agent/xsd/` directory at a specific repository path.
147///
148/// # Arguments
149///
150/// * `repo_root` - Path to the repository root
151pub fn setup_xsd_schemas_at(repo_root: &Path) -> io::Result<()> {
152    let tmp_dir = repo_root.join(".agent/tmp");
153    fs::create_dir_all(&tmp_dir)?;
154
155    fs::write(tmp_dir.join("plan.xsd"), PLAN_XSD_SCHEMA)?;
156    fs::write(
157        tmp_dir.join("development_result.xsd"),
158        DEVELOPMENT_RESULT_XSD_SCHEMA,
159    )?;
160    fs::write(tmp_dir.join("issues.xsd"), ISSUES_XSD_SCHEMA)?;
161    fs::write(tmp_dir.join("fix_result.xsd"), FIX_RESULT_XSD_SCHEMA)?;
162    fs::write(
163        tmp_dir.join("commit_message.xsd"),
164        COMMIT_MESSAGE_XSD_SCHEMA,
165    )?;
166
167    Ok(())
168}
169
170/// Ensure required files and directories exist using workspace.
171///
172/// This is the workspace-based version of [`ensure_files_at`].
173/// Creates the `.agent/logs` and `.agent/tmp` directories if they don't exist.
174/// Also writes XSD schemas to `.agent/tmp/` for agent self-validation.
175///
176/// When `isolation_mode` is true (the default), STATUS.md, NOTES.md and ISSUES.md
177/// are NOT created. This prevents context contamination from previous runs.
178pub fn ensure_files_with_workspace(
179    workspace: &dyn Workspace,
180    isolation_mode: bool,
181) -> io::Result<()> {
182    let agent_dir = Path::new(".agent");
183
184    // Best-effort state repair before we start touching `.agent/` contents.
185    // If the state is unrecoverable, fail early with a clear error.
186    if let recovery::RecoveryStatus::Unrecoverable(msg) =
187        recovery::auto_repair_with_workspace(workspace, agent_dir)?
188    {
189        return Err(io::Error::other(format!(
190            "Failed to repair .agent state: {msg}"
191        )));
192    }
193
194    integrity::check_filesystem_ready_with_workspace(workspace, agent_dir)?;
195    workspace.create_dir_all(&agent_dir.join("logs"))?;
196    workspace.create_dir_all(&agent_dir.join("tmp"))?;
197
198    // Clean up any stale XML files from previous runs that might be locked
199    let tmp_dir = agent_dir.join("tmp");
200    let _ = integrity::cleanup_stale_xml_files_with_workspace(workspace, &tmp_dir, false);
201    // Note: cleanup is best-effort, failures are not fatal
202
203    // Write XSD schemas to .agent/tmp/ for agent self-validation
204    setup_xsd_schemas_with_workspace(workspace)?;
205
206    // Only create STATUS.md, NOTES.md and ISSUES.md when NOT in isolation mode
207    if !isolation_mode {
208        // Always overwrite/truncate these files to a single vague sentence to
209        // avoid detailed context persisting across runs.
210        workspace.write_atomic(
211            &agent_dir.join("STATUS.md"),
212            &format!("{VAGUE_STATUS_LINE}\n"),
213        )?;
214        workspace.write_atomic(
215            &agent_dir.join("NOTES.md"),
216            &format!("{VAGUE_NOTES_LINE}\n"),
217        )?;
218        workspace.write_atomic(
219            &agent_dir.join("ISSUES.md"),
220            &format!("{VAGUE_ISSUES_LINE}\n"),
221        )?;
222    }
223
224    Ok(())
225}
226
227/// Delete the PLAN.md file after integration.
228///
229/// Called after the plan has been integrated into the codebase.
230/// Silently succeeds if the file doesn't exist.
231///
232/// **Note:** This function uses the current working directory for paths.
233/// For explicit path control, use [`delete_plan_file_at`] instead.
234pub fn delete_plan_file() -> io::Result<()> {
235    delete_plan_file_at(Path::new("."))
236}
237
238/// Delete the PLAN.md file after integration at a specific repository path.
239///
240/// # Arguments
241///
242/// * `repo_root` - Path to the repository root
243pub fn delete_plan_file_at(repo_root: &Path) -> io::Result<()> {
244    let plan_path = repo_root.join(".agent/PLAN.md");
245    if plan_path.exists() {
246        fs::remove_file(plan_path)?;
247    }
248    Ok(())
249}
250
251/// Delete the commit-message.txt file after committing.
252///
253/// Called after a successful git commit to clean up the temporary
254/// commit message file. Silently succeeds if the file doesn't exist.
255///
256/// **Note:** This function uses the current working directory for paths.
257/// For explicit path control, use [`delete_commit_message_file_at`] instead.
258pub fn delete_commit_message_file() -> io::Result<()> {
259    delete_commit_message_file_at(Path::new("."))
260}
261
262/// Delete the commit-message.txt file after committing at a specific repository path.
263///
264/// # Arguments
265///
266/// * `repo_root` - Path to the repository root
267pub fn delete_commit_message_file_at(repo_root: &Path) -> io::Result<()> {
268    let msg_path = repo_root.join(".agent/commit-message.txt");
269    if msg_path.exists() {
270        fs::remove_file(msg_path)?;
271    }
272    Ok(())
273}
274
275/// Read commit message from file; fails if missing or empty.
276///
277/// # Errors
278///
279/// Returns an error if the file doesn't exist, cannot be read, or is empty.
280///
281/// **Note:** This function uses the current working directory for paths.
282/// For explicit path control, use [`read_commit_message_file_at`] instead.
283pub fn read_commit_message_file() -> io::Result<String> {
284    read_commit_message_file_at(Path::new("."))
285}
286
287/// Read commit message from file at a specific repository path; fails if missing or empty.
288///
289/// # Arguments
290///
291/// * `repo_root` - Path to the repository root
292///
293/// # Errors
294///
295/// Returns an error if the file doesn't exist, cannot be read, or is empty.
296pub fn read_commit_message_file_at(repo_root: &Path) -> io::Result<String> {
297    let msg_path = repo_root.join(".agent/commit-message.txt");
298    if msg_path.exists() && !integrity::verify_file_not_corrupted(&msg_path)? {
299        return Err(io::Error::new(
300            io::ErrorKind::InvalidData,
301            ".agent/commit-message.txt appears corrupted",
302        ));
303    }
304    let content = fs::read_to_string(&msg_path).map_err(|e| {
305        io::Error::new(
306            e.kind(),
307            format!("Failed to read .agent/commit-message.txt: {e}"),
308        )
309    })?;
310    let trimmed = content.trim();
311    if trimmed.is_empty() {
312        return Err(io::Error::new(
313            io::ErrorKind::InvalidData,
314            ".agent/commit-message.txt is empty",
315        ));
316    }
317    Ok(trimmed.to_string())
318}
319
320/// Write commit message to file.
321///
322/// Creates the .agent directory if it doesn't exist and writes the
323/// commit message to .agent/commit-message.txt.
324///
325/// # Arguments
326///
327/// * `message` - The commit message to write
328///
329/// # Errors
330///
331/// Returns an error if the file cannot be created or written.
332///
333/// **Note:** This function uses the current working directory for paths.
334/// For explicit path control, use [`write_commit_message_file_at`] instead.
335pub fn write_commit_message_file(message: &str) -> io::Result<()> {
336    write_commit_message_file_at(Path::new("."), message)
337}
338
339/// Write commit message to file at a specific repository path.
340///
341/// Creates the .agent directory if it doesn't exist and writes the
342/// commit message to .agent/commit-message.txt.
343///
344/// # Arguments
345///
346/// * `repo_root` - Path to the repository root
347/// * `message` - The commit message to write
348///
349/// # Errors
350///
351/// Returns an error if the file cannot be created or written.
352pub fn write_commit_message_file_at(repo_root: &Path, message: &str) -> io::Result<()> {
353    let msg_path = repo_root.join(".agent/commit-message.txt");
354    if let Some(parent) = msg_path.parent() {
355        fs::create_dir_all(parent)?;
356    }
357    integrity::write_file_atomic(&msg_path, message)?;
358    Ok(())
359}
360
361/// Clean up all generated files.
362///
363/// Removes temporary files that may have been left behind by an interrupted
364/// pipeline run. This includes PLAN.md, commit-message.txt, and other
365/// artifacts listed in [`GENERATED_FILES`].
366///
367/// This function is best-effort: individual file deletion failures are
368/// silently ignored since we're in a cleanup context.
369///
370/// **Note:** This function uses the current working directory for paths.
371/// For explicit path control, use [`cleanup_generated_files_at`] instead.
372pub fn cleanup_generated_files() {
373    cleanup_generated_files_at(Path::new("."))
374}
375
376/// Clean up all generated files at a specific repository path.
377///
378/// Removes temporary files that may have been left behind by an interrupted
379/// pipeline run. This includes PLAN.md, commit-message.txt, and other
380/// artifacts listed in [`GENERATED_FILES`].
381///
382/// This function is best-effort: individual file deletion failures are
383/// silently ignored since we're in a cleanup context.
384///
385/// # Arguments
386///
387/// * `repo_root` - Path to the repository root
388pub fn cleanup_generated_files_at(repo_root: &Path) {
389    for file in GENERATED_FILES {
390        let _ = fs::remove_file(repo_root.join(file));
391    }
392}
393
394// ============================================================================
395// Workspace-based functions (for testability with MemoryWorkspace)
396// ============================================================================
397
398/// Check if a file contains a specific marker string using the Workspace trait.
399///
400/// This is the workspace-based version of `file_contains_marker`.
401///
402/// # Arguments
403///
404/// * `workspace` - The workspace for file operations
405/// * `path` - Relative path within the workspace
406/// * `marker` - String to search for
407///
408/// Returns `Ok(true)` if the marker is found, `Ok(false)` if not found or file doesn't exist.
409pub fn file_contains_marker_with_workspace(
410    workspace: &dyn Workspace,
411    path: &Path,
412    marker: &str,
413) -> io::Result<bool> {
414    if !workspace.exists(path) {
415        return Ok(false);
416    }
417
418    let content = workspace.read(path)?;
419    for line in content.lines() {
420        if line.contains(marker) {
421            return Ok(true);
422        }
423    }
424
425    Ok(false)
426}
427
428/// Delete the PLAN.md file using the workspace.
429///
430/// This is the workspace-based version of `delete_plan_file_at`.
431pub fn delete_plan_file_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
432    let plan_path = Path::new(".agent/PLAN.md");
433    if workspace.exists(plan_path) {
434        workspace.remove(plan_path)?;
435    }
436    Ok(())
437}
438
439/// Delete the commit-message.txt file using the workspace.
440///
441/// This is the workspace-based version of `delete_commit_message_file_at`.
442pub fn delete_commit_message_file_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
443    let msg_path = Path::new(".agent/commit-message.txt");
444    if workspace.exists(msg_path) {
445        workspace.remove(msg_path)?;
446    }
447    Ok(())
448}
449
450/// Read commit message from file using the workspace.
451///
452/// This is the workspace-based version of `read_commit_message_file_at`.
453///
454/// # Errors
455///
456/// Returns an error if the file doesn't exist, cannot be read, or is empty.
457pub fn read_commit_message_file_with_workspace(workspace: &dyn Workspace) -> io::Result<String> {
458    let msg_path = Path::new(".agent/commit-message.txt");
459
460    if workspace.exists(msg_path) {
461        // Use workspace-based verification
462        if !super::integrity::verify_file_not_corrupted_with_workspace(workspace, msg_path)? {
463            return Err(io::Error::new(
464                io::ErrorKind::InvalidData,
465                ".agent/commit-message.txt appears corrupted",
466            ));
467        }
468    }
469
470    let content = workspace.read(msg_path).map_err(|e| {
471        io::Error::new(
472            e.kind(),
473            format!("Failed to read .agent/commit-message.txt: {e}"),
474        )
475    })?;
476
477    let trimmed = content.trim();
478    if trimmed.is_empty() {
479        return Err(io::Error::new(
480            io::ErrorKind::InvalidData,
481            ".agent/commit-message.txt is empty",
482        ));
483    }
484    Ok(trimmed.to_string())
485}
486
487/// Write commit message to file using the workspace.
488///
489/// Uses atomic write to ensure the file is either fully written or not written
490/// at all, preventing partial writes on crash/interruption.
491///
492/// This is the workspace-based version of `write_commit_message_file_at`.
493pub fn write_commit_message_file_with_workspace(
494    workspace: &dyn Workspace,
495    message: &str,
496) -> io::Result<()> {
497    let msg_path = Path::new(".agent/commit-message.txt");
498    workspace.write_atomic(msg_path, message)
499}
500
501/// Clean up generated files using the workspace.
502///
503/// This is the workspace-based version of `cleanup_generated_files_at`.
504pub fn cleanup_generated_files_with_workspace(workspace: &dyn Workspace) {
505    for file in GENERATED_FILES {
506        let _ = workspace.remove(Path::new(file));
507    }
508}
509
510/// Write XSD schemas to .agent/tmp/ using the workspace.
511///
512/// This is the workspace-based version of `setup_xsd_schemas_at`.
513pub fn setup_xsd_schemas_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
514    let tmp_dir = Path::new(".agent/tmp");
515    workspace.create_dir_all(tmp_dir)?;
516
517    workspace.write(&tmp_dir.join("plan.xsd"), PLAN_XSD_SCHEMA)?;
518    workspace.write(
519        &tmp_dir.join("development_result.xsd"),
520        DEVELOPMENT_RESULT_XSD_SCHEMA,
521    )?;
522    workspace.write(&tmp_dir.join("issues.xsd"), ISSUES_XSD_SCHEMA)?;
523    workspace.write(&tmp_dir.join("fix_result.xsd"), FIX_RESULT_XSD_SCHEMA)?;
524    workspace.write(
525        &tmp_dir.join("commit_message.xsd"),
526        COMMIT_MESSAGE_XSD_SCHEMA,
527    )?;
528
529    Ok(())
530}
531
532#[cfg(test)]
533mod tests {
534    // =========================================================================
535    // Workspace-based tests (for testability without real filesystem)
536    // =========================================================================
537
538    #[cfg(feature = "test-utils")]
539    mod workspace_tests {
540        use super::super::*;
541        use crate::workspace::MemoryWorkspace;
542
543        #[test]
544        fn test_file_contains_marker_with_workspace() {
545            let workspace =
546                MemoryWorkspace::new_test().with_file("test.txt", "line1\nMARKER_TEST\nline3");
547
548            assert!(file_contains_marker_with_workspace(
549                &workspace,
550                Path::new("test.txt"),
551                "MARKER_TEST"
552            )
553            .unwrap());
554            assert!(!file_contains_marker_with_workspace(
555                &workspace,
556                Path::new("test.txt"),
557                "NONEXISTENT"
558            )
559            .unwrap());
560        }
561
562        #[test]
563        fn test_file_contains_marker_with_workspace_missing() {
564            let workspace = MemoryWorkspace::new_test();
565
566            let result = file_contains_marker_with_workspace(
567                &workspace,
568                Path::new("nonexistent.txt"),
569                "MARKER",
570            );
571            assert!(!result.unwrap());
572        }
573
574        #[test]
575        fn test_delete_plan_file_with_workspace() {
576            let workspace = MemoryWorkspace::new_test().with_file(".agent/PLAN.md", "test plan");
577
578            assert!(workspace.exists(Path::new(".agent/PLAN.md")));
579
580            delete_plan_file_with_workspace(&workspace).unwrap();
581
582            assert!(!workspace.exists(Path::new(".agent/PLAN.md")));
583        }
584
585        #[test]
586        fn test_delete_plan_file_with_workspace_nonexistent() {
587            let workspace = MemoryWorkspace::new_test();
588
589            // Should succeed even if file doesn't exist
590            delete_plan_file_with_workspace(&workspace).unwrap();
591        }
592
593        #[test]
594        fn test_read_commit_message_file_with_workspace() {
595            let workspace = MemoryWorkspace::new_test()
596                .with_file(".agent/commit-message.txt", "feat: test commit\n");
597
598            let msg = read_commit_message_file_with_workspace(&workspace).unwrap();
599            assert_eq!(msg, "feat: test commit");
600        }
601
602        #[test]
603        fn test_read_commit_message_file_with_workspace_empty() {
604            let workspace =
605                MemoryWorkspace::new_test().with_file(".agent/commit-message.txt", "   \n");
606
607            assert!(read_commit_message_file_with_workspace(&workspace).is_err());
608        }
609
610        #[test]
611        fn test_write_commit_message_file_with_workspace() {
612            let workspace = MemoryWorkspace::new_test();
613
614            write_commit_message_file_with_workspace(&workspace, "feat: new feature").unwrap();
615
616            assert!(workspace.exists(Path::new(".agent/commit-message.txt")));
617            let content = workspace
618                .read(Path::new(".agent/commit-message.txt"))
619                .unwrap();
620            assert_eq!(content, "feat: new feature");
621        }
622
623        #[test]
624        fn test_delete_commit_message_file_with_workspace() {
625            let workspace =
626                MemoryWorkspace::new_test().with_file(".agent/commit-message.txt", "test message");
627
628            assert!(workspace.exists(Path::new(".agent/commit-message.txt")));
629
630            delete_commit_message_file_with_workspace(&workspace).unwrap();
631
632            assert!(!workspace.exists(Path::new(".agent/commit-message.txt")));
633        }
634
635        #[test]
636        fn test_cleanup_generated_files_with_workspace() {
637            let workspace = MemoryWorkspace::new_test()
638                .with_file(".no_agent_commit", "")
639                .with_file(".agent/PLAN.md", "plan")
640                .with_file(".agent/commit-message.txt", "msg");
641
642            cleanup_generated_files_with_workspace(&workspace);
643
644            assert!(!workspace.exists(Path::new(".no_agent_commit")));
645            assert!(!workspace.exists(Path::new(".agent/PLAN.md")));
646            assert!(!workspace.exists(Path::new(".agent/commit-message.txt")));
647        }
648
649        #[test]
650        fn test_setup_xsd_schemas_with_workspace() {
651            let workspace = MemoryWorkspace::new_test();
652
653            setup_xsd_schemas_with_workspace(&workspace).unwrap();
654
655            // Verify all schemas are written
656            assert!(workspace.exists(Path::new(".agent/tmp/plan.xsd")));
657            assert!(workspace.exists(Path::new(".agent/tmp/development_result.xsd")));
658            assert!(workspace.exists(Path::new(".agent/tmp/issues.xsd")));
659            assert!(workspace.exists(Path::new(".agent/tmp/fix_result.xsd")));
660            assert!(workspace.exists(Path::new(".agent/tmp/commit_message.xsd")));
661
662            // Verify content
663            let plan_xsd = workspace.read(Path::new(".agent/tmp/plan.xsd")).unwrap();
664            assert!(plan_xsd.contains("xs:schema"));
665            assert!(plan_xsd.contains("ralph-plan"));
666        }
667    }
668}