1use std::collections::HashMap;
6use std::path::PathBuf;
7
8use anyhow::{Context, Result};
9
10use crate::error::{PhaseError, XCheckerError};
11use crate::exit_codes;
12use crate::fixup::{FixupMode, FixupPhase};
13use crate::hooks::{HookContext, HookExecutor, HookType, execute_and_process_hook};
14use crate::packet::PacketBuilder;
15use crate::phase::{Phase, PhaseContext};
16use crate::phases::{DesignPhase, RequirementsPhase, ReviewPhase, TasksPhase};
17use crate::status::artifact::{Artifact, ArtifactType};
18use crate::types::{ErrorKind, FileType, LlmInfo, PacketEvidence, PhaseId, PipelineInfo};
19
20use super::llm::{ClaudeExecutionMetadata, LlmInvocationError};
21use super::{OrchestratorConfig, PhaseOrchestrator, PhaseTimeout};
22
23#[derive(Debug)]
39pub struct ExecutionResult {
40 pub phase: PhaseId,
42 pub success: bool,
44 pub exit_code: i32,
46 pub artifact_paths: Vec<PathBuf>,
48 pub receipt_path: Option<PathBuf>,
50 pub error: Option<String>,
52}
53
54pub(crate) struct PhaseCoreOutput {
146 pub packet_evidence: PacketEvidence,
148 pub claude_exit_code: i32,
150 pub claude_metadata: Option<ClaudeExecutionMetadata>,
152 pub llm_result: Option<crate::llm::LlmResult>,
154 pub llm_fallback_warning: Option<String>,
156 pub phase_result: xchecker_phase_api::PhaseResult,
158}
159
160pub(crate) async fn execute_phase_with_timeout<F, T>(
162 fut: F,
163 phase_id: PhaseId,
164 timeout_config: &PhaseTimeout,
165) -> Result<T, XCheckerError>
166where
167 F: std::future::Future<Output = Result<T>>,
168{
169 match tokio::time::timeout(timeout_config.duration, fut).await {
170 Ok(result) => result.map_err(|e| {
171 match e.downcast::<XCheckerError>() {
174 Ok(xchecker_err) => xchecker_err,
175 Err(_original_err) => {
176 XCheckerError::Phase(PhaseError::ExecutionFailed {
178 phase: phase_id.as_str().to_string(),
179 code: 1,
180 })
181 }
182 }
183 }),
184 Err(_) => {
185 Err(XCheckerError::Phase(PhaseError::Timeout {
187 phase: phase_id.as_str().to_string(),
188 timeout_seconds: timeout_config.duration.as_secs(),
189 }))
190 }
191 }
192}
193
194impl PhaseOrchestrator {
195 #[cfg_attr(not(test), allow(dead_code))]
211 pub async fn execute_requirements_phase(
212 &self,
213 config: &OrchestratorConfig,
214 ) -> Result<ExecutionResult> {
215 self.validate_transition(PhaseId::Requirements)?;
217
218 let phase = self.get_phase_impl(PhaseId::Requirements, config)?;
219 self.execute_phase_with_timeout_handling(phase.as_ref(), config)
220 .await
221 }
222
223 #[allow(dead_code)] pub async fn execute_design_phase(
237 &self,
238 config: &OrchestratorConfig,
239 ) -> Result<ExecutionResult> {
240 self.validate_transition(PhaseId::Design)?;
242
243 let phase = self.get_phase_impl(PhaseId::Design, config)?;
244 self.execute_phase_with_timeout_handling(phase.as_ref(), config)
245 .await
246 }
247
248 #[allow(dead_code)] pub async fn execute_tasks_phase(
262 &self,
263 config: &OrchestratorConfig,
264 ) -> Result<ExecutionResult> {
265 self.validate_transition(PhaseId::Tasks)?;
267
268 let phase = self.get_phase_impl(PhaseId::Tasks, config)?;
269 self.execute_phase_with_timeout_handling(phase.as_ref(), config)
270 .await
271 }
272
273 pub async fn resume_from_phase(
285 &self,
286 phase_id: PhaseId,
287 config: &OrchestratorConfig,
288 ) -> Result<ExecutionResult> {
289 self.validate_transition(phase_id)?;
291
292 let phase = self.get_phase_impl(phase_id, config)?;
294 self.execute_phase_with_resume(phase.as_ref(), config).await
295 }
296
297 pub(crate) async fn execute_phase_with_timeout_handling(
299 &self,
300 phase: &dyn Phase,
301 config: &OrchestratorConfig,
302 ) -> Result<ExecutionResult> {
303 let phase_id = phase.id();
304
305 let timeout_config = PhaseTimeout::from_config(config);
307
308 match execute_phase_with_timeout(
310 self.execute_phase(phase, config),
311 phase_id,
312 &timeout_config,
313 )
314 .await
315 {
316 Ok(result) => Ok(result),
317 Err(XCheckerError::Phase(PhaseError::Timeout {
318 phase: _,
319 timeout_seconds,
320 })) => {
321 self.handle_phase_timeout(phase_id, timeout_seconds, config)
323 .await
324 }
325 Err(e) => Err(e.into()),
326 }
327 }
328
329 async fn handle_phase_timeout(
331 &self,
332 phase_id: PhaseId,
333 timeout_seconds: u64,
334 config: &OrchestratorConfig,
335 ) -> Result<ExecutionResult> {
336 let partial_content = format!(
338 "# {} Phase (Partial - Timeout)\n\nThis phase timed out after {} seconds.\n\nNo output was generated before the timeout occurred.\n",
339 phase_id.as_str(),
340 timeout_seconds
341 );
342
343 let partial_filename = format!(
344 "{:02}-{}.partial.md",
345 self.get_phase_number(phase_id),
346 phase_id.as_str().to_lowercase()
347 );
348
349 let partial_artifact = Artifact {
351 name: partial_filename,
352 content: partial_content.clone(),
353 artifact_type: ArtifactType::Partial,
354 blake3_hash: blake3::hash(partial_content.as_bytes())
355 .to_hex()
356 .to_string(),
357 };
358
359 let partial_result = self.artifact_manager().store_artifact(&partial_artifact)?;
360 let partial_path = partial_result.path;
361
362 let packet_evidence = PacketEvidence {
364 files: vec![],
365 max_bytes: 65536,
366 max_lines: 1200,
367 };
368
369 let mut flags = HashMap::new();
370 flags.insert("phase".to_string(), phase_id.as_str().to_string());
371
372 let warnings = vec![format!("phase_timeout:{}", timeout_seconds)];
373 let pipeline_info = Some(PipelineInfo {
374 execution_strategy: Some("controlled".to_string()),
375 });
376
377 let configured_model = config.config.get("model").map_or("unknown", |s| s.as_str());
379 let configured_runner = config
380 .config
381 .get("runner_mode")
382 .map_or("unknown", |s| s.as_str());
383
384 let receipt = self.receipt_manager().create_receipt_with_redactor(
385 config.redactor.as_ref(),
386 self.spec_id(),
387 phase_id,
388 exit_codes::codes::PHASE_TIMEOUT, vec![], env!("CARGO_PKG_VERSION"),
391 "unknown", configured_model,
393 None, flags,
395 packet_evidence,
396 None, None, warnings,
399 None, configured_runner,
401 None, Some(ErrorKind::PhaseTimeout),
403 Some(format!("Phase timed out after {timeout_seconds} seconds")),
404 None, pipeline_info,
406 );
407
408 let receipt_path = self.receipt_manager().write_receipt(&receipt)?;
409
410 Ok(ExecutionResult {
411 phase: phase_id,
412 success: false,
413 exit_code: exit_codes::codes::PHASE_TIMEOUT,
414 artifact_paths: vec![partial_path.into_std_path_buf()],
415 receipt_path: Some(receipt_path.into_std_path_buf()),
416 error: Some(format!("Phase timed out after {timeout_seconds} seconds")),
417 })
418 }
419
420 async fn execute_phase_with_resume(
422 &self,
423 phase: &dyn Phase,
424 config: &OrchestratorConfig,
425 ) -> Result<ExecutionResult> {
426 let phase_id = phase.id();
427
428 let has_partial = self.artifact_manager().has_partial_artifact(phase_id);
430
431 if has_partial {
432 println!(
433 "Found partial artifact for {} phase from previous failed run",
434 phase_id.as_str()
435 );
436
437 if !config.dry_run {
438 println!("Deleting partial artifact and starting fresh...");
443 self.artifact_manager().delete_partial_artifact(phase_id)?;
444 }
445 }
446
447 let result = self.execute_phase(phase, config).await?;
449
450 if result.success {
452 if let Err(e) = self.artifact_manager().delete_partial_artifact(phase_id) {
454 eprintln!("Warning: Failed to clean up partial artifact: {e}");
456 }
457 }
458
459 Ok(result)
460 }
461
462 pub(crate) async fn execute_phase_core(
489 &self,
490 phase: &dyn Phase,
491 config: &OrchestratorConfig,
492 ) -> Result<PhaseCoreOutput> {
493 let phase_id = phase.id();
494
495 let phase_context = self.create_phase_context(phase_id, config)?;
497
498 self.check_phase_dependencies(phase)?;
500
501 let prompt = phase.prompt(&phase_context);
503
504 let packet = phase.make_packet(&phase_context).map_err(|e| {
506 XCheckerError::Phase(PhaseError::PacketCreationFailed {
507 phase: phase_id.as_str().to_string(),
508 reason: e.to_string(),
509 })
510 })?;
511
512 let budget = packet.budget_usage();
514 tracing::info!(
515 target: "xchecker::packet",
516 spec_id = %self.spec_id(),
517 phase = %phase_id.as_str(),
518 packet_hash = %packet.hash(),
519 bytes_used = budget.bytes_used,
520 bytes_limit = budget.max_bytes,
521 lines_used = budget.lines_used,
522 lines_limit = budget.max_lines,
523 "Built packet for phase"
524 );
525
526 let packet_evidence = packet.evidence.clone();
527
528 let redactor = config.redactor.as_ref();
530
531 if redactor.has_secrets(&packet.content, "packet")? {
533 return Err(XCheckerError::Phase(PhaseError::ExecutionFailed {
534 phase: phase_id.as_str().to_string(),
535 code: exit_codes::codes::SECRET_DETECTED,
536 })
537 .into());
538 }
539
540 let _packet_preview_path = self
542 .artifact_manager()
543 .store_context_file(&format!("{}-packet", phase_id.as_str()), &packet.content)?;
544
545 let debug_packet_enabled = config
548 .config
549 .get("debug_packet")
550 .is_some_and(|s| s == "true");
551
552 if debug_packet_enabled {
553 let context_dir = self.artifact_manager().context_path();
555
556 let temp_builder = PacketBuilder::new().map_err(|e| {
558 XCheckerError::Phase(PhaseError::PacketCreationFailed {
559 phase: phase_id.as_str().to_string(),
560 reason: format!("Failed to create PacketBuilder for debug packet: {e}"),
561 })
562 })?;
563
564 if let Err(e) =
565 temp_builder.write_debug_packet(&packet.content, phase_id.as_str(), &context_dir)
566 {
567 eprintln!("Warning: Failed to write debug packet: {e}");
569 }
570 }
571
572 let (claude_response, claude_exit_code, claude_metadata, llm_result, llm_fallback_warning) =
574 if config.dry_run {
575 let simulated_llm = self.simulate_llm_result(phase_id);
576 let simulated_metadata = super::llm::ClaudeExecutionMetadata {
577 model_alias: None,
578 model_full_name: "haiku".to_string(),
579 claude_cli_version: "0.8.1".to_string(),
580 fallback_used: false,
581 runner: "simulated".to_string(),
582 runner_distro: None,
583 stderr_tail: None,
584 };
585 (
586 self.simulate_claude_response(phase_id, &prompt),
587 0,
588 Some(simulated_metadata),
589 Some(simulated_llm),
590 None,
591 )
592 } else {
593 self.run_llm_invocation(&prompt, &packet.content, phase_id, config)
595 .await?
596 };
597
598 let phase_result = if claude_exit_code == 0 {
600 phase
601 .postprocess(&claude_response, &phase_context)
602 .with_context(|| {
603 format!(
604 "Failed to postprocess response for phase: {}",
605 phase_id.as_str()
606 )
607 })?
608 } else {
609 xchecker_phase_api::PhaseResult {
611 artifacts: vec![],
612 next_step: xchecker_phase_api::NextStep::Continue,
613 metadata: xchecker_phase_api::PhaseMetadata::default(),
614 }
615 };
616
617 for artifact in &phase_result.artifacts {
619 let _partial_result = self
621 .artifact_manager()
622 .store_partial_staged_artifact(artifact)
623 .with_context(|| format!("Failed to store partial artifact: {}", artifact.name))?;
624 }
625
626 for artifact in &phase_result.artifacts {
628 let _final_path = self
629 .artifact_manager()
630 .promote_staged_to_final(&artifact.name)
631 .with_context(|| {
632 format!("Failed to promote artifact to final: {}", artifact.name)
633 })?;
634 }
635
636 Ok(PhaseCoreOutput {
640 packet_evidence,
641 claude_exit_code,
642 claude_metadata,
643 llm_result,
644 llm_fallback_warning,
645 phase_result,
646 })
647 }
648
649 pub(crate) async fn execute_phase(
651 &self,
652 phase: &dyn Phase,
653 config: &OrchestratorConfig,
654 ) -> Result<ExecutionResult> {
655 let phase_id = phase.id();
656 let pipeline_info = Some(PipelineInfo {
657 execution_strategy: Some("controlled".to_string()),
658 });
659
660 self.artifact_manager()
662 .remove_stale_partial_dir()
663 .with_context(|| "Failed to remove stale .partial/ directory")?;
664
665 let phase_context = self.create_phase_context(phase_id, config)?;
670
671 self.check_phase_dependencies(phase)?;
673
674 let mut hook_warnings: Vec<String> = Vec::new();
677 if let Some(ref hooks_config) = config.hooks
678 && let Some(hook_config) = hooks_config.get_pre_phase_hook(phase_id)
679 {
680 let executor = HookExecutor::new(
681 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
682 );
683 let context = HookContext::new(self.spec_id(), phase_id, HookType::PrePhase);
684
685 match execute_and_process_hook(
686 &executor,
687 hook_config,
688 &context,
689 HookType::PrePhase,
690 phase_id,
691 )
692 .await
693 {
694 Ok(outcome) => {
695 if let Some(warning) = outcome.warning() {
696 hook_warnings.push(warning.to_warning_string());
697 }
698 if !outcome.should_continue() {
699 let error_reason = format!(
701 "Pre-phase hook failed: {}",
702 outcome.error().map(|e| e.to_string()).unwrap_or_default()
703 );
704
705 let packet_evidence = PacketEvidence {
707 files: vec![],
708 max_bytes: 65536,
709 max_lines: 1200,
710 };
711 let mut flags = HashMap::new();
712 flags.insert("phase".to_string(), phase_id.as_str().to_string());
713 flags.insert("hook_failure".to_string(), "pre_phase".to_string());
714
715 let configured_model =
717 config.config.get("model").map_or("unknown", |s| s.as_str());
718 let configured_runner = config
719 .config
720 .get("runner_mode")
721 .map_or("unknown", |s| s.as_str());
722
723 let receipt = self.receipt_manager().create_receipt_with_redactor(
724 config.redactor.as_ref(),
725 self.spec_id(),
726 phase_id,
727 exit_codes::codes::CLAUDE_FAILURE,
728 vec![], env!("CARGO_PKG_VERSION"),
730 "unknown", configured_model,
732 None,
733 flags,
734 packet_evidence,
735 None,
736 None,
737 hook_warnings.clone(),
738 None,
739 configured_runner,
740 None,
741 Some(ErrorKind::ClaudeFailure),
742 Some(error_reason.clone()),
743 None,
744 pipeline_info.clone(),
745 );
746
747 let receipt_path = self.receipt_manager().write_receipt(&receipt)?;
748
749 return Ok(ExecutionResult {
750 phase: phase_id,
751 success: false,
752 exit_code: exit_codes::codes::CLAUDE_FAILURE,
753 artifact_paths: vec![],
754 receipt_path: Some(receipt_path.into_std_path_buf()),
755 error: Some(error_reason),
756 });
757 }
758 }
759 Err(e) => {
760 let error_reason = format!("Pre-phase hook error: {}", e);
762
763 let packet_evidence = PacketEvidence {
765 files: vec![],
766 max_bytes: 65536,
767 max_lines: 1200,
768 };
769 let mut flags = HashMap::new();
770 flags.insert("phase".to_string(), phase_id.as_str().to_string());
771 flags.insert("hook_error".to_string(), "pre_phase".to_string());
772
773 let configured_model =
775 config.config.get("model").map_or("unknown", |s| s.as_str());
776 let configured_runner = config
777 .config
778 .get("runner_mode")
779 .map_or("unknown", |s| s.as_str());
780
781 let receipt = self.receipt_manager().create_receipt_with_redactor(
782 config.redactor.as_ref(),
783 self.spec_id(),
784 phase_id,
785 exit_codes::codes::CLAUDE_FAILURE,
786 vec![], env!("CARGO_PKG_VERSION"),
788 "unknown", configured_model,
790 None,
791 flags,
792 packet_evidence,
793 None,
794 None,
795 vec![format!("hook_error:pre_phase:{}", e)],
796 None,
797 configured_runner,
798 None,
799 Some(ErrorKind::ClaudeFailure),
800 Some(error_reason.clone()),
801 None,
802 pipeline_info.clone(),
803 );
804
805 let receipt_path = self.receipt_manager().write_receipt(&receipt)?;
806
807 return Ok(ExecutionResult {
808 phase: phase_id,
809 success: false,
810 exit_code: exit_codes::codes::CLAUDE_FAILURE,
811 artifact_paths: vec![],
812 receipt_path: Some(receipt_path.into_std_path_buf()),
813 error: Some(error_reason),
814 });
815 }
816 }
817 }
818
819 let prompt = phase.prompt(&phase_context);
821
822 let packet = phase.make_packet(&phase_context).map_err(|e| {
824 XCheckerError::Phase(PhaseError::PacketCreationFailed {
825 phase: phase_id.as_str().to_string(),
826 reason: e.to_string(),
827 })
828 })?;
829
830 let budget = packet.budget_usage();
832 tracing::info!(
833 target: "xchecker::packet",
834 spec_id = %self.spec_id(),
835 phase = %phase_id.as_str(),
836 packet_hash = %packet.hash(),
837 bytes_used = budget.bytes_used,
838 bytes_limit = budget.max_bytes,
839 lines_used = budget.lines_used,
840 lines_limit = budget.max_lines,
841 "Built packet for phase"
842 );
843
844 let redactor = config.redactor.as_ref();
846
847 if redactor.has_secrets(&packet.content, "packet")? {
849 let matches = redactor.scan_for_secrets(&packet.content, "packet")?;
850
851 let packet_evidence = packet.evidence.clone();
853 let mut flags = HashMap::new();
854 flags.insert("phase".to_string(), phase_id.as_str().to_string());
855
856 let secret_patterns: Vec<String> =
857 matches.iter().map(|m| m.pattern_id.clone()).collect();
858 let error_reason = format!(
859 "Secret detected in packet. Matched patterns: {}",
860 secret_patterns.join(", ")
861 );
862
863 let receipt = self.receipt_manager().create_receipt_with_redactor(
864 config.redactor.as_ref(),
865 self.spec_id(),
866 phase_id,
867 exit_codes::codes::SECRET_DETECTED, vec![], env!("CARGO_PKG_VERSION"),
870 "0.8.1", "haiku", None, flags,
874 packet_evidence,
875 None, None, vec![format!("Secret detection prevented Claude invocation")],
878 None, "native", None, Some(ErrorKind::SecretDetected),
882 Some(error_reason.clone()),
883 None, pipeline_info.clone(),
885 );
886
887 let receipt_path = self.receipt_manager().write_receipt(&receipt)?;
888
889 return Ok(ExecutionResult {
890 phase: phase_id,
891 success: false,
892 exit_code: exit_codes::codes::SECRET_DETECTED,
893 artifact_paths: vec![],
894 receipt_path: Some(receipt_path.into_std_path_buf()),
895 error: Some(error_reason),
896 });
897 }
898
899 let _packet_preview_path = self
901 .artifact_manager()
902 .store_context_file(&format!("{}-packet", phase_id.as_str()), &packet.content)?;
903
904 let debug_packet_enabled = config
907 .config
908 .get("debug_packet")
909 .is_some_and(|s| s == "true");
910
911 if debug_packet_enabled {
912 let context_dir = self.artifact_manager().context_path();
914
915 let temp_builder = PacketBuilder::new().map_err(|e| {
918 XCheckerError::Phase(PhaseError::PacketCreationFailed {
919 phase: phase_id.as_str().to_string(),
920 reason: format!("Failed to create PacketBuilder for debug packet: {e}"),
921 })
922 })?;
923
924 if let Err(e) =
925 temp_builder.write_debug_packet(&packet.content, phase_id.as_str(), &context_dir)
926 {
927 eprintln!("Warning: Failed to write debug packet: {e}");
929 }
930 }
931
932 let mut llm_fallback_warning: Option<String> = None;
934 let (claude_response, claude_exit_code, claude_metadata, llm_result) = if config.dry_run {
935 let simulated_llm = self.simulate_llm_result(phase_id);
936 let simulated_metadata = super::llm::ClaudeExecutionMetadata {
937 model_alias: None,
938 model_full_name: "haiku".to_string(),
939 claude_cli_version: "0.8.1".to_string(),
940 fallback_used: false,
941 runner: "simulated".to_string(),
942 runner_distro: None,
943 stderr_tail: None,
944 };
945 (
946 self.simulate_claude_response(phase_id, &prompt),
947 0,
948 Some(simulated_metadata),
949 Some(simulated_llm),
950 )
951 } else {
952 match self
954 .run_llm_invocation(&prompt, &packet.content, phase_id, config)
955 .await
956 {
957 Ok((response, exit_code, metadata, result, fallback_warning)) => {
958 llm_fallback_warning = fallback_warning;
959 (response, exit_code, metadata, result)
960 }
961 Err(e) => {
962 let (xchecker_err, fallback_warning) =
963 if let Some(invocation_err) = e.downcast_ref::<LlmInvocationError>() {
964 (
965 invocation_err.error(),
966 invocation_err.fallback_warning().map(|s| s.to_string()),
967 )
968 } else if let Some(xchecker_err) = e.downcast_ref::<XCheckerError>() {
969 (xchecker_err, None)
970 } else {
971 return Err(e);
972 };
973
974 llm_fallback_warning = fallback_warning;
975
976 if let XCheckerError::Llm(llm_err) = xchecker_err {
978 if matches!(llm_err, crate::llm::LlmError::BudgetExceeded { .. }) {
979 let packet_evidence = packet.evidence.clone();
981 let mut flags = HashMap::new();
982 flags.insert("phase".to_string(), phase_id.as_str().to_string());
983
984 let configured_model =
986 config.config.get("model").map_or("unknown", |s| s.as_str());
987 let configured_runner = config
988 .config
989 .get("runner_mode")
990 .map_or("unknown", |s| s.as_str());
991
992 let mut warnings = vec![format!("LLM budget exhausted: {}", llm_err)];
993 if let Some(ref warning) = llm_fallback_warning {
994 warnings.push(warning.clone());
995 }
996
997 let mut receipt = self.receipt_manager().create_receipt_with_redactor(
998 config.redactor.as_ref(),
999 self.spec_id(),
1000 phase_id,
1001 exit_codes::codes::CLAUDE_FAILURE, vec![], env!("CARGO_PKG_VERSION"),
1004 "unknown", configured_model,
1006 None, flags,
1008 packet_evidence,
1009 None, None, warnings,
1012 None, configured_runner,
1014 None, Some(ErrorKind::ClaudeFailure),
1016 Some(llm_err.to_string()),
1017 None, pipeline_info.clone(),
1019 );
1020
1021 receipt.llm = Some(LlmInfo::for_budget_exhaustion());
1023
1024 let receipt_path = self.receipt_manager().write_receipt(&receipt)?;
1025
1026 return Ok(ExecutionResult {
1027 phase: phase_id,
1028 success: false,
1029 exit_code: exit_codes::codes::CLAUDE_FAILURE,
1030 artifact_paths: vec![],
1031 receipt_path: Some(receipt_path.into_std_path_buf()),
1032 error: Some(llm_err.to_string()),
1033 });
1034 }
1035
1036 let packet_evidence = packet.evidence.clone();
1037 let mut flags = HashMap::new();
1038 flags.insert("phase".to_string(), phase_id.as_str().to_string());
1039
1040 let configured_model =
1042 config.config.get("model").map_or("unknown", |s| s.as_str());
1043 let configured_runner = config
1044 .config
1045 .get("runner_mode")
1046 .map_or("unknown", |s| s.as_str());
1047
1048 let (exit_code, error_kind) =
1049 exit_codes::error_to_exit_code_and_kind(xchecker_err);
1050
1051 let invocation =
1052 self.build_llm_invocation(phase_id, &prompt, &packet.content, config);
1053 let provider = self
1054 .config_from_orchestrator_config(config)
1055 .llm
1056 .provider
1057 .unwrap_or_else(|| "claude-cli".to_string());
1058
1059 let mut llm_info = LlmInfo {
1060 provider: Some(provider),
1061 model_used: if invocation.model.is_empty() {
1062 None
1063 } else {
1064 Some(invocation.model.clone())
1065 },
1066 tokens_input: None,
1067 tokens_output: None,
1068 timed_out: None,
1069 timeout_seconds: Some(invocation.timeout.as_secs()),
1070 budget_exhausted: None,
1071 };
1072
1073 let mut warnings = Vec::new();
1074 match llm_err {
1075 crate::llm::LlmError::Timeout { duration } => {
1076 llm_info.timed_out = Some(true);
1077 llm_info.timeout_seconds = Some(duration.as_secs());
1078 warnings.push(format!("phase_timeout:{}", duration.as_secs()));
1079 }
1080 _ => {
1081 llm_info.timed_out = Some(false);
1082 warnings.push(format!("llm_error:{}", llm_err));
1083 }
1084 }
1085 if let Some(ref warning) = llm_fallback_warning {
1086 warnings.push(warning.clone());
1087 }
1088
1089 let mut receipt = self.receipt_manager().create_receipt_with_redactor(
1090 config.redactor.as_ref(),
1091 self.spec_id(),
1092 phase_id,
1093 exit_code,
1094 vec![], env!("CARGO_PKG_VERSION"),
1096 "unknown", configured_model,
1098 None, flags,
1100 packet_evidence,
1101 None, None, warnings,
1104 None, configured_runner,
1106 None, Some(error_kind),
1108 Some(llm_err.to_string()),
1109 None, pipeline_info.clone(),
1111 );
1112
1113 receipt.llm = Some(llm_info);
1114
1115 let receipt_path = self.receipt_manager().write_receipt(&receipt)?;
1116
1117 return Ok(ExecutionResult {
1118 phase: phase_id,
1119 success: false,
1120 exit_code,
1121 artifact_paths: vec![],
1122 receipt_path: Some(receipt_path.into_std_path_buf()),
1123 error: Some(llm_err.to_string()),
1124 });
1125 }
1126 return Err(e);
1128 }
1129 }
1130 };
1131
1132 if claude_exit_code != 0 {
1134 let partial_filename = format!(
1136 "{:02}-{}.partial.md",
1137 self.get_phase_number(phase_id),
1138 phase_id.as_str().to_lowercase()
1139 );
1140
1141 let partial_result = self.artifact_manager().store_artifact(&Artifact {
1142 name: partial_filename.clone(),
1143 content: claude_response.clone(),
1144 artifact_type: ArtifactType::Partial,
1145 blake3_hash: blake3::hash(claude_response.as_bytes())
1146 .to_hex()
1147 .to_string(),
1148 })?;
1149 let partial_path = partial_result.path;
1150
1151 let packet_evidence = packet.evidence.clone();
1154
1155 let mut flags = HashMap::new();
1156 flags.insert("phase".to_string(), phase_id.as_str().to_string());
1157
1158 let (model_alias, model_full_name) = if let Some(metadata) = &claude_metadata {
1159 (
1160 metadata.model_alias.clone(),
1161 metadata.model_full_name.clone(),
1162 )
1163 } else {
1164 (None, "haiku".to_string())
1165 };
1166
1167 let mut warnings = vec!["Phase execution failed with non-zero exit code".to_string()];
1168 if let Some(ref warning) = llm_fallback_warning {
1169 warnings.push(warning.clone());
1170 }
1171
1172 let mut receipt = self.receipt_manager().create_receipt_with_redactor(
1173 config.redactor.as_ref(),
1174 self.spec_id(),
1175 phase_id,
1176 claude_exit_code,
1177 vec![], env!("CARGO_PKG_VERSION"),
1179 claude_metadata
1180 .as_ref()
1181 .map_or("0.8.1", |m| m.claude_cli_version.as_str()),
1182 &model_full_name,
1183 model_alias,
1184 flags,
1185 packet_evidence,
1186 Some("Claude CLI execution failed".to_string()), None, warnings,
1189 claude_metadata.as_ref().map(|m| m.fallback_used),
1190 claude_metadata
1191 .as_ref()
1192 .map_or("native", |m| m.runner.as_str()),
1193 claude_metadata
1194 .as_ref()
1195 .and_then(|m| m.runner_distro.clone()),
1196 Some(ErrorKind::ClaudeFailure),
1197 Some("Claude CLI execution failed".to_string()),
1198 None, pipeline_info.clone(),
1200 );
1201
1202 receipt.llm = llm_result.map(|result| result.into_llm_info());
1203
1204 let receipt_path = self.receipt_manager().write_receipt(&receipt)?;
1205
1206 let stderr_info = claude_metadata
1208 .as_ref()
1209 .and_then(|m| m.stderr_tail.clone())
1210 .unwrap_or_else(|| "No stderr captured".to_string());
1211
1212 let enhanced_error = if !stderr_info.is_empty() && stderr_info != "No stderr captured" {
1213 XCheckerError::Phase(PhaseError::ExecutionFailedWithStderr {
1214 phase: phase_id.as_str().to_string(),
1215 code: claude_exit_code,
1216 stderr_tail: stderr_info,
1217 })
1218 } else {
1219 XCheckerError::Phase(PhaseError::PartialOutputSaved {
1220 phase: phase_id.as_str().to_string(),
1221 partial_path: format!("artifacts/{partial_filename}"),
1222 })
1223 };
1224
1225 return Ok(ExecutionResult {
1226 phase: phase_id,
1227 success: false,
1228 exit_code: claude_exit_code,
1229 artifact_paths: vec![partial_path.into_std_path_buf()], receipt_path: Some(receipt_path.into_std_path_buf()),
1231 error: Some(enhanced_error.to_string()),
1232 });
1233 }
1234
1235 let phase_result = phase
1237 .postprocess(&claude_response, &phase_context)
1238 .with_context(|| {
1239 format!(
1240 "Failed to postprocess response for phase: {}",
1241 phase_id.as_str()
1242 )
1243 })?;
1244
1245 let mut artifact_paths = Vec::new();
1247 let mut output_hashes = Vec::new();
1248 let mut atomic_write_warnings = Vec::new();
1249
1250 for artifact in &phase_result.artifacts {
1251 let partial_result = self
1253 .artifact_manager()
1254 .store_partial_staged_artifact(artifact)
1255 .with_context(|| format!("Failed to store partial artifact: {}", artifact.name))?;
1256
1257 for warning in &partial_result.atomic_write_result.warnings {
1259 atomic_write_warnings.push(format!("{}: {}", artifact.name, warning));
1260 }
1261
1262 let file_type = if let Some(ext) = std::path::Path::new(&artifact.name).extension() {
1265 FileType::from_extension(ext.to_str().unwrap_or(""))
1266 } else {
1267 match artifact.artifact_type {
1269 ArtifactType::Markdown => FileType::Markdown,
1270 ArtifactType::CoreYaml => FileType::Yaml,
1271 _ => FileType::Text,
1272 }
1273 };
1274
1275 let file_hash = self
1276 .receipt_manager()
1277 .create_file_hash(
1278 &format!("artifacts/{}", artifact.name),
1279 &artifact.content,
1280 file_type,
1281 phase_id.as_str(),
1282 )
1283 .map_err(|e| {
1284 XCheckerError::Phase(PhaseError::OutputValidationFailed {
1285 phase: phase_id.as_str().to_string(),
1286 reason: e.to_string(),
1287 })
1288 })?;
1289
1290 output_hashes.push(file_hash);
1291 }
1292
1293 for artifact in &phase_result.artifacts {
1295 let final_path = self
1296 .artifact_manager()
1297 .promote_staged_to_final(&artifact.name)
1298 .with_context(|| {
1299 format!("Failed to promote artifact to final: {}", artifact.name)
1300 })?;
1301
1302 artifact_paths.push(final_path.into_std_path_buf());
1303 }
1304
1305 let packet_evidence = packet.evidence.clone();
1308
1309 let mut flags = HashMap::new();
1310 flags.insert("phase".to_string(), phase_id.as_str().to_string());
1311
1312 let (model_alias, model_full_name) = if let Some(metadata) = &claude_metadata {
1313 (
1314 metadata.model_alias.clone(),
1315 metadata.model_full_name.clone(),
1316 )
1317 } else {
1318 (None, "haiku".to_string())
1319 };
1320
1321 let mut warnings: Vec<String> = atomic_write_warnings
1322 .into_iter()
1323 .chain(hook_warnings.iter().cloned())
1324 .collect();
1325 if let Some(warning) = llm_fallback_warning {
1326 warnings.push(warning);
1327 }
1328
1329 let mut receipt = self.receipt_manager().create_receipt_with_redactor(
1330 config.redactor.as_ref(),
1331 self.spec_id(),
1332 phase_id,
1333 0, output_hashes,
1335 env!("CARGO_PKG_VERSION"),
1336 claude_metadata
1337 .as_ref()
1338 .map_or("0.8.1", |m| m.claude_cli_version.as_str()),
1339 &model_full_name,
1340 model_alias,
1341 flags,
1342 packet_evidence,
1343 None, None, warnings, claude_metadata.as_ref().map(|m| m.fallback_used),
1347 claude_metadata
1348 .as_ref()
1349 .map_or("native", |m| m.runner.as_str()),
1350 claude_metadata
1351 .as_ref()
1352 .and_then(|m| m.runner_distro.clone()),
1353 None, None, None, pipeline_info.clone(),
1357 );
1358 receipt.llm = llm_result.map(|r| r.into_llm_info());
1360
1361 let receipt_path = self
1362 .receipt_manager()
1363 .write_receipt(&receipt)
1364 .with_context(|| format!("Failed to write receipt for phase: {}", phase_id.as_str()))?;
1365
1366 if let Some(ref hooks_config) = config.hooks
1371 && let Some(hook_config) = hooks_config.get_post_phase_hook(phase_id)
1372 {
1373 let executor = HookExecutor::new(
1374 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
1375 );
1376 let context = HookContext::new(self.spec_id(), phase_id, HookType::PostPhase);
1377
1378 match execute_and_process_hook(
1379 &executor,
1380 hook_config,
1381 &context,
1382 HookType::PostPhase,
1383 phase_id,
1384 )
1385 .await
1386 {
1387 Ok(outcome) => {
1388 if let Some(warning) = outcome.warning() {
1390 tracing::warn!(
1391 phase = %phase_id.as_str(),
1392 "Post-phase hook warning: {}",
1393 warning.to_warning_string()
1394 );
1395 }
1396 if !outcome.should_continue() {
1399 tracing::warn!(
1400 phase = %phase_id.as_str(),
1401 "Post-phase hook had on_fail=fail but phase artifacts already created; treating as warning"
1402 );
1403 }
1404 }
1405 Err(e) => {
1406 tracing::warn!(
1409 phase = %phase_id.as_str(),
1410 error = %e,
1411 "Post-phase hook execution error (treated as warning)"
1412 );
1413 }
1414 }
1415 }
1416
1417 Ok(ExecutionResult {
1418 phase: phase_id,
1419 success: true,
1420 exit_code: 0,
1421 artifact_paths,
1422 receipt_path: Some(receipt_path.into_std_path_buf()),
1423 error: None,
1424 })
1425 }
1426
1427 pub(crate) fn create_phase_context(
1429 &self,
1430 phase_id: PhaseId,
1431 config: &OrchestratorConfig,
1432 ) -> Result<PhaseContext> {
1433 let artifacts = self.artifact_manager().list_artifacts().map_err(|e| {
1435 XCheckerError::Phase(PhaseError::ContextCreationFailed {
1436 phase: phase_id.as_str().to_string(),
1437 reason: format!("Failed to list existing artifacts: {e}"),
1438 })
1439 })?;
1440
1441 Ok(PhaseContext {
1442 spec_id: self.spec_id().to_string(),
1443 spec_dir: self
1444 .artifact_manager()
1445 .base_path()
1446 .clone()
1447 .into_std_path_buf(),
1448 config: config.config.clone(),
1449 artifacts,
1450 selectors: config.selectors.clone(),
1451 strict_validation: config.strict_validation,
1452 redactor: config.redactor.clone(),
1453 })
1454 }
1455
1456 pub(crate) fn check_phase_dependencies(&self, phase: &dyn Phase) -> Result<()> {
1458 let deps = phase.deps();
1459
1460 for dep_phase in deps {
1461 if let Some(receipt) = self.receipt_manager().read_latest_receipt(*dep_phase)? {
1463 if receipt.exit_code != 0 {
1464 return Err(XCheckerError::Phase(PhaseError::DependencyNotSatisfied {
1465 phase: phase.id().as_str().to_string(),
1466 dependency: dep_phase.as_str().to_string(),
1467 })
1468 .into());
1469 }
1470 } else {
1471 return Err(XCheckerError::Phase(PhaseError::DependencyNotSatisfied {
1472 phase: phase.id().as_str().to_string(),
1473 dependency: dep_phase.as_str().to_string(),
1474 })
1475 .into());
1476 }
1477 }
1478
1479 Ok(())
1480 }
1481
1482 pub(crate) fn simulate_llm_result(&self, _phase_id: PhaseId) -> crate::llm::LlmResult {
1485 crate::llm::LlmResult::new(
1486 "simulated response".to_string(),
1487 "claude-cli-simulated".to_string(),
1488 "haiku".to_string(),
1489 )
1490 .with_tokens(1000, 2000)
1491 .with_timeout(false)
1492 .with_timeout_seconds(600) .with_extension("dry_run", serde_json::json!(true))
1494 }
1495
1496 pub(crate) fn simulate_claude_response(&self, _phase_id: PhaseId, _prompt: &str) -> String {
1498 match _phase_id {
1499 PhaseId::Requirements => {
1500 r"# Requirements Document
1502
1503## Introduction
1504
1505This is a generated requirements document for the current specification. The system will provide core functionality for managing and processing specifications through a structured workflow.
1506
1507## Requirements
1508
1509### Requirement 1
1510
1511**User Story:** As a developer, I want to generate structured requirements from rough ideas, so that I can create comprehensive specifications efficiently.
1512
1513#### Acceptance Criteria
1514
15151. WHEN I provide a problem statement THEN the system SHALL generate structured requirements in EARS format
15162. WHEN requirements are generated THEN they SHALL include user stories and acceptance criteria
15173. WHEN the process completes THEN the system SHALL produce both markdown and YAML artifacts
1518
1519### Requirement 2
1520
1521**User Story:** As a developer, I want deterministic output generation, so that I can reproduce results consistently.
1522
1523#### Acceptance Criteria
1524
15251. WHEN identical inputs are provided THEN the system SHALL produce identical canonicalized outputs
15262. WHEN artifacts are created THEN they SHALL include BLAKE3 hashes for verification
15273. WHEN the process runs THEN it SHALL create audit receipts for traceability
1528
1529### Requirement 3
1530
1531**User Story:** As a developer, I want atomic file operations, so that partial writes don't corrupt the system state.
1532
1533#### Acceptance Criteria
1534
15351. WHEN writing artifacts THEN the system SHALL use atomic write operations
15362. WHEN failures occur THEN partial artifacts SHALL be preserved for debugging
15373. WHEN operations complete THEN all files SHALL be in a consistent state
1538
1539## Non-Functional Requirements
1540
1541**NFR1 Performance:** The system SHALL complete requirements generation within reasonable time limits
1542**NFR2 Reliability:** All file operations SHALL be atomic to prevent corruption
1543**NFR3 Auditability:** All operations SHALL be logged with cryptographic verification
1544".to_string()
1545 }
1546 PhaseId::Design => {
1547 r"# Design Document
1548
1549## Overview
1550
1551This is a comprehensive design document for the current specification. The system implements a phase-based architecture for orchestrating spec generation workflows using the Claude CLI.
1552
1553## Architecture
1554
1555The system follows a modular architecture with clear separation of concerns:
1556
1557```mermaid
1558graph TD
1559 A[CLI Entry] --> B[Phase Orchestrator]
1560 B --> C[Requirements Phase]
1561 C --> D[Design Phase]
1562 D --> E[Tasks Phase]
1563 E --> F[Review Phase]
1564```
1565
1566## Components and Interfaces
1567
1568### Phase System
1569- **Phase trait**: Defines the interface for all workflow phases
1570- **PhaseOrchestrator**: Manages phase execution and dependencies
1571- **PhaseContext**: Provides context and configuration to phases
1572
1573### Artifact Management
1574- **ArtifactManager**: Handles atomic file operations and storage
1575- **ReceiptManager**: Creates and manages execution receipts
1576- **Canonicalizer**: Ensures deterministic output formatting
1577
1578## Data Models
1579
1580### Core Types
1581- `PhaseId`: Enumeration of available phases
1582- `Artifact`: Represents generated outputs with metadata
1583- `Receipt`: Audit trail for phase execution
1584
1585### Configuration
1586- `OrchestratorConfig`: Runtime configuration parameters
1587- `PhaseContext`: Execution context for phases
1588
1589## Error Handling
1590
1591The system implements comprehensive error handling with:
1592- Structured error types for different failure modes
1593- Partial artifact preservation on failures
1594- Detailed error reporting with context
1595
1596## Testing Strategy
1597
1598- Unit tests for individual components
1599- Integration tests for end-to-end workflows
1600- Property-based tests for determinism validation
1601- Mock Claude CLI for testing scenarios
1602".to_string()
1603 }
1604 PhaseId::Tasks => {
1605 r"# Implementation Plan
1606
1607## Milestone 1: Core Phase System
1608
1609- [ ] 1. Set up project structure and core interfaces
1610 - Create directory structure for phases, artifacts, and receipts
1611 - Define Phase trait with separated concerns (prompt, make_packet, postprocess)
1612 - Implement PhaseId enum and basic dependency system
1613 - _Requirements: R10.1, R10.3_
1614
1615- [ ] 2. Implement Requirements phase
1616- [ ] 2.1 Create RequirementsPhase struct
1617 - Implement Phase trait methods for requirements generation
1618 - Create prompt template for EARS format requirements
1619 - Add packet construction with basic context
1620 - _Requirements: R1.1_
1621
1622- [ ] 2.2 Add requirements postprocessing
1623 - Parse Claude response into requirements.md artifact
1624 - Generate requirements.core.yaml with structured data
1625 - Implement artifact creation and storage
1626 - _Requirements: R1.1, R2.1_
1627
1628- [ ]* 2.3 Write unit tests for Requirements phase
1629 - Test prompt generation and packet creation
1630 - Verify postprocessing creates correct artifacts
1631 - Test error handling scenarios
1632 - _Requirements: R1.1_
1633
1634## Milestone 2: Design and Tasks Phases
1635
1636- [ ] 3. Implement Design phase
1637- [ ] 3.1 Create DesignPhase struct
1638 - Implement Phase trait with architecture-focused prompts
1639 - Add dependency on Requirements phase
1640 - Include requirements artifacts in packet construction
1641 - _Requirements: R1.1_
1642
1643- [ ] 3.2 Add design postprocessing
1644 - Parse Claude response into design.md artifact
1645 - Generate design.core.yaml with structured data
1646 - Implement component and interface extraction
1647 - _Requirements: R1.1, R2.1_
1648
1649- [ ] 4. Implement Tasks phase
1650- [ ] 4.1 Create TasksPhase struct
1651 - Implement Phase trait with implementation planning prompts
1652 - Add dependencies on Design and Requirements phases
1653 - Include all upstream artifacts in packet construction
1654 - _Requirements: R1.1_
1655
1656- [ ] 4.2 Add tasks postprocessing
1657 - Parse Claude response into tasks.md artifact
1658 - Generate tasks.core.yaml with structured task data
1659 - Implement task parsing and validation
1660 - _Requirements: R1.1, R2.1_
1661
1662- [ ]* 4.3 Write integration tests for phase system
1663 - Test Requirements → Design → Tasks flow
1664 - Verify dependency checking works correctly
1665 - Test artifact propagation between phases
1666 - _Requirements: R1.1, R4.2_
1667
1668## Milestone 3: Orchestrator Integration
1669
1670- [ ] 5. Update PhaseOrchestrator for new phases
1671- [ ] 5.1 Add execution methods for Design and Tasks phases
1672 - Implement execute_design_phase method
1673 - Implement execute_tasks_phase method
1674 - Update dependency checking logic
1675 - _Requirements: R1.1, R4.2_
1676
1677- [ ] 5.2 Enhance Claude response simulation
1678 - Add realistic responses for Design phase
1679 - Add realistic responses for Tasks phase
1680 - Update test scenarios for all phases
1681 - _Requirements: R4.1_
1682
1683- [ ]* 5.3 Write end-to-end integration tests
1684 - Test complete Requirements → Design → Tasks workflow
1685 - Verify artifact creation and receipt generation
1686 - Test error handling and partial artifact storage
1687 - _Requirements: R1.1, R4.3_
1688".to_string()
1689 }
1690 _ => {
1691 format!("Simulated response for phase: {}", _phase_id.as_str())
1692 }
1693 }
1694 }
1695
1696 pub(crate) const fn get_phase_number(&self, phase_id: PhaseId) -> u8 {
1698 match phase_id {
1699 PhaseId::Requirements => 0,
1700 PhaseId::Design => 10,
1701 PhaseId::Tasks => 20,
1702 PhaseId::Review => 30,
1703 PhaseId::Fixup => 40,
1704 PhaseId::Final => 50,
1705 }
1706 }
1707
1708 pub(crate) fn get_phase_impl(
1711 &self,
1712 phase_id: PhaseId,
1713 config: &OrchestratorConfig,
1714 ) -> Result<Box<dyn Phase>> {
1715 match phase_id {
1716 PhaseId::Requirements => Ok(Box::new(RequirementsPhase::new())),
1717 PhaseId::Design => Ok(Box::new(DesignPhase::new())),
1718 PhaseId::Tasks => Ok(Box::new(TasksPhase::new())),
1719 PhaseId::Review => Ok(Box::new(ReviewPhase::new())),
1720 PhaseId::Fixup => {
1721 let apply_fixups = config
1723 .config
1724 .get("apply_fixups")
1725 .is_some_and(|s| s == "true");
1726
1727 let fixup_mode = if apply_fixups {
1728 FixupMode::Apply
1729 } else {
1730 FixupMode::Preview
1731 };
1732
1733 Ok(Box::new(FixupPhase::new_with_mode(fixup_mode)))
1734 }
1735 PhaseId::Final => Err(anyhow::anyhow!("Final phase not yet implemented")),
1736 }
1737 }
1738}