Skip to main content

xchecker_engine/orchestrator/
phase_exec.rs

1//! Single-phase execution, timeout handling, and receipt emission.
2//!
3//! This module contains phase execution code extracted from mod.rs.
4
5use 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/// Result of executing a phase through the orchestrator.
24///
25/// Contains all information about the phase execution including
26/// success status, artifacts created, and any errors encountered.
27///
28/// This is the primary result type returned by orchestrator execution methods
29/// and is intended for CLI and Kiro callers to inspect execution outcomes.
30///
31/// # Fields
32/// - `phase`: The phase that was executed
33/// - `success`: Whether the phase completed successfully (exit_code == 0)
34/// - `exit_code`: Exit code from the phase execution (0 = success, see `exit_codes` module)
35/// - `artifact_paths`: Paths to artifacts that were created (empty on failure)
36/// - `receipt_path`: Path to the receipt file (always present, even on failure)
37/// - `error`: Human-readable error message if execution failed
38#[derive(Debug)]
39pub struct ExecutionResult {
40    /// The phase that was executed
41    pub phase: PhaseId,
42    /// Whether the phase completed successfully
43    pub success: bool,
44    /// Exit code from the phase execution
45    pub exit_code: i32,
46    /// Paths to artifacts that were created
47    pub artifact_paths: Vec<PathBuf>,
48    /// Path to the receipt file
49    pub receipt_path: Option<PathBuf>,
50    /// Any error that occurred during execution
51    pub error: Option<String>,
52}
53
54// ============================================================================
55// ORC-002: Phase Core Execution Architecture
56// ============================================================================
57//
58// The execute_phase function follows a 10-step execution sequence:
59//
60// 1. Remove stale .partial/ directories (FR-ORC-003, FR-ORC-007)
61// 2. Validate transition and acquire exclusive lock
62// 3. Build packet with phase context
63// 4. Scan for secrets (blocks on detection)
64// 5. Execute LLM invocation (or simulate in dry-run)
65// 6. Handle Claude CLI failure (save partial, create failure receipt)
66// 7. Postprocess Claude response into artifacts
67// 8. Write partial artifacts to .partial/ staging directory (FR-ORC-004)
68// 9. Promote staged artifacts to final location (atomic rename)
69// 10. Create and write receipt with cryptographic hashes (FR-ORC-005, FR-ORC-006)
70//
71// Future Refactoring: execute_phase_core
72// ---------------------------------------
73// To reduce duplication between phase_exec.rs and workflow.rs, a future
74// execute_phase_core function will extract steps 3-10 into a reusable helper.
75// This function will return PhaseCoreOutput containing all intermediate values
76// needed for receipt generation and artifact management.
77//
78// The separation will look like:
79//   execute_phase (current)      → High-level orchestration + timeout handling
80//   execute_phase_core (future)  → Core execution logic (packet → receipt)
81//
82// Benefits:
83// - Eliminates duplicate receipt/artifact handling logic
84// - Makes testing easier (can test core logic independently)
85// - Simplifies workflow.rs implementation
86// - Maintains backward compatibility with existing tests
87//
88// PhaseCoreOutput Structure
89// --------------------------
90// The PhaseCoreOutput struct captures intermediate values generated during
91// phase execution. These values are needed for:
92// - Receipt generation (packet_evidence, claude_metadata, llm_result)
93// - Error handling (claude_exit_code)
94// - Artifact content (phase_result)
95//
96// Fields:
97// - packet_evidence: PacketEvidence - Metadata about files included in packet
98// - claude_exit_code: i32 - Exit code from LLM execution (0 = success)
99// - claude_metadata: Option<ClaudeExecutionMetadata> - LLM execution details
100// - llm_result: Option<LlmResult> - Structured LLM result for receipt
101// - phase_result: PhaseResult - Postprocessed artifacts from response
102//
103// Removed fields (v1.0 cleanup - not consumed by callers):
104// - phase_id, prompt, claude_response, artifact_paths, output_hashes, atomic_write_warnings
105//
106// This struct is returned by execute_phase_core and consumed by workflow.rs
107// for receipt generation.
108
109/// Captures intermediate values generated during core phase execution.
110///
111/// This structure is used by `execute_phase_core` (ORC-002) to return values
112/// needed for receipt generation and artifact management in workflow execution.
113///
114/// # Usage
115///
116/// ```ignore
117/// let core_output = execute_phase_core(phase, config).await?;
118///
119/// // Create receipt from core output
120/// let receipt = receipt_manager.create_receipt(
121///     spec_id,
122///     phase_id,  // from phase.id()
123///     core_output.claude_exit_code,
124///     // ... other fields from core_output
125/// );
126/// ```
127///
128/// # Fields
129///
130/// - `packet_evidence`: Metadata about files included in the LLM packet
131/// - `claude_exit_code`: Exit code from LLM (0 = success, non-zero = failure)
132/// - `claude_metadata`: Optional execution metadata (model, version, runner info)
133/// - `llm_result`: Structured LLM result that converts to `LlmInfo` on receipts
134/// - `phase_result`: Postprocessed artifacts with parsed content
135///
136/// # Removed Fields (v1.0 cleanup)
137///
138/// The following fields were removed as they are not consumed by callers:
139/// - `phase_id`: Callers use `phase.id()` directly
140/// - `prompt`: Only used internally during execution
141/// - `claude_response`: Only used internally for postprocessing
142/// - `artifact_paths`: Callers re-store artifacts directly
143/// - `output_hashes`: Callers re-compute hashes
144/// - `atomic_write_warnings`: Not used by workflow execution
145pub(crate) struct PhaseCoreOutput {
146    /// Metadata about files included in the LLM packet
147    pub packet_evidence: PacketEvidence,
148    /// Exit code from LLM execution (0 = success, non-zero = failure)
149    pub claude_exit_code: i32,
150    /// Optional LLM execution metadata (model, version, runner info, stderr)
151    pub claude_metadata: Option<ClaudeExecutionMetadata>,
152    /// Structured LLM result that converts to LlmInfo on receipts
153    pub llm_result: Option<crate::llm::LlmResult>,
154    /// Warning message when a fallback provider was used
155    pub llm_fallback_warning: Option<String>,
156    /// Postprocessed artifacts with parsed content from LLM response
157    pub phase_result: xchecker_phase_api::PhaseResult,
158}
159
160/// Execute a phase with timeout enforcement
161pub(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            // Convert anyhow::Error to XCheckerError
172            // Try to downcast to XCheckerError first
173            match e.downcast::<XCheckerError>() {
174                Ok(xchecker_err) => xchecker_err,
175                Err(_original_err) => {
176                    // If it's not an XCheckerError, wrap it as a generic phase error
177                    XCheckerError::Phase(PhaseError::ExecutionFailed {
178                        phase: phase_id.as_str().to_string(),
179                        code: 1,
180                    })
181                }
182            }
183        }),
184        Err(_) => {
185            // Timeout occurred
186            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    /// Execute the Requirements phase end-to-end with timeout.
196    ///
197    /// This is the primary entry point for generating requirements from a spec.
198    /// Validates the phase transition, builds a packet, invokes the LLM,
199    /// and stores the resulting artifacts and receipt.
200    ///
201    /// Used by CLI and Kiro flows; respects `dry_run` and LLM configuration.
202    ///
203    /// **Note**: Public for tests and advanced integrations; prefer `OrchestratorHandle`
204    /// for general use. May be narrowed to `pub(crate)` in a future version.
205    ///
206    /// Reserved for future orchestration API; not currently used by CLI.
207    ///
208    /// # Errors
209    /// Returns error if transition is invalid or execution fails.
210    #[cfg_attr(not(test), allow(dead_code))]
211    pub async fn execute_requirements_phase(
212        &self,
213        config: &OrchestratorConfig,
214    ) -> Result<ExecutionResult> {
215        // Validate transition before execution (FR-ORC-001)
216        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    /// Execute the Design phase end-to-end with timeout.
224    ///
225    /// Generates architecture and design documents based on requirements.
226    /// Validates the phase transition and dependencies before execution.
227    ///
228    /// Used by CLI and Kiro flows; respects `dry_run` and LLM configuration.
229    ///
230    /// **Note**: Public for tests and advanced integrations; prefer `OrchestratorHandle`
231    /// for general use. May be narrowed to `pub(crate)` in a future version.
232    ///
233    /// # Errors
234    /// Returns error if transition is invalid or execution fails.
235    #[allow(dead_code)] // Phase execution method for CLI/library usage
236    pub async fn execute_design_phase(
237        &self,
238        config: &OrchestratorConfig,
239    ) -> Result<ExecutionResult> {
240        // Validate transition before execution (FR-ORC-001)
241        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    /// Execute the Tasks phase end-to-end with timeout.
249    ///
250    /// Generates implementation tasks and milestones based on design.
251    /// Validates the phase transition and dependencies before execution.
252    ///
253    /// Used by CLI and Kiro flows; respects `dry_run` and LLM configuration.
254    ///
255    /// **Note**: Public for tests and advanced integrations; prefer `OrchestratorHandle`
256    /// for general use. May be narrowed to `pub(crate)` in a future version.
257    ///
258    /// # Errors
259    /// Returns error if transition is invalid or execution fails.
260    #[allow(dead_code)] // Phase execution method for CLI/library usage
261    pub async fn execute_tasks_phase(
262        &self,
263        config: &OrchestratorConfig,
264    ) -> Result<ExecutionResult> {
265        // Validate transition before execution (FR-ORC-001)
266        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    /// Resume execution from a specific phase.
274    ///
275    /// Validates that all dependencies are satisfied before executing.
276    /// Use this to continue a workflow from any valid phase.
277    ///
278    /// # Arguments
279    /// * `phase_id` - The phase to resume from
280    /// * `config` - Execution configuration
281    ///
282    /// # Errors
283    /// Returns error if dependencies are not satisfied or execution fails.
284    pub async fn resume_from_phase(
285        &self,
286        phase_id: PhaseId,
287        config: &OrchestratorConfig,
288    ) -> Result<ExecutionResult> {
289        // Validate transition before execution (FR-ORC-001, FR-ORC-002)
290        self.validate_transition(phase_id)?;
291
292        // Use phase factory to get the appropriate phase implementation
293        let phase = self.get_phase_impl(phase_id, config)?;
294        self.execute_phase_with_resume(phase.as_ref(), config).await
295    }
296
297    /// Execute a phase with timeout handling
298    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        // Get timeout configuration from config
306        let timeout_config = PhaseTimeout::from_config(config);
307
308        // Execute phase with timeout
309        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                // Handle timeout: write partial artifact and receipt with warning
322                self.handle_phase_timeout(phase_id, timeout_seconds, config)
323                    .await
324            }
325            Err(e) => Err(e.into()),
326        }
327    }
328
329    /// Handle phase timeout by writing partial artifact and receipt
330    async fn handle_phase_timeout(
331        &self,
332        phase_id: PhaseId,
333        timeout_seconds: u64,
334        config: &OrchestratorConfig,
335    ) -> Result<ExecutionResult> {
336        // Create minimal partial artifact
337        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        // Store partial artifact
350        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        // Create receipt with timeout warning
363        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        // Use config values for truthful failure receipts (no hard-coded metadata)
378        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, // Exit code 10
389            vec![],                           // No successful outputs
390            env!("CARGO_PKG_VERSION"),
391            "unknown", // Claude may have been running but we don't have metadata
392            configured_model,
393            None, // No model alias
394            flags,
395            packet_evidence,
396            None, // No stderr_tail
397            None, // No stderr_redacted
398            warnings,
399            None, // No fallback
400            configured_runner,
401            None, // No runner distro
402            Some(ErrorKind::PhaseTimeout),
403            Some(format!("Phase timed out after {timeout_seconds} seconds")),
404            None, // No diff_context,
405            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    /// Execute a phase with resume support (handles partial artifacts)
421    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        // Check if there's a partial artifact from a previous failed run
429        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                // In a real implementation, we might want to ask the user if they want to:
439                // 1. Continue from partial (not implemented yet)
440                // 2. Start fresh (delete partial and re-run)
441                // For now, we'll delete the partial and start fresh
442                println!("Deleting partial artifact and starting fresh...");
443                self.artifact_manager().delete_partial_artifact(phase_id)?;
444            }
445        }
446
447        // Execute the phase normally
448        let result = self.execute_phase(phase, config).await?;
449
450        // If successful and we had a partial, clean up any remaining partials
451        if result.success {
452            // Delete any partial artifacts on success (R4.5)
453            if let Err(e) = self.artifact_manager().delete_partial_artifact(phase_id) {
454                // Log warning but don't fail the operation
455                eprintln!("Warning: Failed to clean up partial artifact: {e}");
456            }
457        }
458
459        Ok(result)
460    }
461
462    /// Execute core phase logic: packet → LLM → artifacts (steps 1-8 from execute_phase).
463    ///
464    /// This function extracts the common execution flow shared between execute_phase
465    /// and workflow execution. It performs:
466    ///
467    /// 1. Prompt generation
468    /// 2. Packet creation with context
469    /// 3. Secret scanning (returns early with error on detection)
470    /// 4. Debug packet writing (if enabled)
471    /// 5. LLM invocation (dry-run or real)
472    /// 6. Postprocessing response into artifacts
473    /// 7. Artifact staging to .partial/ + warnings collection
474    /// 8. File hashing + artifact promotion to final location
475    ///
476    /// # Returns
477    ///
478    /// `PhaseCoreOutput` containing all intermediate values needed for receipt generation.
479    ///
480    /// # Errors
481    ///
482    /// Returns error if any core execution step fails. Note that LLM failures
483    /// (non-zero exit codes) are NOT errors - they're captured in `claude_exit_code`.
484    /// Early returns occur for:
485    /// - Secret detection (XCheckerError::Phase with ErrorKind::SecretDetected)
486    /// - Packet creation failures
487    /// - Postprocessing failures (only if exit_code == 0)
488    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        // Create phase context
496        let phase_context = self.create_phase_context(phase_id, config)?;
497
498        // Check dependencies
499        self.check_phase_dependencies(phase)?;
500
501        // Step 1: Generate prompt
502        let prompt = phase.prompt(&phase_context);
503
504        // Step 2: Build packet (FR-ORC-003)
505        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        // Log packet hash and budget usage for visibility
513        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        // Step 3: Scan for secrets (FR-ORC-003, FR-SEC)
529        let redactor = config.redactor.as_ref();
530
531        // Check for secrets in the packet content - return error immediately if found
532        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        // Store packet for debugging/preview
541        let _packet_preview_path = self
542            .artifact_manager()
543            .store_context_file(&format!("{}-packet", phase_id.as_str()), &packet.content)?;
544
545        // Step 4: Write full debug packet if --debug-packet flag is set (FR-PKT-006, FR-PKT-007)
546        // Only write after secret scan passes; file is excluded from receipts
547        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            // Get context directory from artifact manager
554            let context_dir = self.artifact_manager().context_path();
555
556            // Create a temporary PacketBuilder just to call write_debug_packet
557            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                // Log warning but don't fail the operation (debug packet is optional)
568                eprintln!("Warning: Failed to write debug packet: {e}");
569            }
570        }
571
572        // Step 5: Execute LLM (or simulate in dry-run mode)
573        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                // Use new LLM backend abstraction (V11: Claude CLI only)
594                self.run_llm_invocation(&prompt, &packet.content, phase_id, config)
595                    .await?
596            };
597
598        // Step 6: Postprocess Claude response (only if LLM succeeded)
599        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            // For failed LLM runs, return empty result - caller will handle partial artifact
610            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        // Step 7: Write partial artifacts to .partial/ subdirectory (FR-ORC-004)
618        for artifact in &phase_result.artifacts {
619            // Store to .partial/ staging directory first
620            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        // Step 8: Promote to final (atomic rename) (FR-ORC-004)
627        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        // Return intermediate values for receipt generation
637        // Note: Callers (workflow.rs) re-store artifacts and re-compute hashes,
638        // so we don't return phase_id, artifact_paths, output_hashes, or atomic_write_warnings
639        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    /// Execute a single phase with full orchestration
650    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        // Step 0: Remove stale .partial/ directories (FR-ORC-003, FR-ORC-007)
661        self.artifact_manager()
662            .remove_stale_partial_dir()
663            .with_context(|| "Failed to remove stale .partial/ directory")?;
664
665        // Step 1: Validate transition (already done before calling this method)
666        // Step 2: Acquire exclusive lock (already done in constructor)
667
668        // Create phase context
669        let phase_context = self.create_phase_context(phase_id, config)?;
670
671        // Check dependencies (Requirements phase has no deps)
672        self.check_phase_dependencies(phase)?;
673
674        // Execute pre-phase hook if configured
675        // Hooks run from invocation CWD so relative paths like ./scripts/... work
676        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                        // Pre-hook failed with on_fail=fail - abort phase but still create receipt
700                        let error_reason = format!(
701                            "Pre-phase hook failed: {}",
702                            outcome.error().map(|e| e.to_string()).unwrap_or_default()
703                        );
704
705                        // Create failure receipt for hook failure (audit trail requirement)
706                        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                        // Use config values for truthful failure receipts (no hard-coded metadata)
716                        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![], // No outputs
729                            env!("CARGO_PKG_VERSION"),
730                            "unknown", // Claude hasn't run yet, so version is unknown
731                            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                    // Hook execution error - treat as failure but still create receipt
761                    let error_reason = format!("Pre-phase hook error: {}", e);
762
763                    // Create failure receipt for hook error (audit trail requirement)
764                    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                    // Use config values for truthful failure receipts (no hard-coded metadata)
774                    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![], // No outputs
787                        env!("CARGO_PKG_VERSION"),
788                        "unknown", // Claude hasn't run yet, so version is unknown
789                        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        // Generate prompt
820        let prompt = phase.prompt(&phase_context);
821
822        // Step 3: Build packet (FR-ORC-003)
823        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        // Log packet hash and budget usage for visibility
831        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        // Step 4: Scan for secrets (FR-ORC-003, FR-SEC)
845        let redactor = config.redactor.as_ref();
846
847        // Check for secrets in the packet content
848        if redactor.has_secrets(&packet.content, "packet")? {
849            let matches = redactor.scan_for_secrets(&packet.content, "packet")?;
850
851            // Create error receipt for secret detection (FR-SEC, FR-EXIT)
852            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, // Exit code 8
868                vec![],                             // No successful outputs
869                env!("CARGO_PKG_VERSION"),
870                "0.8.1", // Default Claude CLI version
871                "haiku", // Default model
872                None,    // No model alias
873                flags,
874                packet_evidence,
875                None, // No stderr_tail
876                None, // No stderr_redacted
877                vec![format!("Secret detection prevented Claude invocation")],
878                None,     // No fallback
879                "native", // Default runner
880                None,     // No runner distro
881                Some(ErrorKind::SecretDetected),
882                Some(error_reason.clone()),
883                None, // No diff_context,
884                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        // Store packet for debugging/preview
900        let _packet_preview_path = self
901            .artifact_manager()
902            .store_context_file(&format!("{}-packet", phase_id.as_str()), &packet.content)?;
903
904        // Write full debug packet if --debug-packet flag is set (FR-PKT-006, FR-PKT-007)
905        // Only write after secret scan passes; file is excluded from receipts
906        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            // Get context directory from artifact manager
913            let context_dir = self.artifact_manager().context_path();
914
915            // Create a temporary PacketBuilder just to call write_debug_packet
916            // (This is a bit awkward but maintains the existing API)
917            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                // Log warning but don't fail the operation (debug packet is optional)
928                eprintln!("Warning: Failed to write debug packet: {e}");
929            }
930        }
931
932        // Execute LLM (or simulate in dry-run mode)
933        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            // Use new LLM backend abstraction (V11: Claude CLI only)
953            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                    // Check if this is a budget exhaustion error by downcasting
977                    if let XCheckerError::Llm(llm_err) = xchecker_err {
978                        if matches!(llm_err, crate::llm::LlmError::BudgetExceeded { .. }) {
979                            // Handle budget exhaustion specially - create receipt with budget_exhausted flag
980                            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                            // Use config values for truthful failure receipts (no hard-coded metadata)
985                            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, // Exit code 70
1002                                vec![],                            // No successful outputs
1003                                env!("CARGO_PKG_VERSION"),
1004                                "unknown", // Claude hasn't completed, so version is unknown
1005                                configured_model,
1006                                None, // No model alias
1007                                flags,
1008                                packet_evidence,
1009                                None, // No stderr_tail
1010                                None, // No stderr_redacted
1011                                warnings,
1012                                None, // No fallback
1013                                configured_runner,
1014                                None, // No runner distro
1015                                Some(ErrorKind::ClaudeFailure),
1016                                Some(llm_err.to_string()),
1017                                None, // No diff_context
1018                                pipeline_info.clone(),
1019                            );
1020
1021                            // Attach LlmInfo with budget_exhausted flag
1022                            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                        // Use config values for truthful failure receipts (no hard-coded metadata)
1041                        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![], // No successful outputs
1095                            env!("CARGO_PKG_VERSION"),
1096                            "unknown", // Claude hasn't completed, so version is unknown
1097                            configured_model,
1098                            None, // No model alias
1099                            flags,
1100                            packet_evidence,
1101                            None, // No stderr_tail
1102                            None, // No stderr_redacted
1103                            warnings,
1104                            None, // No fallback
1105                            configured_runner,
1106                            None, // No runner distro
1107                            Some(error_kind),
1108                            Some(llm_err.to_string()),
1109                            None, // No diff_context
1110                            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                    // For other errors, propagate normally
1127                    return Err(e);
1128                }
1129            }
1130        };
1131
1132        // Handle Claude CLI failure (R4.3)
1133        if claude_exit_code != 0 {
1134            // Save partial output as required by R4.3
1135            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            // Create failure receipt with stderr_tail and warnings (R4.3)
1152            // Use the actual packet evidence from the packet that was created
1153            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![], // No successful outputs
1178                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()), // stderr_tail
1187                None,                                            // stderr_redacted
1188                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, // No diff_context
1199                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            // Create enhanced error with stderr information (R4.3)
1207            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()], // Include partial artifact
1230                receipt_path: Some(receipt_path.into_std_path_buf()),
1231                error: Some(enhanced_error.to_string()),
1232            });
1233        }
1234
1235        // Process Claude response
1236        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        // Step 7: Write partial artifacts to .partial/ subdirectory (FR-ORC-004)
1246        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            // Store to .partial/ staging directory first
1252            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            // Collect atomic write warnings
1258            for warning in &partial_result.atomic_write_result.warnings {
1259                atomic_write_warnings.push(format!("{}: {}", artifact.name, warning));
1260            }
1261
1262            // Create file hash for receipt (using final content)
1263            // Determine file type from extension for proper canonicalization
1264            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                // Fallback to artifact type if no extension
1268                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        // Step 8: Promote to final (atomic rename) (FR-ORC-004)
1294        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        // Step 9: Create and write receipt (FR-ORC-005, FR-ORC-006)
1306        // Use the actual packet evidence from the packet that was created
1307        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, // Success exit code
1334            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,     // No stderr_tail for successful execution
1344            None,     // No stderr_redacted for successful execution
1345            warnings, // Include atomic write warnings, hook warnings, and LLM fallback warning
1346            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, // No error_kind for successful execution
1354            None, // No error_reason for successful execution
1355            None, // No diff_context
1356            pipeline_info.clone(),
1357        );
1358        // Set LLM info from the invocation result (V11+ multi-provider support)
1359        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        // Execute post-phase hook if configured (runs on success)
1367        // Hooks run from invocation CWD so relative paths like ./scripts/... work
1368        // Note: Post-hook failures are treated as warnings, not phase failures
1369        // (artifacts have already been created and receipt written)
1370        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                    // Log any warnings from successful or failed hooks
1389                    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                    // Check if hook wanted to fail but we treat it as warning
1397                    // (post-hooks run after artifacts are created, so we don't fail the phase)
1398                    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                    // Log hook execution errors but don't fail the phase
1407                    // (artifacts are already created at this point)
1408                    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    /// Create phase context for execution
1428    pub(crate) fn create_phase_context(
1429        &self,
1430        phase_id: PhaseId,
1431        config: &OrchestratorConfig,
1432    ) -> Result<PhaseContext> {
1433        // List available artifacts from previous phases
1434        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    /// Check that phase dependencies are satisfied
1457    pub(crate) fn check_phase_dependencies(&self, phase: &dyn Phase) -> Result<()> {
1458        let deps = phase.deps();
1459
1460        for dep_phase in deps {
1461            // Check if we have a successful receipt for the dependency
1462            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    /// Create a simulated LlmResult for dry-run mode
1483    /// This ensures receipts have complete LLM metadata even during testing
1484    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) // Default timeout for simulated runs
1493        .with_extension("dry_run", serde_json::json!(true))
1494    }
1495
1496    /// Simulate Claude CLI response for testing/dry-run
1497    pub(crate) fn simulate_claude_response(&self, _phase_id: PhaseId, _prompt: &str) -> String {
1498        match _phase_id {
1499            PhaseId::Requirements => {
1500                // Generate a realistic requirements document
1501                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    /// Get the phase number for artifact naming
1697    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    /// Get a phase implementation by ID (phase factory)
1709    /// This method creates the appropriate Phase trait object for the given phase ID
1710    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                // Determine fixup mode from configuration (FR-FIX-004, FR-FIX-005)
1722                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}