1use std::io::{BufRead, BufReader, Write};
6use std::path::Path;
7use std::process::{Child, Command, Stdio};
8use std::sync::{Arc, Mutex};
9
10use crate::error::{Autom8Error, Result};
11use crate::knowledge::ProjectKnowledge;
12use crate::spec::{Spec, UserStory};
13use crate::state::IterationRecord;
14
15use super::stream::{extract_text_from_stream_line, extract_usage_from_result_line};
16use super::types::{ClaudeErrorInfo, ClaudeOutcome, ClaudeStoryResult, ClaudeUsage};
17use super::utils::{build_knowledge_context, build_previous_context, extract_work_summary};
18
19const COMPLETION_SIGNAL: &str = "<promise>COMPLETE</promise>";
20
21#[derive(Clone)]
27pub struct ClaudeRunner {
28 child: Arc<Mutex<Option<Child>>>,
29}
30
31impl ClaudeRunner {
32 pub fn new() -> Self {
34 Self {
35 child: Arc::new(Mutex::new(None)),
36 }
37 }
38
39 pub fn kill(&self) -> Result<bool> {
48 let mut child_guard = self.child.lock().map_err(|e| {
49 Autom8Error::ClaudeError(format!("Failed to acquire lock for kill: {}", e))
50 })?;
51
52 if let Some(mut child) = child_guard.take() {
53 if let Err(e) = child.kill() {
55 if e.kind() != std::io::ErrorKind::InvalidInput {
57 return Err(Autom8Error::ClaudeError(format!(
58 "Failed to kill Claude subprocess: {}",
59 e
60 )));
61 }
62 }
63 let _ = child.wait();
65 Ok(true)
66 } else {
67 Ok(false)
68 }
69 }
70
71 pub fn is_running(&self) -> bool {
73 self.child
74 .lock()
75 .map(|guard| guard.is_some())
76 .unwrap_or(false)
77 }
78
79 fn set_child(&self, child: Child) -> Result<()> {
81 let mut child_guard = self.child.lock().map_err(|e| {
82 Autom8Error::ClaudeError(format!("Failed to acquire lock for set_child: {}", e))
83 })?;
84 *child_guard = Some(child);
85 Ok(())
86 }
87
88 fn take_child(&self) -> Result<Option<Child>> {
91 let mut child_guard = self.child.lock().map_err(|e| {
92 Autom8Error::ClaudeError(format!("Failed to acquire lock for take_child: {}", e))
93 })?;
94 Ok(child_guard.take())
95 }
96}
97
98impl Default for ClaudeRunner {
99 fn default() -> Self {
100 Self::new()
101 }
102}
103
104impl ClaudeRunner {
105 pub fn run<F>(
120 &self,
121 spec: &Spec,
122 story: &UserStory,
123 spec_path: &Path,
124 previous_iterations: &[IterationRecord],
125 knowledge: &ProjectKnowledge,
126 mut on_output: F,
127 ) -> Result<ClaudeStoryResult>
128 where
129 F: FnMut(&str),
130 {
131 let previous_context = build_previous_context(previous_iterations);
132 let knowledge_context = build_knowledge_context(knowledge);
133 let prompt = build_prompt(
134 spec,
135 story,
136 spec_path,
137 knowledge_context.as_deref(),
138 previous_context.as_deref(),
139 );
140
141 let mut child = Command::new("claude")
142 .args([
143 "--dangerously-skip-permissions",
144 "--print",
145 "--output-format",
146 "stream-json",
147 "--verbose",
148 ])
149 .stdin(Stdio::piped())
150 .stdout(Stdio::piped())
151 .stderr(Stdio::piped())
152 .spawn()
153 .map_err(|e| Autom8Error::ClaudeError(format!("Failed to spawn claude: {}", e)))?;
154
155 if let Some(mut stdin) = child.stdin.take() {
157 stdin.write_all(prompt.as_bytes()).map_err(|e| {
158 Autom8Error::ClaudeError(format!("Failed to write to stdin: {}", e))
159 })?;
160 }
162
163 let stderr = child.stderr.take();
165
166 let stdout = child
168 .stdout
169 .take()
170 .ok_or_else(|| Autom8Error::ClaudeError("Failed to capture stdout".into()))?;
171
172 self.set_child(child)?;
174
175 let reader = BufReader::new(stdout);
177 let mut found_complete = false;
178 let mut accumulated_text = String::new();
179 let mut usage: Option<ClaudeUsage> = None;
180
181 for line in reader.lines() {
182 let line = line.map_err(|e| Autom8Error::ClaudeError(format!("Read error: {}", e)))?;
183
184 if let Some(text) = extract_text_from_stream_line(&line) {
186 on_output(&text);
187 accumulated_text.push_str(&text);
188
189 if text.contains(COMPLETION_SIGNAL) || accumulated_text.contains(COMPLETION_SIGNAL)
190 {
191 found_complete = true;
192 }
193 }
194
195 if let Some(line_usage) = extract_usage_from_result_line(&line) {
197 usage = Some(line_usage);
198 }
199 }
200
201 let child = self.take_child()?;
203
204 if let Some(mut child) = child {
205 let status = child
207 .wait()
208 .map_err(|e| Autom8Error::ClaudeError(format!("Wait error: {}", e)))?;
209
210 if !status.success() {
211 let stderr_content = stderr
213 .map(|s| std::io::read_to_string(s).unwrap_or_default())
214 .unwrap_or_default();
215 let error_info = ClaudeErrorInfo::from_process_failure(
216 status,
217 if stderr_content.is_empty() {
218 None
219 } else {
220 Some(stderr_content)
221 },
222 );
223 return Err(Autom8Error::ClaudeError(error_info.message.clone()));
224 }
225 }
226
227 let work_summary = extract_work_summary(&accumulated_text);
229
230 let outcome = if found_complete {
231 ClaudeOutcome::AllStoriesComplete
232 } else {
233 ClaudeOutcome::IterationComplete
234 };
235
236 Ok(ClaudeStoryResult {
237 outcome,
238 work_summary,
239 full_output: accumulated_text,
240 usage,
241 })
242 }
243}
244
245pub fn run_claude<F>(
251 spec: &Spec,
252 story: &UserStory,
253 spec_path: &Path,
254 previous_iterations: &[IterationRecord],
255 knowledge: &ProjectKnowledge,
256 on_output: F,
257) -> Result<ClaudeStoryResult>
258where
259 F: FnMut(&str),
260{
261 let runner = ClaudeRunner::new();
262 runner.run(
263 spec,
264 story,
265 spec_path,
266 previous_iterations,
267 knowledge,
268 on_output,
269 )
270}
271
272fn build_prompt(
273 spec: &Spec,
274 story: &UserStory,
275 spec_path: &Path,
276 knowledge_context: Option<&str>,
277 previous_context: Option<&str>,
278) -> String {
279 let acceptance_criteria = story
280 .acceptance_criteria
281 .iter()
282 .map(|c| format!("- {}", c))
283 .collect::<Vec<_>>()
284 .join("\n");
285
286 let spec_path_str = spec_path.display();
287
288 let knowledge_section = match knowledge_context {
290 Some(context) => format!(
291 r#"
292## Project Knowledge
293
294{}
295"#,
296 context
297 ),
298 None => String::new(),
299 };
300
301 let previous_work_section = match previous_context {
303 Some(context) => format!(
304 r#"
305## Previous Work
306
307The following user stories have already been completed:
308
309{}
310"#,
311 context
312 ),
313 None => String::new(),
314 };
315
316 format!(
317 r#"You are working on project: {project}
318
319## Current Task
320
321Implement user story **{story_id}: {story_title}**
322
323### Description
324{story_description}
325
326### Acceptance Criteria
327{acceptance_criteria}
328
329## Instructions
330
3311. Implement the user story according to the acceptance criteria
3322. Write tests to verify the implementation if useful
3333. Run the related tests to ensure they pass
3344. After implementation, update `{spec_path}` to set `passes: true` for story {story_id}
335
336## Completion
337
338When ALL user stories in `{spec_path}` have `passes: true`, output exactly:
339<promise>COMPLETE</promise>
340
341This signals that the entire feature is done.
342
343## Work Summary
344
345After completing your implementation, output a brief summary (1-3 sentences) of what you accomplished in this format:
346
347<work-summary>
348Files changed: [list key files]. [Brief description of functionality added/changed].
349</work-summary>
350
351This helps provide context for subsequent tasks.
352
353## Structured Context (Optional)
354
355If helpful for future agents, include any of these optional context blocks:
356
357**Files worked with** (key files and their purpose):
358```
359<files-context>
360path/to/file.rs | Brief purpose description | [key_symbol1, key_symbol2]
361</files-context>
362```
363
364**Architectural decisions** (when you made significant choices):
365```
366<decisions>
367topic | choice made | rationale
368</decisions>
369```
370
371**Patterns established** (conventions future agents should follow):
372```
373<patterns>
374Description of pattern or convention
375</patterns>
376```
377
378These are optional - only include them when they add value for subsequent work.
379
380## Project Context
381
382{spec_description}{knowledge}{previous_work}
383
384## Notes
385{notes}
386"#,
387 project = spec.project,
388 story_id = story.id,
389 story_title = story.title,
390 story_description = story.description,
391 acceptance_criteria = acceptance_criteria,
392 spec_description = spec.description,
393 spec_path = spec_path_str,
394 knowledge = knowledge_section,
395 previous_work = previous_work_section,
396 notes = if story.notes.is_empty() {
397 "None"
398 } else {
399 &story.notes
400 }
401 )
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn test_build_prompt() {
410 let spec = Spec {
411 project: "TestProject".into(),
412 branch_name: "test-branch".into(),
413 description: "A test project".into(),
414 user_stories: vec![],
415 };
416 let story = UserStory {
417 id: "US-001".into(),
418 title: "Test Story".into(),
419 description: "A test story".into(),
420 acceptance_criteria: vec!["Criterion 1".into(), "Criterion 2".into()],
421 priority: 1,
422 passes: false,
423 notes: String::new(),
424 };
425 let spec_path = Path::new("/tmp/spec-test.json");
426
427 let prompt = build_prompt(&spec, &story, spec_path, None, None);
428 assert!(prompt.contains("TestProject"));
429 assert!(prompt.contains("US-001"));
430 assert!(prompt.contains("Criterion 1"));
431 assert!(!prompt.contains("Previous Work"));
432 assert!(!prompt.contains("Project Knowledge"));
433 }
434
435 #[test]
436 fn test_build_prompt_includes_structured_context_section() {
437 let spec = Spec {
438 project: "TestProject".into(),
439 branch_name: "test-branch".into(),
440 description: "A test project".into(),
441 user_stories: vec![],
442 };
443 let story = UserStory {
444 id: "US-001".into(),
445 title: "Test Story".into(),
446 description: "A test story".into(),
447 acceptance_criteria: vec!["Test criterion".into()],
448 priority: 1,
449 passes: false,
450 notes: String::new(),
451 };
452 let spec_path = Path::new("/tmp/spec-test.json");
453
454 let prompt = build_prompt(&spec, &story, spec_path, None, None);
455 assert!(prompt.contains("## Structured Context (Optional)"));
456 }
457
458 #[test]
459 fn test_build_prompt_includes_files_context_instructions() {
460 let spec = Spec {
461 project: "TestProject".into(),
462 branch_name: "test-branch".into(),
463 description: "A test project".into(),
464 user_stories: vec![],
465 };
466 let story = UserStory {
467 id: "US-001".into(),
468 title: "Test Story".into(),
469 description: "A test story".into(),
470 acceptance_criteria: vec!["Test criterion".into()],
471 priority: 1,
472 passes: false,
473 notes: String::new(),
474 };
475 let spec_path = Path::new("/tmp/spec-test.json");
476
477 let prompt = build_prompt(&spec, &story, spec_path, None, None);
478 assert!(prompt.contains("<files-context>"));
479 assert!(prompt.contains("</files-context>"));
480 assert!(prompt.contains("path/to/file.rs | Brief purpose description"));
481 }
482
483 #[test]
484 fn test_build_prompt_includes_decisions_instructions() {
485 let spec = Spec {
486 project: "TestProject".into(),
487 branch_name: "test-branch".into(),
488 description: "A test project".into(),
489 user_stories: vec![],
490 };
491 let story = UserStory {
492 id: "US-001".into(),
493 title: "Test Story".into(),
494 description: "A test story".into(),
495 acceptance_criteria: vec!["Test criterion".into()],
496 priority: 1,
497 passes: false,
498 notes: String::new(),
499 };
500 let spec_path = Path::new("/tmp/spec-test.json");
501
502 let prompt = build_prompt(&spec, &story, spec_path, None, None);
503 assert!(prompt.contains("<decisions>"));
504 assert!(prompt.contains("</decisions>"));
505 assert!(prompt.contains("topic | choice made | rationale"));
506 }
507
508 #[test]
509 fn test_build_prompt_includes_patterns_instructions() {
510 let spec = Spec {
511 project: "TestProject".into(),
512 branch_name: "test-branch".into(),
513 description: "A test project".into(),
514 user_stories: vec![],
515 };
516 let story = UserStory {
517 id: "US-001".into(),
518 title: "Test Story".into(),
519 description: "A test story".into(),
520 acceptance_criteria: vec!["Test criterion".into()],
521 priority: 1,
522 passes: false,
523 notes: String::new(),
524 };
525 let spec_path = Path::new("/tmp/spec-test.json");
526
527 let prompt = build_prompt(&spec, &story, spec_path, None, None);
528 assert!(prompt.contains("<patterns>"));
529 assert!(prompt.contains("</patterns>"));
530 }
531
532 #[test]
533 fn test_build_prompt_structured_context_is_optional() {
534 let spec = Spec {
535 project: "TestProject".into(),
536 branch_name: "test-branch".into(),
537 description: "A test project".into(),
538 user_stories: vec![],
539 };
540 let story = UserStory {
541 id: "US-001".into(),
542 title: "Test Story".into(),
543 description: "A test story".into(),
544 acceptance_criteria: vec!["Test criterion".into()],
545 priority: 1,
546 passes: false,
547 notes: String::new(),
548 };
549 let spec_path = Path::new("/tmp/spec-test.json");
550
551 let prompt = build_prompt(&spec, &story, spec_path, None, None);
552 assert!(prompt.contains("Optional"));
554 assert!(prompt.contains("optional"));
555 assert!(prompt.contains("only include them when they add value"));
556 }
557
558 #[test]
559 fn test_build_prompt_with_empty_knowledge_no_section() {
560 let spec = Spec {
561 project: "TestProject".into(),
562 branch_name: "test-branch".into(),
563 description: "A test project".into(),
564 user_stories: vec![],
565 };
566 let story = UserStory {
567 id: "US-001".into(),
568 title: "Test Story".into(),
569 description: "A test story".into(),
570 acceptance_criteria: vec!["Test criterion".into()],
571 priority: 1,
572 passes: false,
573 notes: String::new(),
574 };
575 let spec_path = Path::new("/tmp/spec-test.json");
576
577 let prompt = build_prompt(&spec, &story, spec_path, None, None);
579 assert!(!prompt.contains("## Project Knowledge"));
580 }
581
582 #[test]
583 fn test_build_prompt_with_knowledge_context() {
584 let spec = Spec {
585 project: "TestProject".into(),
586 branch_name: "test-branch".into(),
587 description: "A test project".into(),
588 user_stories: vec![],
589 };
590 let story = UserStory {
591 id: "US-002".into(),
592 title: "Second Story".into(),
593 description: "A second test story".into(),
594 acceptance_criteria: vec!["Test criterion".into()],
595 priority: 2,
596 passes: false,
597 notes: String::new(),
598 };
599 let spec_path = Path::new("/tmp/spec-test.json");
600
601 let knowledge_context = r#"## Files Modified in This Run
602
603| Path | Purpose | Key Symbols | Stories |
604|------|---------|-------------|---------|
605| s/main.rs | Entry point | main | US-001 |
606
607## Architectural Decisions
608
609- **Database**: SQLite — Embedded, no setup"#;
610
611 let prompt = build_prompt(&spec, &story, spec_path, Some(knowledge_context), None);
612
613 assert!(prompt.contains("## Project Knowledge"));
615 assert!(prompt.contains("## Files Modified in This Run"));
616 assert!(prompt.contains("s/main.rs"));
617 assert!(prompt.contains("## Architectural Decisions"));
618 assert!(prompt.contains("SQLite"));
619 }
620
621 #[test]
622 fn test_build_prompt_knowledge_appears_before_previous_work() {
623 let spec = Spec {
624 project: "TestProject".into(),
625 branch_name: "test-branch".into(),
626 description: "A test project".into(),
627 user_stories: vec![],
628 };
629 let story = UserStory {
630 id: "US-003".into(),
631 title: "Third Story".into(),
632 description: "A third story".into(),
633 acceptance_criteria: vec!["Test criterion".into()],
634 priority: 3,
635 passes: false,
636 notes: String::new(),
637 };
638 let spec_path = Path::new("/tmp/spec-test.json");
639
640 let knowledge_context = "Files modified: src/main.rs";
641 let previous_context = "US-001: Added feature X\nUS-002: Added feature Y";
642
643 let prompt = build_prompt(
644 &spec,
645 &story,
646 spec_path,
647 Some(knowledge_context),
648 Some(previous_context),
649 );
650
651 assert!(prompt.contains("## Project Knowledge"));
653 assert!(prompt.contains("## Previous Work"));
654
655 let knowledge_pos = prompt.find("## Project Knowledge").unwrap();
657 let previous_work_pos = prompt.find("## Previous Work").unwrap();
658 assert!(
659 knowledge_pos < previous_work_pos,
660 "Project Knowledge section should appear before Previous Work section"
661 );
662 }
663
664 #[test]
665 fn test_build_prompt_with_previous_work_only() {
666 let spec = Spec {
667 project: "TestProject".into(),
668 branch_name: "test-branch".into(),
669 description: "A test project".into(),
670 user_stories: vec![],
671 };
672 let story = UserStory {
673 id: "US-002".into(),
674 title: "Second Story".into(),
675 description: "A second story".into(),
676 acceptance_criteria: vec!["Test criterion".into()],
677 priority: 2,
678 passes: false,
679 notes: String::new(),
680 };
681 let spec_path = Path::new("/tmp/spec-test.json");
682
683 let previous_context = "US-001: Added authentication module";
684
685 let prompt = build_prompt(&spec, &story, spec_path, None, Some(previous_context));
686
687 assert!(!prompt.contains("## Project Knowledge"));
689 assert!(prompt.contains("## Previous Work"));
690 assert!(prompt.contains("US-001: Added authentication module"));
691 }
692
693 #[test]
694 fn test_build_prompt_with_both_knowledge_and_previous_work() {
695 let spec = Spec {
696 project: "TestProject".into(),
697 branch_name: "test-branch".into(),
698 description: "A test project".into(),
699 user_stories: vec![],
700 };
701 let story = UserStory {
702 id: "US-003".into(),
703 title: "Third Story".into(),
704 description: "Build on previous work".into(),
705 acceptance_criteria: vec!["Test criterion".into()],
706 priority: 3,
707 passes: false,
708 notes: String::new(),
709 };
710 let spec_path = Path::new("/tmp/spec-test.json");
711
712 let knowledge_context = r#"## Files Modified
713
714| Path | Purpose |
715|------|---------|
716| s/auth.rs | Authentication |"#;
717 let previous_context = "US-001: Added auth\nUS-002: Added config";
718
719 let prompt = build_prompt(
720 &spec,
721 &story,
722 spec_path,
723 Some(knowledge_context),
724 Some(previous_context),
725 );
726
727 assert!(prompt.contains("## Project Knowledge"));
729 assert!(prompt.contains("## Previous Work"));
730 assert!(prompt.contains("s/auth.rs"));
731 assert!(prompt.contains("US-001: Added auth"));
732 assert!(prompt.contains("US-002: Added config"));
733 }
734
735 #[test]
736 fn test_build_prompt_knowledge_section_structure() {
737 let spec = Spec {
738 project: "TestProject".into(),
739 branch_name: "test-branch".into(),
740 description: "A test project".into(),
741 user_stories: vec![],
742 };
743 let story = UserStory {
744 id: "US-002".into(),
745 title: "Test Story".into(),
746 description: "Test description".into(),
747 acceptance_criteria: vec!["Test".into()],
748 priority: 1,
749 passes: false,
750 notes: String::new(),
751 };
752 let spec_path = Path::new("/tmp/spec-test.json");
753
754 let knowledge_context = "Test knowledge content";
755
756 let prompt = build_prompt(&spec, &story, spec_path, Some(knowledge_context), None);
757
758 assert!(prompt.contains("## Project Knowledge\n\nTest knowledge content"));
761 }
762}