use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::{Context, Result};
use crate::error::{PhaseError, XCheckerError};
use crate::exit_codes;
use crate::fixup::{FixupMode, FixupPhase};
use crate::hooks::{HookContext, HookExecutor, HookType, execute_and_process_hook};
use crate::packet::PacketBuilder;
use crate::phase::{Phase, PhaseContext};
use crate::phases::{DesignPhase, RequirementsPhase, ReviewPhase, TasksPhase};
use crate::status::artifact::{Artifact, ArtifactType};
use crate::types::{ErrorKind, FileType, LlmInfo, PacketEvidence, PhaseId, PipelineInfo};
use super::llm::{ClaudeExecutionMetadata, LlmInvocationError};
use super::{OrchestratorConfig, PhaseOrchestrator, PhaseTimeout};
#[derive(Debug)]
pub struct ExecutionResult {
pub phase: PhaseId,
pub success: bool,
pub exit_code: i32,
pub artifact_paths: Vec<PathBuf>,
pub receipt_path: Option<PathBuf>,
pub error: Option<String>,
}
pub(crate) struct PhaseCoreOutput {
pub packet_evidence: PacketEvidence,
pub claude_exit_code: i32,
pub claude_metadata: Option<ClaudeExecutionMetadata>,
pub llm_result: Option<crate::llm::LlmResult>,
pub llm_fallback_warning: Option<String>,
pub phase_result: xchecker_phase_api::PhaseResult,
}
pub(crate) async fn execute_phase_with_timeout<F, T>(
fut: F,
phase_id: PhaseId,
timeout_config: &PhaseTimeout,
) -> Result<T, XCheckerError>
where
F: std::future::Future<Output = Result<T>>,
{
match tokio::time::timeout(timeout_config.duration, fut).await {
Ok(result) => result.map_err(|e| {
match e.downcast::<XCheckerError>() {
Ok(xchecker_err) => xchecker_err,
Err(_original_err) => {
XCheckerError::Phase(PhaseError::ExecutionFailed {
phase: phase_id.as_str().to_string(),
code: 1,
})
}
}
}),
Err(_) => {
Err(XCheckerError::Phase(PhaseError::Timeout {
phase: phase_id.as_str().to_string(),
timeout_seconds: timeout_config.duration.as_secs(),
}))
}
}
}
impl PhaseOrchestrator {
#[cfg_attr(not(test), allow(dead_code))]
pub async fn execute_requirements_phase(
&self,
config: &OrchestratorConfig,
) -> Result<ExecutionResult> {
self.validate_transition(PhaseId::Requirements)?;
let phase = self.get_phase_impl(PhaseId::Requirements, config)?;
self.execute_phase_with_timeout_handling(phase.as_ref(), config)
.await
}
#[allow(dead_code)] pub async fn execute_design_phase(
&self,
config: &OrchestratorConfig,
) -> Result<ExecutionResult> {
self.validate_transition(PhaseId::Design)?;
let phase = self.get_phase_impl(PhaseId::Design, config)?;
self.execute_phase_with_timeout_handling(phase.as_ref(), config)
.await
}
#[allow(dead_code)] pub async fn execute_tasks_phase(
&self,
config: &OrchestratorConfig,
) -> Result<ExecutionResult> {
self.validate_transition(PhaseId::Tasks)?;
let phase = self.get_phase_impl(PhaseId::Tasks, config)?;
self.execute_phase_with_timeout_handling(phase.as_ref(), config)
.await
}
pub async fn resume_from_phase(
&self,
phase_id: PhaseId,
config: &OrchestratorConfig,
) -> Result<ExecutionResult> {
self.validate_transition(phase_id)?;
let phase = self.get_phase_impl(phase_id, config)?;
self.execute_phase_with_resume(phase.as_ref(), config).await
}
pub(crate) async fn execute_phase_with_timeout_handling(
&self,
phase: &dyn Phase,
config: &OrchestratorConfig,
) -> Result<ExecutionResult> {
let phase_id = phase.id();
let timeout_config = PhaseTimeout::from_config(config);
match execute_phase_with_timeout(
self.execute_phase(phase, config),
phase_id,
&timeout_config,
)
.await
{
Ok(result) => Ok(result),
Err(XCheckerError::Phase(PhaseError::Timeout {
phase: _,
timeout_seconds,
})) => {
self.handle_phase_timeout(phase_id, timeout_seconds, config)
.await
}
Err(e) => Err(e.into()),
}
}
async fn handle_phase_timeout(
&self,
phase_id: PhaseId,
timeout_seconds: u64,
config: &OrchestratorConfig,
) -> Result<ExecutionResult> {
let partial_content = format!(
"# {} Phase (Partial - Timeout)\n\nThis phase timed out after {} seconds.\n\nNo output was generated before the timeout occurred.\n",
phase_id.as_str(),
timeout_seconds
);
let partial_filename = format!(
"{:02}-{}.partial.md",
self.get_phase_number(phase_id),
phase_id.as_str().to_lowercase()
);
let partial_artifact = Artifact {
name: partial_filename,
content: partial_content.clone(),
artifact_type: ArtifactType::Partial,
blake3_hash: blake3::hash(partial_content.as_bytes())
.to_hex()
.to_string(),
};
let partial_result = self.artifact_manager().store_artifact(&partial_artifact)?;
let partial_path = partial_result.path;
let packet_evidence = PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
};
let mut flags = HashMap::new();
flags.insert("phase".to_string(), phase_id.as_str().to_string());
let warnings = vec![format!("phase_timeout:{}", timeout_seconds)];
let pipeline_info = Some(PipelineInfo {
execution_strategy: Some("controlled".to_string()),
});
let configured_model = config.config.get("model").map_or("unknown", |s| s.as_str());
let configured_runner = config
.config
.get("runner_mode")
.map_or("unknown", |s| s.as_str());
let receipt = self.receipt_manager().create_receipt_with_redactor(
config.redactor.as_ref(),
self.spec_id(),
phase_id,
exit_codes::codes::PHASE_TIMEOUT, vec![], env!("CARGO_PKG_VERSION"),
"unknown", configured_model,
None, flags,
packet_evidence,
None, None, warnings,
None, configured_runner,
None, Some(ErrorKind::PhaseTimeout),
Some(format!("Phase timed out after {timeout_seconds} seconds")),
None, pipeline_info,
);
let receipt_path = self.receipt_manager().write_receipt(&receipt)?;
Ok(ExecutionResult {
phase: phase_id,
success: false,
exit_code: exit_codes::codes::PHASE_TIMEOUT,
artifact_paths: vec![partial_path.into_std_path_buf()],
receipt_path: Some(receipt_path.into_std_path_buf()),
error: Some(format!("Phase timed out after {timeout_seconds} seconds")),
})
}
async fn execute_phase_with_resume(
&self,
phase: &dyn Phase,
config: &OrchestratorConfig,
) -> Result<ExecutionResult> {
let phase_id = phase.id();
let has_partial = self.artifact_manager().has_partial_artifact(phase_id);
if has_partial {
println!(
"Found partial artifact for {} phase from previous failed run",
phase_id.as_str()
);
if !config.dry_run {
println!("Deleting partial artifact and starting fresh...");
self.artifact_manager().delete_partial_artifact(phase_id)?;
}
}
let result = self.execute_phase(phase, config).await?;
if result.success {
if let Err(e) = self.artifact_manager().delete_partial_artifact(phase_id) {
eprintln!("Warning: Failed to clean up partial artifact: {e}");
}
}
Ok(result)
}
pub(crate) async fn execute_phase_core(
&self,
phase: &dyn Phase,
config: &OrchestratorConfig,
) -> Result<PhaseCoreOutput> {
let phase_id = phase.id();
let phase_context = self.create_phase_context(phase_id, config)?;
self.check_phase_dependencies(phase)?;
let prompt = phase.prompt(&phase_context);
let packet = phase.make_packet(&phase_context).map_err(|e| {
XCheckerError::Phase(PhaseError::PacketCreationFailed {
phase: phase_id.as_str().to_string(),
reason: e.to_string(),
})
})?;
let budget = packet.budget_usage();
tracing::info!(
target: "xchecker::packet",
spec_id = %self.spec_id(),
phase = %phase_id.as_str(),
packet_hash = %packet.hash(),
bytes_used = budget.bytes_used,
bytes_limit = budget.max_bytes,
lines_used = budget.lines_used,
lines_limit = budget.max_lines,
"Built packet for phase"
);
let packet_evidence = packet.evidence.clone();
let redactor = config.redactor.as_ref();
if redactor.has_secrets(&packet.content, "packet")? {
return Err(XCheckerError::Phase(PhaseError::ExecutionFailed {
phase: phase_id.as_str().to_string(),
code: exit_codes::codes::SECRET_DETECTED,
})
.into());
}
let _packet_preview_path = self
.artifact_manager()
.store_context_file(&format!("{}-packet", phase_id.as_str()), &packet.content)?;
let debug_packet_enabled = config
.config
.get("debug_packet")
.is_some_and(|s| s == "true");
if debug_packet_enabled {
let context_dir = self.artifact_manager().context_path();
let temp_builder = PacketBuilder::new().map_err(|e| {
XCheckerError::Phase(PhaseError::PacketCreationFailed {
phase: phase_id.as_str().to_string(),
reason: format!("Failed to create PacketBuilder for debug packet: {e}"),
})
})?;
if let Err(e) =
temp_builder.write_debug_packet(&packet.content, phase_id.as_str(), &context_dir)
{
eprintln!("Warning: Failed to write debug packet: {e}");
}
}
let (claude_response, claude_exit_code, claude_metadata, llm_result, llm_fallback_warning) =
if config.dry_run {
let simulated_llm = self.simulate_llm_result(phase_id);
let simulated_metadata = super::llm::ClaudeExecutionMetadata {
model_alias: None,
model_full_name: "haiku".to_string(),
claude_cli_version: "0.8.1".to_string(),
fallback_used: false,
runner: "simulated".to_string(),
runner_distro: None,
stderr_tail: None,
};
(
self.simulate_claude_response(phase_id, &prompt),
0,
Some(simulated_metadata),
Some(simulated_llm),
None,
)
} else {
self.run_llm_invocation(&prompt, &packet.content, phase_id, config)
.await?
};
let phase_result = if claude_exit_code == 0 {
phase
.postprocess(&claude_response, &phase_context)
.with_context(|| {
format!(
"Failed to postprocess response for phase: {}",
phase_id.as_str()
)
})?
} else {
xchecker_phase_api::PhaseResult {
artifacts: vec![],
next_step: xchecker_phase_api::NextStep::Continue,
metadata: xchecker_phase_api::PhaseMetadata::default(),
}
};
for artifact in &phase_result.artifacts {
let _partial_result = self
.artifact_manager()
.store_partial_staged_artifact(artifact)
.with_context(|| format!("Failed to store partial artifact: {}", artifact.name))?;
}
for artifact in &phase_result.artifacts {
let _final_path = self
.artifact_manager()
.promote_staged_to_final(&artifact.name)
.with_context(|| {
format!("Failed to promote artifact to final: {}", artifact.name)
})?;
}
Ok(PhaseCoreOutput {
packet_evidence,
claude_exit_code,
claude_metadata,
llm_result,
llm_fallback_warning,
phase_result,
})
}
pub(crate) async fn execute_phase(
&self,
phase: &dyn Phase,
config: &OrchestratorConfig,
) -> Result<ExecutionResult> {
let phase_id = phase.id();
let pipeline_info = Some(PipelineInfo {
execution_strategy: Some("controlled".to_string()),
});
self.artifact_manager()
.remove_stale_partial_dir()
.with_context(|| "Failed to remove stale .partial/ directory")?;
let phase_context = self.create_phase_context(phase_id, config)?;
self.check_phase_dependencies(phase)?;
let mut hook_warnings: Vec<String> = Vec::new();
if let Some(ref hooks_config) = config.hooks
&& let Some(hook_config) = hooks_config.get_pre_phase_hook(phase_id)
{
let executor = HookExecutor::new(
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
);
let context = HookContext::new(self.spec_id(), phase_id, HookType::PrePhase);
match execute_and_process_hook(
&executor,
hook_config,
&context,
HookType::PrePhase,
phase_id,
)
.await
{
Ok(outcome) => {
if let Some(warning) = outcome.warning() {
hook_warnings.push(warning.to_warning_string());
}
if !outcome.should_continue() {
let error_reason = format!(
"Pre-phase hook failed: {}",
outcome.error().map(|e| e.to_string()).unwrap_or_default()
);
let packet_evidence = PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
};
let mut flags = HashMap::new();
flags.insert("phase".to_string(), phase_id.as_str().to_string());
flags.insert("hook_failure".to_string(), "pre_phase".to_string());
let configured_model =
config.config.get("model").map_or("unknown", |s| s.as_str());
let configured_runner = config
.config
.get("runner_mode")
.map_or("unknown", |s| s.as_str());
let receipt = self.receipt_manager().create_receipt_with_redactor(
config.redactor.as_ref(),
self.spec_id(),
phase_id,
exit_codes::codes::CLAUDE_FAILURE,
vec![], env!("CARGO_PKG_VERSION"),
"unknown", configured_model,
None,
flags,
packet_evidence,
None,
None,
hook_warnings.clone(),
None,
configured_runner,
None,
Some(ErrorKind::ClaudeFailure),
Some(error_reason.clone()),
None,
pipeline_info.clone(),
);
let receipt_path = self.receipt_manager().write_receipt(&receipt)?;
return Ok(ExecutionResult {
phase: phase_id,
success: false,
exit_code: exit_codes::codes::CLAUDE_FAILURE,
artifact_paths: vec![],
receipt_path: Some(receipt_path.into_std_path_buf()),
error: Some(error_reason),
});
}
}
Err(e) => {
let error_reason = format!("Pre-phase hook error: {}", e);
let packet_evidence = PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
};
let mut flags = HashMap::new();
flags.insert("phase".to_string(), phase_id.as_str().to_string());
flags.insert("hook_error".to_string(), "pre_phase".to_string());
let configured_model =
config.config.get("model").map_or("unknown", |s| s.as_str());
let configured_runner = config
.config
.get("runner_mode")
.map_or("unknown", |s| s.as_str());
let receipt = self.receipt_manager().create_receipt_with_redactor(
config.redactor.as_ref(),
self.spec_id(),
phase_id,
exit_codes::codes::CLAUDE_FAILURE,
vec![], env!("CARGO_PKG_VERSION"),
"unknown", configured_model,
None,
flags,
packet_evidence,
None,
None,
vec![format!("hook_error:pre_phase:{}", e)],
None,
configured_runner,
None,
Some(ErrorKind::ClaudeFailure),
Some(error_reason.clone()),
None,
pipeline_info.clone(),
);
let receipt_path = self.receipt_manager().write_receipt(&receipt)?;
return Ok(ExecutionResult {
phase: phase_id,
success: false,
exit_code: exit_codes::codes::CLAUDE_FAILURE,
artifact_paths: vec![],
receipt_path: Some(receipt_path.into_std_path_buf()),
error: Some(error_reason),
});
}
}
}
let prompt = phase.prompt(&phase_context);
let packet = phase.make_packet(&phase_context).map_err(|e| {
XCheckerError::Phase(PhaseError::PacketCreationFailed {
phase: phase_id.as_str().to_string(),
reason: e.to_string(),
})
})?;
let budget = packet.budget_usage();
tracing::info!(
target: "xchecker::packet",
spec_id = %self.spec_id(),
phase = %phase_id.as_str(),
packet_hash = %packet.hash(),
bytes_used = budget.bytes_used,
bytes_limit = budget.max_bytes,
lines_used = budget.lines_used,
lines_limit = budget.max_lines,
"Built packet for phase"
);
let redactor = config.redactor.as_ref();
if redactor.has_secrets(&packet.content, "packet")? {
let matches = redactor.scan_for_secrets(&packet.content, "packet")?;
let packet_evidence = packet.evidence.clone();
let mut flags = HashMap::new();
flags.insert("phase".to_string(), phase_id.as_str().to_string());
let secret_patterns: Vec<String> =
matches.iter().map(|m| m.pattern_id.clone()).collect();
let error_reason = format!(
"Secret detected in packet. Matched patterns: {}",
secret_patterns.join(", ")
);
let receipt = self.receipt_manager().create_receipt_with_redactor(
config.redactor.as_ref(),
self.spec_id(),
phase_id,
exit_codes::codes::SECRET_DETECTED, vec![], env!("CARGO_PKG_VERSION"),
"0.8.1", "haiku", None, flags,
packet_evidence,
None, None, vec![format!("Secret detection prevented Claude invocation")],
None, "native", None, Some(ErrorKind::SecretDetected),
Some(error_reason.clone()),
None, pipeline_info.clone(),
);
let receipt_path = self.receipt_manager().write_receipt(&receipt)?;
return Ok(ExecutionResult {
phase: phase_id,
success: false,
exit_code: exit_codes::codes::SECRET_DETECTED,
artifact_paths: vec![],
receipt_path: Some(receipt_path.into_std_path_buf()),
error: Some(error_reason),
});
}
let _packet_preview_path = self
.artifact_manager()
.store_context_file(&format!("{}-packet", phase_id.as_str()), &packet.content)?;
let debug_packet_enabled = config
.config
.get("debug_packet")
.is_some_and(|s| s == "true");
if debug_packet_enabled {
let context_dir = self.artifact_manager().context_path();
let temp_builder = PacketBuilder::new().map_err(|e| {
XCheckerError::Phase(PhaseError::PacketCreationFailed {
phase: phase_id.as_str().to_string(),
reason: format!("Failed to create PacketBuilder for debug packet: {e}"),
})
})?;
if let Err(e) =
temp_builder.write_debug_packet(&packet.content, phase_id.as_str(), &context_dir)
{
eprintln!("Warning: Failed to write debug packet: {e}");
}
}
let mut llm_fallback_warning: Option<String> = None;
let (claude_response, claude_exit_code, claude_metadata, llm_result) = if config.dry_run {
let simulated_llm = self.simulate_llm_result(phase_id);
let simulated_metadata = super::llm::ClaudeExecutionMetadata {
model_alias: None,
model_full_name: "haiku".to_string(),
claude_cli_version: "0.8.1".to_string(),
fallback_used: false,
runner: "simulated".to_string(),
runner_distro: None,
stderr_tail: None,
};
(
self.simulate_claude_response(phase_id, &prompt),
0,
Some(simulated_metadata),
Some(simulated_llm),
)
} else {
match self
.run_llm_invocation(&prompt, &packet.content, phase_id, config)
.await
{
Ok((response, exit_code, metadata, result, fallback_warning)) => {
llm_fallback_warning = fallback_warning;
(response, exit_code, metadata, result)
}
Err(e) => {
let (xchecker_err, fallback_warning) =
if let Some(invocation_err) = e.downcast_ref::<LlmInvocationError>() {
(
invocation_err.error(),
invocation_err.fallback_warning().map(|s| s.to_string()),
)
} else if let Some(xchecker_err) = e.downcast_ref::<XCheckerError>() {
(xchecker_err, None)
} else {
return Err(e);
};
llm_fallback_warning = fallback_warning;
if let XCheckerError::Llm(llm_err) = xchecker_err {
if matches!(llm_err, crate::llm::LlmError::BudgetExceeded { .. }) {
let packet_evidence = packet.evidence.clone();
let mut flags = HashMap::new();
flags.insert("phase".to_string(), phase_id.as_str().to_string());
let configured_model =
config.config.get("model").map_or("unknown", |s| s.as_str());
let configured_runner = config
.config
.get("runner_mode")
.map_or("unknown", |s| s.as_str());
let mut warnings = vec![format!("LLM budget exhausted: {}", llm_err)];
if let Some(ref warning) = llm_fallback_warning {
warnings.push(warning.clone());
}
let mut receipt = self.receipt_manager().create_receipt_with_redactor(
config.redactor.as_ref(),
self.spec_id(),
phase_id,
exit_codes::codes::CLAUDE_FAILURE, vec![], env!("CARGO_PKG_VERSION"),
"unknown", configured_model,
None, flags,
packet_evidence,
None, None, warnings,
None, configured_runner,
None, Some(ErrorKind::ClaudeFailure),
Some(llm_err.to_string()),
None, pipeline_info.clone(),
);
receipt.llm = Some(LlmInfo::for_budget_exhaustion());
let receipt_path = self.receipt_manager().write_receipt(&receipt)?;
return Ok(ExecutionResult {
phase: phase_id,
success: false,
exit_code: exit_codes::codes::CLAUDE_FAILURE,
artifact_paths: vec![],
receipt_path: Some(receipt_path.into_std_path_buf()),
error: Some(llm_err.to_string()),
});
}
let packet_evidence = packet.evidence.clone();
let mut flags = HashMap::new();
flags.insert("phase".to_string(), phase_id.as_str().to_string());
let configured_model =
config.config.get("model").map_or("unknown", |s| s.as_str());
let configured_runner = config
.config
.get("runner_mode")
.map_or("unknown", |s| s.as_str());
let (exit_code, error_kind) =
exit_codes::error_to_exit_code_and_kind(xchecker_err);
let invocation =
self.build_llm_invocation(phase_id, &prompt, &packet.content, config);
let provider = self
.config_from_orchestrator_config(config)
.llm
.provider
.unwrap_or_else(|| "claude-cli".to_string());
let mut llm_info = LlmInfo {
provider: Some(provider),
model_used: if invocation.model.is_empty() {
None
} else {
Some(invocation.model.clone())
},
tokens_input: None,
tokens_output: None,
timed_out: None,
timeout_seconds: Some(invocation.timeout.as_secs()),
budget_exhausted: None,
};
let mut warnings = Vec::new();
match llm_err {
crate::llm::LlmError::Timeout { duration } => {
llm_info.timed_out = Some(true);
llm_info.timeout_seconds = Some(duration.as_secs());
warnings.push(format!("phase_timeout:{}", duration.as_secs()));
}
_ => {
llm_info.timed_out = Some(false);
warnings.push(format!("llm_error:{}", llm_err));
}
}
if let Some(ref warning) = llm_fallback_warning {
warnings.push(warning.clone());
}
let mut receipt = self.receipt_manager().create_receipt_with_redactor(
config.redactor.as_ref(),
self.spec_id(),
phase_id,
exit_code,
vec![], env!("CARGO_PKG_VERSION"),
"unknown", configured_model,
None, flags,
packet_evidence,
None, None, warnings,
None, configured_runner,
None, Some(error_kind),
Some(llm_err.to_string()),
None, pipeline_info.clone(),
);
receipt.llm = Some(llm_info);
let receipt_path = self.receipt_manager().write_receipt(&receipt)?;
return Ok(ExecutionResult {
phase: phase_id,
success: false,
exit_code,
artifact_paths: vec![],
receipt_path: Some(receipt_path.into_std_path_buf()),
error: Some(llm_err.to_string()),
});
}
return Err(e);
}
}
};
if claude_exit_code != 0 {
let partial_filename = format!(
"{:02}-{}.partial.md",
self.get_phase_number(phase_id),
phase_id.as_str().to_lowercase()
);
let partial_result = self.artifact_manager().store_artifact(&Artifact {
name: partial_filename.clone(),
content: claude_response.clone(),
artifact_type: ArtifactType::Partial,
blake3_hash: blake3::hash(claude_response.as_bytes())
.to_hex()
.to_string(),
})?;
let partial_path = partial_result.path;
let packet_evidence = packet.evidence.clone();
let mut flags = HashMap::new();
flags.insert("phase".to_string(), phase_id.as_str().to_string());
let (model_alias, model_full_name) = if let Some(metadata) = &claude_metadata {
(
metadata.model_alias.clone(),
metadata.model_full_name.clone(),
)
} else {
(None, "haiku".to_string())
};
let mut warnings = vec!["Phase execution failed with non-zero exit code".to_string()];
if let Some(ref warning) = llm_fallback_warning {
warnings.push(warning.clone());
}
let mut receipt = self.receipt_manager().create_receipt_with_redactor(
config.redactor.as_ref(),
self.spec_id(),
phase_id,
claude_exit_code,
vec![], env!("CARGO_PKG_VERSION"),
claude_metadata
.as_ref()
.map_or("0.8.1", |m| m.claude_cli_version.as_str()),
&model_full_name,
model_alias,
flags,
packet_evidence,
Some("Claude CLI execution failed".to_string()), None, warnings,
claude_metadata.as_ref().map(|m| m.fallback_used),
claude_metadata
.as_ref()
.map_or("native", |m| m.runner.as_str()),
claude_metadata
.as_ref()
.and_then(|m| m.runner_distro.clone()),
Some(ErrorKind::ClaudeFailure),
Some("Claude CLI execution failed".to_string()),
None, pipeline_info.clone(),
);
receipt.llm = llm_result.map(|result| result.into_llm_info());
let receipt_path = self.receipt_manager().write_receipt(&receipt)?;
let stderr_info = claude_metadata
.as_ref()
.and_then(|m| m.stderr_tail.clone())
.unwrap_or_else(|| "No stderr captured".to_string());
let enhanced_error = if !stderr_info.is_empty() && stderr_info != "No stderr captured" {
XCheckerError::Phase(PhaseError::ExecutionFailedWithStderr {
phase: phase_id.as_str().to_string(),
code: claude_exit_code,
stderr_tail: stderr_info,
})
} else {
XCheckerError::Phase(PhaseError::PartialOutputSaved {
phase: phase_id.as_str().to_string(),
partial_path: format!("artifacts/{partial_filename}"),
})
};
return Ok(ExecutionResult {
phase: phase_id,
success: false,
exit_code: claude_exit_code,
artifact_paths: vec![partial_path.into_std_path_buf()], receipt_path: Some(receipt_path.into_std_path_buf()),
error: Some(enhanced_error.to_string()),
});
}
let phase_result = phase
.postprocess(&claude_response, &phase_context)
.with_context(|| {
format!(
"Failed to postprocess response for phase: {}",
phase_id.as_str()
)
})?;
let mut artifact_paths = Vec::new();
let mut output_hashes = Vec::new();
let mut atomic_write_warnings = Vec::new();
for artifact in &phase_result.artifacts {
let partial_result = self
.artifact_manager()
.store_partial_staged_artifact(artifact)
.with_context(|| format!("Failed to store partial artifact: {}", artifact.name))?;
for warning in &partial_result.atomic_write_result.warnings {
atomic_write_warnings.push(format!("{}: {}", artifact.name, warning));
}
let file_type = if let Some(ext) = std::path::Path::new(&artifact.name).extension() {
FileType::from_extension(ext.to_str().unwrap_or(""))
} else {
match artifact.artifact_type {
ArtifactType::Markdown => FileType::Markdown,
ArtifactType::CoreYaml => FileType::Yaml,
_ => FileType::Text,
}
};
let file_hash = self
.receipt_manager()
.create_file_hash(
&format!("artifacts/{}", artifact.name),
&artifact.content,
file_type,
phase_id.as_str(),
)
.map_err(|e| {
XCheckerError::Phase(PhaseError::OutputValidationFailed {
phase: phase_id.as_str().to_string(),
reason: e.to_string(),
})
})?;
output_hashes.push(file_hash);
}
for artifact in &phase_result.artifacts {
let final_path = self
.artifact_manager()
.promote_staged_to_final(&artifact.name)
.with_context(|| {
format!("Failed to promote artifact to final: {}", artifact.name)
})?;
artifact_paths.push(final_path.into_std_path_buf());
}
let packet_evidence = packet.evidence.clone();
let mut flags = HashMap::new();
flags.insert("phase".to_string(), phase_id.as_str().to_string());
let (model_alias, model_full_name) = if let Some(metadata) = &claude_metadata {
(
metadata.model_alias.clone(),
metadata.model_full_name.clone(),
)
} else {
(None, "haiku".to_string())
};
let mut warnings: Vec<String> = atomic_write_warnings
.into_iter()
.chain(hook_warnings.iter().cloned())
.collect();
if let Some(warning) = llm_fallback_warning {
warnings.push(warning);
}
let mut receipt = self.receipt_manager().create_receipt_with_redactor(
config.redactor.as_ref(),
self.spec_id(),
phase_id,
0, output_hashes,
env!("CARGO_PKG_VERSION"),
claude_metadata
.as_ref()
.map_or("0.8.1", |m| m.claude_cli_version.as_str()),
&model_full_name,
model_alias,
flags,
packet_evidence,
None, None, warnings, claude_metadata.as_ref().map(|m| m.fallback_used),
claude_metadata
.as_ref()
.map_or("native", |m| m.runner.as_str()),
claude_metadata
.as_ref()
.and_then(|m| m.runner_distro.clone()),
None, None, None, pipeline_info.clone(),
);
receipt.llm = llm_result.map(|r| r.into_llm_info());
let receipt_path = self
.receipt_manager()
.write_receipt(&receipt)
.with_context(|| format!("Failed to write receipt for phase: {}", phase_id.as_str()))?;
if let Some(ref hooks_config) = config.hooks
&& let Some(hook_config) = hooks_config.get_post_phase_hook(phase_id)
{
let executor = HookExecutor::new(
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
);
let context = HookContext::new(self.spec_id(), phase_id, HookType::PostPhase);
match execute_and_process_hook(
&executor,
hook_config,
&context,
HookType::PostPhase,
phase_id,
)
.await
{
Ok(outcome) => {
if let Some(warning) = outcome.warning() {
tracing::warn!(
phase = %phase_id.as_str(),
"Post-phase hook warning: {}",
warning.to_warning_string()
);
}
if !outcome.should_continue() {
tracing::warn!(
phase = %phase_id.as_str(),
"Post-phase hook had on_fail=fail but phase artifacts already created; treating as warning"
);
}
}
Err(e) => {
tracing::warn!(
phase = %phase_id.as_str(),
error = %e,
"Post-phase hook execution error (treated as warning)"
);
}
}
}
Ok(ExecutionResult {
phase: phase_id,
success: true,
exit_code: 0,
artifact_paths,
receipt_path: Some(receipt_path.into_std_path_buf()),
error: None,
})
}
pub(crate) fn create_phase_context(
&self,
phase_id: PhaseId,
config: &OrchestratorConfig,
) -> Result<PhaseContext> {
let artifacts = self.artifact_manager().list_artifacts().map_err(|e| {
XCheckerError::Phase(PhaseError::ContextCreationFailed {
phase: phase_id.as_str().to_string(),
reason: format!("Failed to list existing artifacts: {e}"),
})
})?;
Ok(PhaseContext {
spec_id: self.spec_id().to_string(),
spec_dir: self
.artifact_manager()
.base_path()
.clone()
.into_std_path_buf(),
config: config.config.clone(),
artifacts,
selectors: config.selectors.clone(),
strict_validation: config.strict_validation,
redactor: config.redactor.clone(),
})
}
pub(crate) fn check_phase_dependencies(&self, phase: &dyn Phase) -> Result<()> {
let deps = phase.deps();
for dep_phase in deps {
if let Some(receipt) = self.receipt_manager().read_latest_receipt(*dep_phase)? {
if receipt.exit_code != 0 {
return Err(XCheckerError::Phase(PhaseError::DependencyNotSatisfied {
phase: phase.id().as_str().to_string(),
dependency: dep_phase.as_str().to_string(),
})
.into());
}
} else {
return Err(XCheckerError::Phase(PhaseError::DependencyNotSatisfied {
phase: phase.id().as_str().to_string(),
dependency: dep_phase.as_str().to_string(),
})
.into());
}
}
Ok(())
}
pub(crate) fn simulate_llm_result(&self, _phase_id: PhaseId) -> crate::llm::LlmResult {
crate::llm::LlmResult::new(
"simulated response".to_string(),
"claude-cli-simulated".to_string(),
"haiku".to_string(),
)
.with_tokens(1000, 2000)
.with_timeout(false)
.with_timeout_seconds(600) .with_extension("dry_run", serde_json::json!(true))
}
pub(crate) fn simulate_claude_response(&self, _phase_id: PhaseId, _prompt: &str) -> String {
match _phase_id {
PhaseId::Requirements => {
r"# Requirements Document
## Introduction
This is a generated requirements document for the current specification. The system will provide core functionality for managing and processing specifications through a structured workflow.
## Requirements
### Requirement 1
**User Story:** As a developer, I want to generate structured requirements from rough ideas, so that I can create comprehensive specifications efficiently.
#### Acceptance Criteria
1. WHEN I provide a problem statement THEN the system SHALL generate structured requirements in EARS format
2. WHEN requirements are generated THEN they SHALL include user stories and acceptance criteria
3. WHEN the process completes THEN the system SHALL produce both markdown and YAML artifacts
### Requirement 2
**User Story:** As a developer, I want deterministic output generation, so that I can reproduce results consistently.
#### Acceptance Criteria
1. WHEN identical inputs are provided THEN the system SHALL produce identical canonicalized outputs
2. WHEN artifacts are created THEN they SHALL include BLAKE3 hashes for verification
3. WHEN the process runs THEN it SHALL create audit receipts for traceability
### Requirement 3
**User Story:** As a developer, I want atomic file operations, so that partial writes don't corrupt the system state.
#### Acceptance Criteria
1. WHEN writing artifacts THEN the system SHALL use atomic write operations
2. WHEN failures occur THEN partial artifacts SHALL be preserved for debugging
3. WHEN operations complete THEN all files SHALL be in a consistent state
## Non-Functional Requirements
**NFR1 Performance:** The system SHALL complete requirements generation within reasonable time limits
**NFR2 Reliability:** All file operations SHALL be atomic to prevent corruption
**NFR3 Auditability:** All operations SHALL be logged with cryptographic verification
".to_string()
}
PhaseId::Design => {
r"# Design Document
## Overview
This 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.
## Architecture
The system follows a modular architecture with clear separation of concerns:
```mermaid
graph TD
A[CLI Entry] --> B[Phase Orchestrator]
B --> C[Requirements Phase]
C --> D[Design Phase]
D --> E[Tasks Phase]
E --> F[Review Phase]
```
## Components and Interfaces
### Phase System
- **Phase trait**: Defines the interface for all workflow phases
- **PhaseOrchestrator**: Manages phase execution and dependencies
- **PhaseContext**: Provides context and configuration to phases
### Artifact Management
- **ArtifactManager**: Handles atomic file operations and storage
- **ReceiptManager**: Creates and manages execution receipts
- **Canonicalizer**: Ensures deterministic output formatting
## Data Models
### Core Types
- `PhaseId`: Enumeration of available phases
- `Artifact`: Represents generated outputs with metadata
- `Receipt`: Audit trail for phase execution
### Configuration
- `OrchestratorConfig`: Runtime configuration parameters
- `PhaseContext`: Execution context for phases
## Error Handling
The system implements comprehensive error handling with:
- Structured error types for different failure modes
- Partial artifact preservation on failures
- Detailed error reporting with context
## Testing Strategy
- Unit tests for individual components
- Integration tests for end-to-end workflows
- Property-based tests for determinism validation
- Mock Claude CLI for testing scenarios
".to_string()
}
PhaseId::Tasks => {
r"# Implementation Plan
## Milestone 1: Core Phase System
- [ ] 1. Set up project structure and core interfaces
- Create directory structure for phases, artifacts, and receipts
- Define Phase trait with separated concerns (prompt, make_packet, postprocess)
- Implement PhaseId enum and basic dependency system
- _Requirements: R10.1, R10.3_
- [ ] 2. Implement Requirements phase
- [ ] 2.1 Create RequirementsPhase struct
- Implement Phase trait methods for requirements generation
- Create prompt template for EARS format requirements
- Add packet construction with basic context
- _Requirements: R1.1_
- [ ] 2.2 Add requirements postprocessing
- Parse Claude response into requirements.md artifact
- Generate requirements.core.yaml with structured data
- Implement artifact creation and storage
- _Requirements: R1.1, R2.1_
- [ ]* 2.3 Write unit tests for Requirements phase
- Test prompt generation and packet creation
- Verify postprocessing creates correct artifacts
- Test error handling scenarios
- _Requirements: R1.1_
## Milestone 2: Design and Tasks Phases
- [ ] 3. Implement Design phase
- [ ] 3.1 Create DesignPhase struct
- Implement Phase trait with architecture-focused prompts
- Add dependency on Requirements phase
- Include requirements artifacts in packet construction
- _Requirements: R1.1_
- [ ] 3.2 Add design postprocessing
- Parse Claude response into design.md artifact
- Generate design.core.yaml with structured data
- Implement component and interface extraction
- _Requirements: R1.1, R2.1_
- [ ] 4. Implement Tasks phase
- [ ] 4.1 Create TasksPhase struct
- Implement Phase trait with implementation planning prompts
- Add dependencies on Design and Requirements phases
- Include all upstream artifacts in packet construction
- _Requirements: R1.1_
- [ ] 4.2 Add tasks postprocessing
- Parse Claude response into tasks.md artifact
- Generate tasks.core.yaml with structured task data
- Implement task parsing and validation
- _Requirements: R1.1, R2.1_
- [ ]* 4.3 Write integration tests for phase system
- Test Requirements → Design → Tasks flow
- Verify dependency checking works correctly
- Test artifact propagation between phases
- _Requirements: R1.1, R4.2_
## Milestone 3: Orchestrator Integration
- [ ] 5. Update PhaseOrchestrator for new phases
- [ ] 5.1 Add execution methods for Design and Tasks phases
- Implement execute_design_phase method
- Implement execute_tasks_phase method
- Update dependency checking logic
- _Requirements: R1.1, R4.2_
- [ ] 5.2 Enhance Claude response simulation
- Add realistic responses for Design phase
- Add realistic responses for Tasks phase
- Update test scenarios for all phases
- _Requirements: R4.1_
- [ ]* 5.3 Write end-to-end integration tests
- Test complete Requirements → Design → Tasks workflow
- Verify artifact creation and receipt generation
- Test error handling and partial artifact storage
- _Requirements: R1.1, R4.3_
".to_string()
}
_ => {
format!("Simulated response for phase: {}", _phase_id.as_str())
}
}
}
pub(crate) const fn get_phase_number(&self, phase_id: PhaseId) -> u8 {
match phase_id {
PhaseId::Requirements => 0,
PhaseId::Design => 10,
PhaseId::Tasks => 20,
PhaseId::Review => 30,
PhaseId::Fixup => 40,
PhaseId::Final => 50,
}
}
pub(crate) fn get_phase_impl(
&self,
phase_id: PhaseId,
config: &OrchestratorConfig,
) -> Result<Box<dyn Phase>> {
match phase_id {
PhaseId::Requirements => Ok(Box::new(RequirementsPhase::new())),
PhaseId::Design => Ok(Box::new(DesignPhase::new())),
PhaseId::Tasks => Ok(Box::new(TasksPhase::new())),
PhaseId::Review => Ok(Box::new(ReviewPhase::new())),
PhaseId::Fixup => {
let apply_fixups = config
.config
.get("apply_fixups")
.is_some_and(|s| s == "true");
let fixup_mode = if apply_fixups {
FixupMode::Apply
} else {
FixupMode::Preview
};
Ok(Box::new(FixupPhase::new_with_mode(fixup_mode)))
}
PhaseId::Final => Err(anyhow::anyhow!("Final phase not yet implemented")),
}
}
}