Skip to main content

perspt_agent/orchestrator/
mod.rs

1//! SRBN Orchestrator
2//!
3//! Manages the Task DAG and orchestrates agent execution following the 7-step control loop.
4
5mod bundle;
6mod commit;
7mod convergence;
8mod init;
9mod planning;
10mod repair;
11mod solo;
12mod verification;
13
14use crate::agent::{ActuatorAgent, Agent, ArchitectAgent, SpeculatorAgent, VerifierAgent};
15use crate::context_retriever::ContextRetriever;
16use crate::lsp::LspClient;
17use crate::test_runner::{self, PythonTestRunner, TestResults};
18use crate::tools::{AgentTools, ToolCall};
19use crate::types::{AgentContext, EnergyComponents, ModelTier, NodeState, SRBNNode, TaskPlan};
20use anyhow::{Context, Result};
21use perspt_core::types::{
22    EscalationCategory, EscalationReport, NodeClass, ProvisionalBranch, ProvisionalBranchState,
23    RewriteAction, RewriteRecord, SheafValidationResult, SheafValidatorClass, WorkspaceState,
24};
25use petgraph::graph::{DiGraph, NodeIndex};
26use petgraph::visit::{EdgeRef, Topo, Walker};
27use std::collections::HashMap;
28use std::path::PathBuf;
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::sync::Arc;
31use std::time::Instant;
32
33/// Dependency edge type
34#[derive(Debug, Clone)]
35pub struct Dependency {
36    /// Dependency type description
37    pub kind: String,
38}
39
40/// Result of an approval request
41#[derive(Debug, Clone)]
42pub enum ApprovalResult {
43    /// User approved the action
44    Approved,
45    /// User approved with an edited value (e.g., project name)
46    ApprovedWithEdit(String),
47    /// User rejected the action
48    Rejected,
49}
50
51/// Outcome of executing a single graph node.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum NodeOutcome {
54    /// Node converged and committed successfully.
55    Completed,
56    /// Node failed to converge and was escalated.
57    Escalated,
58}
59
60/// The SRBN Orchestrator - manages the agent workflow
61pub struct SRBNOrchestrator {
62    /// Task DAG managed by petgraph
63    pub graph: DiGraph<SRBNNode, Dependency>,
64    /// Node ID to graph index mapping
65    node_indices: HashMap<String, NodeIndex>,
66    /// Agent context
67    pub context: AgentContext,
68    /// Auto-approve mode
69    pub auto_approve: bool,
70    /// LSP clients per language
71    lsp_clients: HashMap<String, LspClient>,
72    /// Agents for different roles
73    agents: Vec<Box<dyn Agent>>,
74    /// Agent tools for file/command operations
75    tools: AgentTools,
76    /// Last written file path (for LSP tracking)
77    last_written_file: Option<PathBuf>,
78    /// File version counter for LSP
79    file_version: i32,
80    /// LLM provider for correction calls
81    provider: std::sync::Arc<perspt_core::llm_provider::GenAIProvider>,
82    /// Architect model name for planning
83    architect_model: String,
84    /// Actuator model name for corrections
85    actuator_model: String,
86    /// Verifier model name for correction guidance
87    verifier_model: String,
88    /// Speculator model name for lookahead hints
89    speculator_model: String,
90    /// PSP-5: Fallback model for Architect tier (used when primary fails structured-output contract)
91    architect_fallback_model: Option<String>,
92    /// PSP-5: Fallback model for Actuator tier
93    actuator_fallback_model: Option<String>,
94    /// PSP-5: Fallback model for Verifier tier
95    verifier_fallback_model: Option<String>,
96    /// PSP-5: Fallback model for Speculator tier
97    speculator_fallback_model: Option<String>,
98    /// Event sender for TUI updates (optional)
99    event_sender: Option<perspt_core::events::channel::EventSender>,
100    /// Action receiver for TUI commands (optional)
101    action_receiver: Option<perspt_core::events::channel::ActionReceiver>,
102    /// Persistence ledger
103    pub ledger: crate::ledger::MerkleLedger,
104    /// Last tool failure message (for energy calculation)
105    pub last_tool_failure: Option<String>,
106    /// PSP-5 Phase 3: Last assembled context provenance (for commit recording)
107    last_context_provenance: Option<perspt_core::types::ContextProvenance>,
108    /// PSP-5 Phase 3: Last formatted context from restriction map (for correction prompts)
109    last_formatted_context: String,
110    /// PSP-5 Phase 4: Last plugin-driven verification result (for convergence checks)
111    last_verification_result: Option<perspt_core::types::VerificationResult>,
112    /// PSP-5 Phase 9: Last applied artifact bundle (for persistence in step_commit)
113    last_applied_bundle: Option<perspt_core::types::ArtifactBundle>,
114    /// Last recorded RepairFootprint (for multi-file correction context)
115    last_repair_footprint: Option<perspt_core::RepairFootprint>,
116    /// PSP-5 Phase 6: Blocked dependencies awaiting parent interface seals
117    blocked_dependencies: Vec<perspt_core::types::BlockedDependency>,
118    /// Session-level budget envelope for step/cost/revision caps.
119    budget: perspt_core::types::BudgetEnvelope,
120    /// Adaptive planning policy for agent phase selection.
121    pub planning_policy: perspt_core::PlanningPolicy,
122    /// Session-level stability threshold (ε for V(x) < ε convergence)
123    pub stability_epsilon: f32,
124    /// Energy weight α (syntax/build errors)
125    pub energy_alpha: f32,
126    /// Energy weight β (structural concerns)
127    pub energy_beta: f32,
128    /// Energy weight γ (test/lint failures)
129    pub energy_gamma: f32,
130    /// Session abort flag — set by external signal handlers or TUI
131    abort_requested: Arc<AtomicBool>,
132}
133
134/// Get current timestamp as epoch seconds.
135fn epoch_seconds() -> i64 {
136    use std::time::{SystemTime, UNIX_EPOCH};
137    SystemTime::now()
138        .duration_since(UNIX_EPOCH)
139        .unwrap()
140        .as_secs() as i64
141}
142
143/// Detect stub/placeholder content in a generated source file.
144///
145/// Returns `Some(reason)` if the file is predominantly stub content (i.e. it
146/// contains a known stub pattern AND has fewer than 5 lines of real code).
147/// Returns `None` for files that contain a real implementation.
148///
149/// Language detection uses `plugin_hint` ("rust", "python", "javascript") with
150/// a fallback to file extension so this works for any project type.
151fn detect_stub_content(path: &std::path::Path, plugin_hint: &str) -> Option<String> {
152    let content = std::fs::read_to_string(path).ok()?;
153
154    // Determine language from plugin hint or file extension.
155    let lang = if !plugin_hint.is_empty() && plugin_hint != "unknown" {
156        plugin_hint.to_ascii_lowercase()
157    } else {
158        path.extension()
159            .and_then(|e| e.to_str())
160            .map(|e| match e {
161                "rs" => "rust",
162                "py" => "python",
163                "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" => "javascript",
164                _ => "",
165            })
166            .unwrap_or("")
167            .to_string()
168    };
169
170    // Universal stub markers (case-insensitive substring match).
171    let universal_patterns = [
172        "// stub",
173        "# stub",
174        "// placeholder",
175        "# placeholder",
176        "// will be replaced",
177        "# will be replaced",
178        "/* todo */",
179    ];
180
181    // Language-specific stub patterns.
182    let lang_patterns: &[&str] = match lang.as_str() {
183        "rust" => &["todo!()", "unimplemented!()"],
184        "python" => &["raise NotImplementedError", "raise NotImplementedError()"],
185        "javascript" | "typescript" => &[
186            "throw new Error(\"not implemented\")",
187            "throw new Error('not implemented')",
188            "throw new Error(\"TODO\")",
189            "throw new Error('TODO')",
190        ],
191        _ => &[],
192    };
193
194    let content_lower = content.to_ascii_lowercase();
195
196    // Check for any matching stub pattern.
197    let mut matched_pattern = None;
198    for pat in &universal_patterns {
199        if content_lower.contains(pat) {
200            matched_pattern = Some(*pat);
201            break;
202        }
203    }
204    if matched_pattern.is_none() {
205        for pat in lang_patterns {
206            if content.contains(pat) {
207                matched_pattern = Some(*pat);
208                break;
209            }
210        }
211    }
212
213    // Python-specific: detect `pass` or `...` as sole function/class body.
214    if matched_pattern.is_none() && lang == "python" {
215        let trimmed_lines: Vec<&str> = content
216            .lines()
217            .map(|l| l.trim())
218            .filter(|l| !l.is_empty() && !l.starts_with('#'))
219            .collect();
220        let body_only: Vec<&&str> = trimmed_lines
221            .iter()
222            .filter(|l| {
223                !l.starts_with("def ")
224                    && !l.starts_with("class ")
225                    && !l.starts_with("import ")
226                    && !l.starts_with("from ")
227            })
228            .collect();
229        if body_only.len() <= 2 && body_only.iter().all(|l| **l == "pass" || **l == "...") {
230            matched_pattern = Some("only pass/... body");
231        }
232    }
233
234    let pattern = matched_pattern?;
235
236    // Count real code lines: non-blank, non-comment, non-import.
237    let real_lines = count_real_code_lines(&content, &lang);
238    if real_lines >= 5 {
239        // File has enough real code — a single stub marker inside a large
240        // implementation is acceptable (e.g. a todo!() in one branch).
241        return None;
242    }
243
244    Some(format!(
245        "found '{}' with only {} line(s) of real code",
246        pattern, real_lines
247    ))
248}
249
250/// Count non-blank, non-comment, non-import lines of code.
251fn count_real_code_lines(content: &str, lang: &str) -> usize {
252    content
253        .lines()
254        .filter(|line| {
255            let trimmed = line.trim();
256            if trimmed.is_empty() {
257                return false;
258            }
259            // Skip comments.
260            match lang {
261                "rust" => {
262                    if trimmed.starts_with("//")
263                        || trimmed.starts_with("/*")
264                        || trimmed.starts_with('*')
265                    {
266                        return false;
267                    }
268                    // Skip use/extern/mod declarations (imports).
269                    if trimmed.starts_with("use ")
270                        || trimmed.starts_with("extern ")
271                        || trimmed.starts_with("mod ")
272                    {
273                        return false;
274                    }
275                }
276                "python" => {
277                    if trimmed.starts_with('#')
278                        || trimmed.starts_with("\"\"\"")
279                        || trimmed.starts_with("'''")
280                    {
281                        return false;
282                    }
283                    if trimmed.starts_with("import ") || trimmed.starts_with("from ") {
284                        return false;
285                    }
286                }
287                "javascript" | "typescript" => {
288                    if trimmed.starts_with("//")
289                        || trimmed.starts_with("/*")
290                        || trimmed.starts_with('*')
291                    {
292                        return false;
293                    }
294                    if trimmed.starts_with("import ")
295                        || trimmed.starts_with("require(")
296                        || trimmed.starts_with("const ") && trimmed.contains("require(")
297                    {
298                        return false;
299                    }
300                }
301                _ => {
302                    if trimmed.starts_with("//")
303                        || trimmed.starts_with('#')
304                        || trimmed.starts_with("/*")
305                    {
306                        return false;
307                    }
308                }
309            }
310            true
311        })
312        .count()
313}
314
315impl SRBNOrchestrator {
316    /// Create a new orchestrator with default models
317    pub fn new(working_dir: PathBuf, auto_approve: bool) -> Self {
318        Self::new_with_models(
319            working_dir,
320            auto_approve,
321            None,
322            None,
323            None,
324            None,
325            None,
326            None,
327            None,
328            None,
329        )
330    }
331
332    /// Create a new orchestrator with custom model configuration
333    #[allow(clippy::too_many_arguments)]
334    pub fn new_with_models(
335        working_dir: PathBuf,
336        auto_approve: bool,
337        architect_model: Option<String>,
338        actuator_model: Option<String>,
339        verifier_model: Option<String>,
340        speculator_model: Option<String>,
341        architect_fallback_model: Option<String>,
342        actuator_fallback_model: Option<String>,
343        verifier_fallback_model: Option<String>,
344        speculator_fallback_model: Option<String>,
345    ) -> Self {
346        // Create a shared LLM provider - agents will use this for LLM calls.
347        let provider = std::sync::Arc::new(
348            perspt_core::llm_provider::GenAIProvider::new().unwrap_or_else(|e| {
349                log::warn!("Failed to create GenAIProvider: {}, using default", e);
350                perspt_core::llm_provider::GenAIProvider::new().expect("GenAI must initialize")
351            }),
352        );
353
354        Self::new_with_models_and_provider(
355            working_dir,
356            auto_approve,
357            provider,
358            architect_model,
359            actuator_model,
360            verifier_model,
361            speculator_model,
362            architect_fallback_model,
363            actuator_fallback_model,
364            verifier_fallback_model,
365            speculator_fallback_model,
366        )
367    }
368
369    /// Create a new orchestrator with custom models and an injected provider.
370    ///
371    /// The injected provider should already be bound to the resolved adapter
372    /// (e.g. via `GenAIProvider::from_config`) so custom/local model names route
373    /// correctly. The bound adapter is only the fallback, so the four tiers may
374    /// still use recognized provider model names that route by prefix.
375    #[allow(clippy::too_many_arguments)]
376    pub fn new_with_models_and_provider(
377        working_dir: PathBuf,
378        auto_approve: bool,
379        provider: std::sync::Arc<perspt_core::llm_provider::GenAIProvider>,
380        architect_model: Option<String>,
381        actuator_model: Option<String>,
382        verifier_model: Option<String>,
383        speculator_model: Option<String>,
384        architect_fallback_model: Option<String>,
385        actuator_fallback_model: Option<String>,
386        verifier_fallback_model: Option<String>,
387        speculator_fallback_model: Option<String>,
388    ) -> Self {
389        let context = AgentContext {
390            working_dir: working_dir.clone(),
391            auto_approve,
392            ..Default::default()
393        };
394
395        // Create agent tools for file/command operations
396        let tools = AgentTools::new(working_dir.clone(), !auto_approve);
397
398        // Store model names for direct LLM calls
399        let stored_architect_model = architect_model
400            .clone()
401            .unwrap_or_else(|| ModelTier::Architect.default_model().to_string());
402        let stored_actuator_model = actuator_model
403            .clone()
404            .unwrap_or_else(|| ModelTier::Actuator.default_model().to_string());
405        let stored_verifier_model = verifier_model
406            .clone()
407            .unwrap_or_else(|| ModelTier::Verifier.default_model().to_string());
408        let stored_speculator_model = speculator_model
409            .clone()
410            .unwrap_or_else(|| ModelTier::Speculator.default_model().to_string());
411
412        Self {
413            graph: DiGraph::new(),
414            node_indices: HashMap::new(),
415            context,
416            auto_approve,
417            lsp_clients: HashMap::new(),
418            agents: vec![
419                Box::new(ArchitectAgent::new(provider.clone(), architect_model)),
420                Box::new(ActuatorAgent::new(provider.clone(), actuator_model)),
421                Box::new(VerifierAgent::new(provider.clone(), verifier_model)),
422                Box::new(SpeculatorAgent::new(provider.clone(), speculator_model)),
423            ],
424            tools,
425            last_written_file: None,
426            file_version: 0,
427            provider,
428            architect_model: stored_architect_model,
429            actuator_model: stored_actuator_model,
430            verifier_model: stored_verifier_model,
431            speculator_model: stored_speculator_model,
432            architect_fallback_model,
433            actuator_fallback_model,
434            verifier_fallback_model,
435            speculator_fallback_model,
436            event_sender: None,
437            action_receiver: None,
438            #[cfg(test)]
439            ledger: crate::ledger::MerkleLedger::in_memory().expect("Failed to create test ledger"),
440            #[cfg(not(test))]
441            ledger: crate::ledger::MerkleLedger::new().expect("Failed to create ledger"),
442            last_tool_failure: None,
443            last_context_provenance: None,
444            last_formatted_context: String::new(),
445            last_verification_result: None,
446            last_applied_bundle: None,
447            last_repair_footprint: None,
448            blocked_dependencies: Vec::new(),
449            budget: perspt_core::types::BudgetEnvelope::new("pending"),
450            planning_policy: perspt_core::PlanningPolicy::default(),
451            stability_epsilon: 0.1,
452            energy_alpha: 1.0,
453            energy_beta: 0.5,
454            energy_gamma: 2.0,
455            abort_requested: Arc::new(AtomicBool::new(false)),
456        }
457    }
458
459    /// Create a new orchestrator for testing with an in-memory ledger
460    #[cfg(test)]
461    pub fn new_for_testing(working_dir: PathBuf) -> Self {
462        let context = AgentContext {
463            working_dir: working_dir.clone(),
464            auto_approve: true,
465            ..Default::default()
466        };
467
468        let provider = std::sync::Arc::new(
469            perspt_core::llm_provider::GenAIProvider::new().unwrap_or_else(|e| {
470                log::warn!("Failed to create GenAIProvider: {}, using default", e);
471                perspt_core::llm_provider::GenAIProvider::new().expect("GenAI must initialize")
472            }),
473        );
474
475        let tools = AgentTools::new(working_dir.clone(), false);
476
477        Self {
478            graph: DiGraph::new(),
479            node_indices: HashMap::new(),
480            context,
481            auto_approve: true,
482            lsp_clients: HashMap::new(),
483            agents: vec![
484                Box::new(ArchitectAgent::new(provider.clone(), None)),
485                Box::new(ActuatorAgent::new(provider.clone(), None)),
486                Box::new(VerifierAgent::new(provider.clone(), None)),
487                Box::new(SpeculatorAgent::new(provider.clone(), None)),
488            ],
489            tools,
490            last_written_file: None,
491            file_version: 0,
492            provider,
493            architect_model: ModelTier::Architect.default_model().to_string(),
494            actuator_model: ModelTier::Actuator.default_model().to_string(),
495            verifier_model: ModelTier::Verifier.default_model().to_string(),
496            speculator_model: ModelTier::Speculator.default_model().to_string(),
497            architect_fallback_model: None,
498            actuator_fallback_model: None,
499            verifier_fallback_model: None,
500            speculator_fallback_model: None,
501            event_sender: None,
502            action_receiver: None,
503            ledger: crate::ledger::MerkleLedger::in_memory().expect("Failed to create test ledger"),
504            last_tool_failure: None,
505            last_context_provenance: None,
506            last_formatted_context: String::new(),
507            last_verification_result: None,
508            last_applied_bundle: None,
509            last_repair_footprint: None,
510            blocked_dependencies: Vec::new(),
511            budget: perspt_core::types::BudgetEnvelope::new("test"),
512            planning_policy: perspt_core::PlanningPolicy::default(),
513            stability_epsilon: 0.1,
514            energy_alpha: 1.0,
515            energy_beta: 0.5,
516            energy_gamma: 2.0,
517            abort_requested: Arc::new(AtomicBool::new(false)),
518        }
519    }
520
521    /// Add a node to the task DAG
522    pub fn add_node(&mut self, node: SRBNNode) -> NodeIndex {
523        let node_id = node.node_id.clone();
524        let idx = self.graph.add_node(node);
525        self.node_indices.insert(node_id, idx);
526        idx
527    }
528
529    /// Connect TUI channels for interactive control
530    pub fn connect_tui(
531        &mut self,
532        event_sender: perspt_core::events::channel::EventSender,
533        action_receiver: perspt_core::events::channel::ActionReceiver,
534    ) {
535        self.tools.set_event_sender(event_sender.clone());
536        self.event_sender = Some(event_sender);
537        self.action_receiver = Some(action_receiver);
538    }
539
540    /// Get a handle to the abort flag for external signal handlers.
541    pub fn abort_flag(&self) -> Arc<AtomicBool> {
542        self.abort_requested.clone()
543    }
544
545    /// Check whether an abort has been requested.
546    fn is_abort_requested(&self) -> bool {
547        self.abort_requested.load(Ordering::Relaxed)
548    }
549
550    /// Finalize the session in the ledger based on the execution result.
551    fn finalize_session(&mut self, result: &Result<perspt_core::SessionOutcome>) {
552        let status = if self.is_abort_requested() {
553            "ABORTED"
554        } else {
555            match result {
556                Ok(perspt_core::SessionOutcome::Success) => "COMPLETED",
557                Ok(perspt_core::SessionOutcome::PartialSuccess) => "PARTIAL",
558                Ok(perspt_core::SessionOutcome::Failed) | Err(_) => "FAILED",
559            }
560        };
561        if let Err(e) = self.ledger.end_session(status) {
562            log::error!("Failed to finalize session as {}: {}", status, e);
563        }
564    }
565
566    /// Configure the session-level budget envelope.
567    ///
568    /// Call this before `run()` to set step, cost, or revision caps from CLI
569    /// flags.  Uncapped limits remain `None`.
570    pub fn set_budget(
571        &mut self,
572        max_steps: Option<u32>,
573        max_revisions: Option<u32>,
574        max_cost_usd: Option<f64>,
575    ) {
576        self.budget.max_steps = max_steps;
577        self.budget.max_revisions = max_revisions;
578        self.budget.max_cost_usd = max_cost_usd;
579    }
580
581    // =========================================================================
582    // PSP-5 Phase 8: Session Rehydration for Resume
583    // =========================================================================
584
585    /// Rehydrate the orchestrator from a persisted session, rebuilding the
586    /// DAG from stored node snapshots and graph edges.
587    ///
588    /// Terminal nodes (Completed, Failed, Aborted) will be skipped during
589    /// the subsequent `run_resumed()` execution. Non-terminal nodes are
590    /// placed back in their persisted state so the executor can continue
591    /// from the last durable boundary.
592    ///
593    /// Returns `Ok(snapshot)` with the loaded session snapshot on success,
594    /// or an error when the session cannot be reconstructed.
595    pub fn rehydrate_session(
596        &mut self,
597        session_id: &str,
598    ) -> Result<crate::ledger::SessionSnapshot> {
599        // Attach the ledger to this session so facades read the right data
600        self.context.session_id = session_id.to_string();
601        self.ledger.current_session = Some(crate::ledger::SessionRecordLegacy {
602            session_id: session_id.to_string(),
603            task: String::new(),
604            started_at: epoch_seconds(),
605            ended_at: None,
606            status: "RESUMING".to_string(),
607            total_nodes: 0,
608            completed_nodes: 0,
609        });
610
611        let snapshot = self.ledger.load_session_snapshot()?;
612
613        // PSP-5 Phase 12: Restore budget envelope from persisted state so
614        // resume honours the same step/cost/revision caps.
615        if let Ok(Some(row)) = self.ledger.get_budget_envelope() {
616            self.budget = perspt_core::types::BudgetEnvelope {
617                session_id: row.session_id,
618                max_steps: row.max_steps.map(|v| v as u32),
619                steps_used: row.steps_used as u32,
620                max_revisions: row.max_revisions.map(|v| v as u32),
621                revisions_used: row.revisions_used as u32,
622                max_cost_usd: row.max_cost_usd,
623                cost_used_usd: row.cost_used_usd,
624            };
625            log::info!(
626                "Restored budget envelope: steps {}/{:?}, revisions {}/{:?}, cost ${:.2}/{:?}",
627                self.budget.steps_used,
628                self.budget.max_steps,
629                self.budget.revisions_used,
630                self.budget.max_revisions,
631                self.budget.cost_used_usd,
632                self.budget.max_cost_usd,
633            );
634        }
635
636        // PSP-5 Phase 8: Corruption / backward-compatibility checks
637        if snapshot.node_details.is_empty() {
638            anyhow::bail!(
639                "Session {} has no persisted nodes — cannot resume",
640                session_id
641            );
642        }
643
644        // Detect orphaned edges (references to nodes not in snapshot)
645        let node_ids: std::collections::HashSet<&str> = snapshot
646            .node_details
647            .iter()
648            .map(|d| d.record.node_id.as_str())
649            .collect();
650        let orphaned_edges = snapshot
651            .graph_edges
652            .iter()
653            .filter(|e| {
654                !node_ids.contains(e.parent_node_id.as_str())
655                    || !node_ids.contains(e.child_node_id.as_str())
656            })
657            .count();
658        if orphaned_edges > 0 {
659            log::warn!(
660                "Session {} has {} orphaned edge(s) referencing unknown nodes — \
661                 edges will be dropped during resume",
662                session_id,
663                orphaned_edges
664            );
665            self.emit_log(format!(
666                "⚠️ Resume: dropping {} orphaned graph edge(s)",
667                orphaned_edges
668            ));
669        }
670
671        // Rebuild graph: first add all nodes
672        let mut node_map: HashMap<String, NodeIndex> = HashMap::new();
673
674        for detail in &snapshot.node_details {
675            let rec = &detail.record;
676
677            let state = parse_node_state(&rec.state);
678            let node_class = rec
679                .node_class
680                .as_deref()
681                .map(parse_node_class)
682                .unwrap_or_default();
683
684            let mut node = SRBNNode::new(
685                rec.node_id.clone(),
686                rec.goal.clone().unwrap_or_default(),
687                ModelTier::Actuator,
688            );
689            node.state = state;
690            node.node_class = node_class;
691            node.owner_plugin = rec.owner_plugin.clone().unwrap_or_default();
692            node.parent_id = rec.parent_id.clone();
693            node.children = rec
694                .children
695                .as_deref()
696                .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
697                .unwrap_or_default();
698            node.monitor.attempt_count = rec.attempt_count as usize;
699
700            // Restore latest energy if available
701            if let Some(last_energy) = detail.energy_history.last() {
702                node.monitor.energy_history.push(last_energy.v_total);
703            }
704
705            // Restore interface seal hash from persisted seals
706            if let Some(seal) = detail.interface_seals.last() {
707                if seal.seal_hash.len() == 32 {
708                    let mut hash = [0u8; 32];
709                    hash.copy_from_slice(&seal.seal_hash);
710                    node.interface_seal_hash = Some(hash);
711                }
712            }
713
714            let idx = self.add_node(node);
715            node_map.insert(rec.node_id.clone(), idx);
716        }
717
718        // Rebuild edges from persisted graph topology
719        for edge in &snapshot.graph_edges {
720            if let (Some(&from_idx), Some(&to_idx)) = (
721                node_map.get(&edge.parent_node_id),
722                node_map.get(&edge.child_node_id),
723            ) {
724                self.graph.add_edge(
725                    from_idx,
726                    to_idx,
727                    Dependency {
728                        kind: edge.edge_type.clone(),
729                    },
730                );
731            }
732        }
733
734        // Restore blocked dependencies from non-completed parents of Interface class
735        for (child_id, &child_idx) in &node_map {
736            let parents: Vec<NodeIndex> = self
737                .graph
738                .neighbors_directed(child_idx, petgraph::Direction::Incoming)
739                .collect();
740
741            for parent_idx in parents {
742                let parent = &self.graph[parent_idx];
743                if parent.node_class == NodeClass::Interface
744                    && parent.interface_seal_hash.is_none()
745                    && !parent.state.is_terminal()
746                {
747                    self.blocked_dependencies
748                        .push(perspt_core::types::BlockedDependency {
749                            child_node_id: child_id.clone(),
750                            parent_node_id: parent.node_id.clone(),
751                            required_seal_paths: Vec::new(),
752                            blocked_at: epoch_seconds(),
753                        });
754                }
755            }
756        }
757
758        let terminal = snapshot
759            .node_details
760            .iter()
761            .filter(|d| {
762                let s = parse_node_state(&d.record.state);
763                s.is_terminal()
764            })
765            .count();
766        let resumable = snapshot.node_details.len() - terminal;
767
768        log::info!(
769            "Rehydrated session {}: {} nodes ({} terminal, {} resumable), {} edges",
770            session_id,
771            snapshot.node_details.len(),
772            terminal,
773            resumable,
774            snapshot.graph_edges.len()
775        );
776
777        // Update legacy session tracker
778        if let Some(ref mut sess) = self.ledger.current_session {
779            sess.total_nodes = snapshot.node_details.len();
780            sess.completed_nodes = terminal;
781            sess.status = "RUNNING".to_string();
782        }
783
784        // PSP-5 Phase 3: Validate context provenance for non-terminal nodes.
785        // Check that files referenced in persisted provenance still exist on
786        // disk so the resumed run has a chance to rebuild equivalent context.
787        for detail in &snapshot.node_details {
788            let state = parse_node_state(&detail.record.state);
789            if state.is_terminal() {
790                continue;
791            }
792
793            if let Some(ref prov) = detail.context_provenance {
794                let retriever = ContextRetriever::new(self.context.working_dir.clone());
795                let drift = retriever.validate_provenance_record(prov);
796                if !drift.is_empty() {
797                    log::warn!(
798                        "Provenance drift for node '{}': {} file(s) missing: {}",
799                        detail.record.node_id,
800                        drift.len(),
801                        drift.join(", ")
802                    );
803                    self.emit_log(format!(
804                        "⚠️ Provenance drift: node '{}' has {} missing file(s)",
805                        detail.record.node_id,
806                        drift.len()
807                    ));
808                    self.emit_event(perspt_core::AgentEvent::ProvenanceDrift {
809                        node_id: detail.record.node_id.clone(),
810                        missing_files: drift,
811                        reason: "Files referenced in persisted context no longer exist".to_string(),
812                    });
813                }
814            }
815        }
816
817        Ok(snapshot)
818    }
819
820    /// Resume execution from a rehydrated session.
821    ///
822    /// Walks the DAG in topological order, skipping terminal nodes and
823    /// executing any node whose state is not completed/failed/aborted.
824    /// Emits a differential resume summary so users can see what will
825    /// be replayed vs. skipped.
826    pub async fn run_resumed(&mut self) -> Result<()> {
827        let result = self.run_resumed_inner().await;
828        self.finalize_session(&result);
829        result.map(|_| ())
830    }
831
832    /// Inner resumed execution logic.
833    async fn run_resumed_inner(&mut self) -> Result<perspt_core::SessionOutcome> {
834        let topo = Topo::new(&self.graph);
835        let indices: Vec<_> = topo.iter(&self.graph).collect();
836        let total_nodes = indices.len();
837        let mut executed = 0;
838        let mut escalated: usize = 0;
839
840        // PSP-5 Phase 8: Emit differential resume summary
841        let terminal_count = indices
842            .iter()
843            .filter(|i| self.graph[**i].state.is_terminal())
844            .count();
845        let blocked_count = indices
846            .iter()
847            .filter(|i| !self.graph[**i].state.is_terminal() && self.check_seal_prerequisites(**i))
848            .count();
849        let resumable_count = total_nodes - terminal_count - blocked_count;
850        self.emit_log(format!(
851            "📊 Differential resume: {} total, {} skipped (terminal), {} blocked (seal), {} to execute",
852            total_nodes, terminal_count, blocked_count, resumable_count
853        ));
854
855        for (i, idx) in indices.iter().enumerate() {
856            // Abort gate
857            if self.is_abort_requested() {
858                self.emit_log("⚠️ Session aborted — stopping resumed execution".to_string());
859                break;
860            }
861
862            // Budget gate: stop execution if step/cost/revision budget exhausted.
863            if self.budget.any_exhausted() {
864                let node_id = self.graph[*idx].node_id.clone();
865                self.emit_log(format!(
866                    "⛔ Budget exhausted — skipping node '{}' and remaining nodes",
867                    node_id
868                ));
869                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
870                    node_id,
871                    status: perspt_core::NodeStatus::Escalated,
872                });
873                break;
874            }
875
876            let node = &self.graph[*idx];
877
878            // Skip terminal nodes
879            if node.state.is_terminal() {
880                log::debug!("Skipping terminal node {} ({:?})", node.node_id, node.state);
881                continue;
882            }
883
884            // Check seal prerequisites
885            if self.check_seal_prerequisites(*idx) {
886                log::warn!(
887                    "Node {} blocked on seal prerequisite — skipping",
888                    self.graph[*idx].node_id
889                );
890                continue;
891            }
892
893            let node = &self.graph[*idx];
894            self.emit_log(format!(
895                "📝 [resume {}/{}] {}",
896                i + 1,
897                total_nodes,
898                node.goal
899            ));
900            self.emit_event(perspt_core::AgentEvent::NodeSelected {
901                node_id: node.node_id.clone(),
902                goal: node.goal.clone(),
903                node_class: node.node_class.to_string(),
904            });
905            self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
906                node_id: node.node_id.clone(),
907                status: perspt_core::NodeStatus::Running,
908            });
909
910            match self.execute_node(*idx).await {
911                Ok(NodeOutcome::Completed) => {
912                    executed += 1;
913                    self.budget.record_step();
914
915                    // Persist budget envelope for auditability.
916                    if let Err(e) = self.ledger.upsert_budget_envelope(&self.budget) {
917                        log::warn!("Failed to persist budget envelope: {}", e);
918                    }
919
920                    if let Some(node) = self.graph.node_weight(*idx) {
921                        self.emit_event(perspt_core::AgentEvent::NodeCompleted {
922                            node_id: node.node_id.clone(),
923                            goal: node.goal.clone(),
924                        });
925                    }
926                }
927                Ok(NodeOutcome::Escalated) => {
928                    escalated += 1;
929                    self.budget.record_step();
930                    continue;
931                }
932                Err(e) => {
933                    escalated += 1;
934                    let node_id = self.graph[*idx].node_id.clone();
935                    log::error!("Node {} failed on resume: {}", node_id, e);
936                    self.emit_log(format!("❌ Node {} failed: {}", node_id, e));
937                    self.graph[*idx].state = NodeState::Escalated;
938                    self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
939                        node_id,
940                        status: perspt_core::NodeStatus::Escalated,
941                    });
942                    continue;
943                }
944            }
945        }
946
947        log::info!(
948            "Resumed execution completed: {} of {} nodes executed",
949            executed,
950            total_nodes
951        );
952
953        // Derive session outcome from actual node results, same logic as
954        // run_orchestration: unattempted nodes count as incomplete.
955        let outcome = if escalated == 0 && executed + terminal_count >= total_nodes {
956            perspt_core::SessionOutcome::Success
957        } else if executed > 0 {
958            perspt_core::SessionOutcome::PartialSuccess
959        } else {
960            perspt_core::SessionOutcome::Failed
961        };
962        self.emit_event(perspt_core::AgentEvent::Complete {
963            success: outcome == perspt_core::SessionOutcome::Success,
964            message: format!(
965                "Resumed: {}/{} completed, {} escalated",
966                executed, total_nodes, escalated
967            ),
968        });
969        Ok(outcome)
970    }
971
972    /// Emit an event to the TUI (if connected)
973    fn emit_event(&self, event: perspt_core::AgentEvent) {
974        if let Some(ref sender) = self.event_sender {
975            let _ = sender.send(event);
976        }
977    }
978
979    /// Emit a log message to TUI
980    fn emit_log(&self, msg: impl Into<String>) {
981        self.emit_event(perspt_core::AgentEvent::Log(msg.into()));
982    }
983
984    /// PSP-7: Record an orchestration step transition to the store.
985    fn record_step_quietly(
986        &self,
987        node_id: &str,
988        step: &str,
989        outcome: &str,
990        energy: Option<&perspt_core::types::EnergyComponents>,
991        attempt_count: i32,
992        duration_ms: i32,
993    ) {
994        let record = perspt_store::SrbnStepRecord {
995            session_id: self.context.session_id.clone(),
996            node_id: node_id.to_string(),
997            step: step.to_string(),
998            outcome: outcome.to_string(),
999            energy_json: energy.and_then(|e| serde_json::to_string(e).ok()),
1000            parse_state: None,
1001            retry_classification: None,
1002            attempt_count,
1003            duration_ms,
1004        };
1005        if let Err(e) = self.ledger.record_step(&record) {
1006            log::warn!("Failed to record step '{}' for {}: {}", step, node_id, e);
1007        }
1008    }
1009
1010    /// Request approval from user and await response
1011    /// Returns ApprovalResult with optional edited value.
1012    /// `review_node_id` is used for persisting the review audit record.
1013    async fn await_approval(
1014        &mut self,
1015        action_type: perspt_core::ActionType,
1016        description: String,
1017        diff: Option<String>,
1018    ) -> ApprovalResult {
1019        self.await_approval_for_node(action_type, description, diff, None)
1020            .await
1021    }
1022
1023    /// Internal approval with optional node_id for audit persistence.
1024    async fn await_approval_for_node(
1025        &mut self,
1026        action_type: perspt_core::ActionType,
1027        description: String,
1028        diff: Option<String>,
1029        review_node_id: Option<&str>,
1030    ) -> ApprovalResult {
1031        // If auto_approve is enabled, skip approval
1032        if self.auto_approve {
1033            if let Some(nid) = review_node_id {
1034                self.persist_review_decision(nid, "auto_approved", None);
1035            }
1036            return ApprovalResult::Approved;
1037        }
1038
1039        // If no TUI connected, default to approve (headless with --yes)
1040        if self.action_receiver.is_none() {
1041            if let Some(nid) = review_node_id {
1042                self.persist_review_decision(nid, "auto_approved", None);
1043            }
1044            return ApprovalResult::Approved;
1045        }
1046
1047        // Generate unique request ID
1048        let request_id = uuid::Uuid::new_v4().to_string();
1049
1050        // Emit approval request
1051        self.emit_event(perspt_core::AgentEvent::ApprovalRequest {
1052            request_id: request_id.clone(),
1053            node_id: review_node_id.unwrap_or("current").to_string(),
1054            action_type,
1055            description,
1056            diff,
1057        });
1058
1059        // Wait for response
1060        if let Some(ref mut receiver) = self.action_receiver {
1061            while let Some(action) = receiver.recv().await {
1062                match action {
1063                    perspt_core::AgentAction::Approve { request_id: rid } if rid == request_id => {
1064                        self.emit_log("✓ Approved by user");
1065                        if let Some(nid) = review_node_id {
1066                            self.persist_review_decision(nid, "approved", None);
1067                        }
1068                        return ApprovalResult::Approved;
1069                    }
1070                    perspt_core::AgentAction::ApproveWithEdit {
1071                        request_id: rid,
1072                        edited_value,
1073                    } if rid == request_id => {
1074                        self.emit_log(format!("✓ Approved with edit: {}", edited_value));
1075                        if let Some(nid) = review_node_id {
1076                            self.persist_review_decision(nid, "approved_with_edit", None);
1077                        }
1078                        return ApprovalResult::ApprovedWithEdit(edited_value);
1079                    }
1080                    perspt_core::AgentAction::Reject {
1081                        request_id: rid,
1082                        reason,
1083                    } if rid == request_id => {
1084                        let msg = reason.unwrap_or_else(|| "User rejected".to_string());
1085                        self.emit_log(format!("✗ Rejected: {}", msg));
1086                        if let Some(nid) = review_node_id {
1087                            self.persist_review_decision(nid, "rejected", Some(&msg));
1088                        }
1089                        return ApprovalResult::Rejected;
1090                    }
1091                    perspt_core::AgentAction::RequestCorrection {
1092                        request_id: rid,
1093                        feedback,
1094                    } if rid == request_id => {
1095                        self.emit_log(format!("🔄 Correction requested: {}", feedback));
1096                        if let Some(nid) = review_node_id {
1097                            self.persist_review_decision(
1098                                nid,
1099                                "correction_requested",
1100                                Some(&feedback),
1101                            );
1102                        }
1103                        return ApprovalResult::Rejected;
1104                    }
1105                    perspt_core::AgentAction::Abort => {
1106                        self.emit_log("⚠️ Session aborted by user");
1107                        self.abort_requested.store(true, Ordering::Relaxed);
1108                        if let Some(nid) = review_node_id {
1109                            self.persist_review_decision(nid, "aborted", None);
1110                        }
1111                        return ApprovalResult::Rejected;
1112                    }
1113                    _ => {
1114                        // Ignore other actions while waiting for this specific approval
1115                        continue;
1116                    }
1117                }
1118            }
1119        }
1120
1121        ApprovalResult::Rejected // Channel closed
1122    }
1123
1124    /// Persist a review decision to the audit trail.
1125    fn persist_review_decision(&self, node_id: &str, outcome: &str, note: Option<&str>) {
1126        let degraded = self.last_verification_result.as_ref().map(|vr| vr.degraded);
1127        if let Err(e) = self
1128            .ledger
1129            .record_review_outcome(node_id, outcome, note, None, degraded, None)
1130        {
1131            log::warn!("Failed to persist review decision for {}: {}", node_id, e);
1132        }
1133    }
1134
1135    /// Add a dependency edge between nodes
1136    pub fn add_dependency(&mut self, from_id: &str, to_id: &str, kind: &str) -> Result<()> {
1137        let from_idx = self
1138            .node_indices
1139            .get(from_id)
1140            .context(format!("Node not found: {}", from_id))?;
1141        let to_idx = self
1142            .node_indices
1143            .get(to_id)
1144            .context(format!("Node not found: {}", to_id))?;
1145
1146        self.graph.add_edge(
1147            *from_idx,
1148            *to_idx,
1149            Dependency {
1150                kind: kind.to_string(),
1151            },
1152        );
1153        Ok(())
1154    }
1155
1156    /// Run the complete SRBN control loop
1157    pub async fn run(&mut self, task: String) -> Result<()> {
1158        log::info!("Starting SRBN execution for task: {}", task);
1159        self.emit_log(format!("🚀 Starting task: {}", task));
1160
1161        // Step 0: Start session first
1162        let session_id = uuid::Uuid::new_v4().to_string();
1163        self.context.session_id = session_id.clone();
1164        self.ledger.start_session(
1165            &session_id,
1166            &task,
1167            &self.context.working_dir.to_string_lossy(),
1168        )?;
1169
1170        // Run orchestration and always finalize the session
1171        let result = self.run_orchestration(task).await;
1172        self.finalize_session(&result);
1173        result.map(|_| ())
1174    }
1175
1176    /// Inner orchestration logic — called by `run()` which handles session lifecycle.
1177    async fn run_orchestration(&mut self, task: String) -> Result<perspt_core::SessionOutcome> {
1178        if self.context.log_llm {
1179            self.emit_log("📝 LLM request logging enabled".to_string());
1180        }
1181
1182        // PSP-5: Detect execution mode (Project is default, Solo only on explicit keywords)
1183        let execution_mode = self.detect_execution_mode(&task);
1184        self.context.execution_mode = execution_mode;
1185        self.emit_log(format!("🎯 Execution mode: {}", execution_mode));
1186
1187        if execution_mode == perspt_core::types::ExecutionMode::Solo {
1188            // Solo Mode: Single-file execution without DAG
1189            log::info!("Using Solo Mode for explicit single-file task");
1190            self.emit_log("⚡ Solo Mode: Single-file execution".to_string());
1191            return self
1192                .run_solo_mode(task)
1193                .await
1194                .map(|()| perspt_core::SessionOutcome::Success);
1195        }
1196
1197        // PSP-5: Classify workspace state before deciding plugin/init strategy
1198        let workspace_state = self.classify_workspace(&task);
1199        self.context.workspace_state = workspace_state.clone();
1200        self.emit_log(format!("📋 Workspace: {}", workspace_state));
1201
1202        // For existing projects, detect plugins and probe verifier readiness now.
1203        // For greenfield/ambiguous, defer until after step_init_project().
1204        if let WorkspaceState::ExistingProject { ref plugins } = workspace_state {
1205            self.context.active_plugins = plugins.clone();
1206            self.emit_log(format!("🔌 Detected plugins: {}", plugins.join(", ")));
1207            self.emit_plugin_readiness();
1208        }
1209
1210        // Team Mode: Full project initialization and DAG sheafification
1211        self.step_init_project(&task).await?;
1212
1213        // PSP-5: For greenfield/ambiguous workspaces, re-detect plugins after init
1214        // and probe verifier readiness against the newly initialized project.
1215        if !matches!(workspace_state, WorkspaceState::ExistingProject { .. }) {
1216            self.redetect_plugins_after_init();
1217        }
1218
1219        // Gate: verify at least one plugin has build capability before planning.
1220        // Without this, the architect may produce a plan whose verification is
1221        // fully degraded, leading to false stability.
1222        self.check_verifier_readiness_gate();
1223
1224        // Start LSP for detected plugins (after classification + init so we
1225        // use the authoritative plugin set, not a provisional one).
1226        {
1227            let plugin_refs: Vec<String> = self.context.active_plugins.clone();
1228            let refs: Vec<&str> = plugin_refs.iter().map(|s| s.as_str()).collect();
1229            if !refs.is_empty() {
1230                self.emit_log("🔍 Starting language servers...".to_string());
1231                if let Err(e) = self.start_lsp_for_plugins(&refs).await {
1232                    log::warn!("Failed to start LSP: {}", e);
1233                    self.emit_log("⚠️ Continuing without LSP".to_string());
1234                } else {
1235                    self.emit_log("✅ Language servers ready".to_string());
1236                }
1237            }
1238        }
1239
1240        // Select planning policy based on workspace state before architect runs.
1241        // Greenfield workspaces use GreenfieldBuild; existing projects
1242        // default to FeatureIncrement (callers may override via set_planning_policy).
1243        if self.planning_policy == perspt_core::PlanningPolicy::default() {
1244            self.planning_policy = match &self.context.workspace_state {
1245                WorkspaceState::Greenfield { .. } => perspt_core::PlanningPolicy::GreenfieldBuild,
1246                WorkspaceState::ExistingProject { .. } => {
1247                    perspt_core::PlanningPolicy::FeatureIncrement
1248                }
1249                WorkspaceState::Ambiguous => perspt_core::PlanningPolicy::FeatureIncrement,
1250            };
1251        }
1252
1253        // PSP-5 Phase 12: Create a default FeatureCharter so the
1254        // file-budget gate in step_sheafify has bounds to enforce.
1255        // Derive sensible defaults from the planning policy.
1256        if self.ledger.get_feature_charter().ok().flatten().is_none() {
1257            let mut charter = perspt_core::FeatureCharter::new(&self.context.session_id, &task);
1258            match self.planning_policy {
1259                perspt_core::PlanningPolicy::LocalEdit => {
1260                    charter.max_modules = Some(1);
1261                    charter.max_files = Some(5);
1262                    charter.max_revisions = Some(3);
1263                }
1264                perspt_core::PlanningPolicy::FeatureIncrement => {
1265                    charter.max_modules = Some(10);
1266                    charter.max_files = Some(30);
1267                    charter.max_revisions = Some(5);
1268                }
1269                perspt_core::PlanningPolicy::LargeFeature
1270                | perspt_core::PlanningPolicy::GreenfieldBuild
1271                | perspt_core::PlanningPolicy::ArchitecturalRevision => {
1272                    charter.max_modules = Some(25);
1273                    charter.max_files = Some(80);
1274                    charter.max_revisions = Some(10);
1275                }
1276            }
1277            if let Some(ref lang) = self.context.active_plugins.first() {
1278                charter.language_constraint = Some(lang.to_string());
1279            }
1280            if let Err(e) = self.ledger.record_feature_charter(&charter) {
1281                log::warn!("Failed to persist default FeatureCharter: {}", e);
1282            } else {
1283                log::info!(
1284                    "Registered default FeatureCharter (max_modules={:?}, max_files={:?})",
1285                    charter.max_modules,
1286                    charter.max_files
1287                );
1288            }
1289        }
1290
1291        // Gate architect planning on policy: LocalEdit skips the architect
1292        // and creates a single-node deterministic graph directly.
1293        if self.planning_policy.needs_architect() {
1294            self.step_sheafify(task).await?;
1295        } else {
1296            self.emit_log("📐 LocalEdit policy — skipping architect, single-node plan".to_string());
1297            self.create_deterministic_fallback_graph(&task)?;
1298        }
1299
1300        // Planning policy is already resolved above; log it after sheafification.
1301        self.emit_log(format!("📐 Planning policy: {:?}", self.planning_policy));
1302
1303        // PSP-5: Emit PlanReady event after sheafification
1304        let node_count = self.graph.node_count();
1305        self.emit_event(perspt_core::AgentEvent::PlanReady {
1306            nodes: node_count,
1307            plugins: self.context.active_plugins.clone(),
1308            execution_mode: execution_mode.to_string(),
1309        });
1310
1311        // Emit task nodes to TUI after sheafification
1312        for node_id in self.node_indices.keys() {
1313            if let Some(idx) = self.node_indices.get(node_id) {
1314                if let Some(node) = self.graph.node_weight(*idx) {
1315                    self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1316                        node_id: node.node_id.clone(),
1317                        status: perspt_core::NodeStatus::Pending,
1318                    });
1319                }
1320            }
1321        }
1322
1323        // Step 2-7: Execute nodes in topological order
1324        let topo = Topo::new(&self.graph);
1325        let indices: Vec<_> = topo.iter(&self.graph).collect();
1326        let total_nodes = indices.len();
1327        let mut completed_count: usize = 0;
1328        let mut escalated_count: usize = 0;
1329
1330        for (i, idx) in indices.iter().enumerate() {
1331            // Abort gate: stop execution if abort was requested.
1332            if self.is_abort_requested() {
1333                self.emit_log("⚠️ Session aborted — stopping execution".to_string());
1334                break;
1335            }
1336
1337            // Budget gate: stop execution if step/cost/revision budget exhausted.
1338            if self.budget.any_exhausted() {
1339                let node_id = self.graph[*idx].node_id.clone();
1340                self.emit_log(format!(
1341                    "⛔ Budget exhausted — skipping node '{}' and remaining nodes",
1342                    node_id
1343                ));
1344                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1345                    node_id,
1346                    status: perspt_core::NodeStatus::Escalated,
1347                });
1348                break;
1349            }
1350
1351            // PSP-5 Phase 6: Check if node is blocked on a parent interface seal.
1352            // In the current sequential topo-order execution this should not fire
1353            // (parents commit before children), but it establishes the gating
1354            // contract for when speculative parallelism is introduced later.
1355            if self.check_seal_prerequisites(*idx) {
1356                log::warn!(
1357                    "Node {} blocked on seal prerequisite — skipping in this iteration",
1358                    self.graph[*idx].node_id
1359                );
1360                continue;
1361            }
1362
1363            // PSP-5: Emit NodeSelected event before execution
1364            if let Some(node) = self.graph.node_weight(*idx) {
1365                self.emit_log(format!("📝 [{}/{}] {}", i + 1, total_nodes, node.goal));
1366                self.emit_event(perspt_core::AgentEvent::NodeSelected {
1367                    node_id: node.node_id.clone(),
1368                    goal: node.goal.clone(),
1369                    node_class: node.node_class.to_string(),
1370                });
1371                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1372                    node_id: node.node_id.clone(),
1373                    status: perspt_core::NodeStatus::Running,
1374                });
1375            }
1376
1377            match self.execute_node(*idx).await {
1378                Ok(NodeOutcome::Completed) => {
1379                    completed_count += 1;
1380
1381                    // Record step in budget envelope
1382                    self.budget.record_step();
1383
1384                    // Emit budget status after each step
1385                    self.emit_event(perspt_core::AgentEvent::BudgetUpdated {
1386                        steps_used: self.budget.steps_used,
1387                        max_steps: self.budget.max_steps,
1388                        cost_used_usd: self.budget.cost_used_usd,
1389                        max_cost_usd: self.budget.max_cost_usd,
1390                        revisions_used: self.budget.revisions_used,
1391                        max_revisions: self.budget.max_revisions,
1392                    });
1393
1394                    // Persist budget envelope to store for auditability.
1395                    if let Err(e) = self.ledger.upsert_budget_envelope(&self.budget) {
1396                        log::warn!("Failed to persist budget envelope: {}", e);
1397                    }
1398
1399                    // Emit completed status
1400                    if let Some(node) = self.graph.node_weight(*idx) {
1401                        self.emit_event(perspt_core::AgentEvent::NodeCompleted {
1402                            node_id: node.node_id.clone(),
1403                            goal: node.goal.clone(),
1404                        });
1405                    }
1406                }
1407                Ok(NodeOutcome::Escalated) => {
1408                    escalated_count += 1;
1409                    self.budget.record_step();
1410
1411                    // Do NOT emit NodeCompleted — the node was escalated, not completed.
1412                    if let Some(node) = self.graph.node_weight(*idx) {
1413                        self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1414                            node_id: node.node_id.clone(),
1415                            status: perspt_core::NodeStatus::Escalated,
1416                        });
1417                    }
1418                    continue;
1419                }
1420                Err(e) => {
1421                    escalated_count += 1;
1422                    let node_id = self.graph[*idx].node_id.clone();
1423                    eprintln!("[SRBN-DIAG] Node {} failed: {:#}", node_id, e);
1424                    log::error!("Node {} failed: {}", node_id, e);
1425                    self.emit_log(format!("❌ Node {} failed: {}", node_id, e));
1426
1427                    // Flush the node's provisional branch so sandbox files
1428                    // don't leak. Without this, files written to the sandbox
1429                    // are lost when step_commit/step_sheaf_validate fails
1430                    // before merge.
1431                    if let Some(bid) = self.graph[*idx].provisional_branch_id.clone() {
1432                        self.flush_provisional_branch(&bid, &node_id);
1433                    }
1434                    self.flush_descendant_branches(*idx);
1435
1436                    self.graph[*idx].state = NodeState::Escalated;
1437                    self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1438                        node_id: node_id.clone(),
1439                        status: perspt_core::NodeStatus::Escalated,
1440                    });
1441                    // Continue to next node instead of stopping all execution
1442                    continue;
1443                }
1444            }
1445        }
1446
1447        log::info!("SRBN execution completed");
1448
1449        // PSP-5 Phase 6: Clean up all session sandboxes
1450        if let Err(e) = crate::tools::cleanup_session_sandboxes(
1451            &self.context.working_dir,
1452            &self.context.session_id,
1453        ) {
1454            log::warn!("Failed to clean up session sandboxes: {}", e);
1455        }
1456
1457        // Derive session outcome from actual node results.
1458        // If not all nodes were attempted (due to budget/abort), only Success
1459        // when every node in the plan completed.
1460        let outcome = if escalated_count == 0 && completed_count >= total_nodes {
1461            perspt_core::SessionOutcome::Success
1462        } else if completed_count > 0 {
1463            perspt_core::SessionOutcome::PartialSuccess
1464        } else {
1465            perspt_core::SessionOutcome::Failed
1466        };
1467        self.emit_event(perspt_core::AgentEvent::Complete {
1468            success: outcome == perspt_core::SessionOutcome::Success,
1469            message: format!(
1470                "{}/{} nodes completed, {} escalated",
1471                completed_count, total_nodes, escalated_count
1472            ),
1473        });
1474        Ok(outcome)
1475    }
1476
1477    /// Execute a single node through the control loop
1478    async fn execute_node(&mut self, idx: NodeIndex) -> Result<NodeOutcome> {
1479        let node = &self.graph[idx];
1480        log::info!("Executing node: {} ({})", node.node_id, node.goal);
1481
1482        // PSP-5 Phase 6: Create provisional branch if node has graph parents
1483        let branch_id = self.maybe_create_provisional_branch(idx);
1484
1485        // Step 2: Recursive Sub-graph Execution (already in topo order)
1486        self.graph[idx].state = NodeState::Coding;
1487        self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1488            node_id: self.graph[idx].node_id.clone(),
1489            status: perspt_core::NodeStatus::Coding,
1490        });
1491
1492        // Step 3: Speculative Generation
1493        let speculate_start = std::time::Instant::now();
1494        self.step_speculate(idx).await?;
1495        self.record_step_quietly(
1496            &self.graph[idx].node_id.clone(),
1497            "speculate",
1498            "ok",
1499            None,
1500            0,
1501            speculate_start.elapsed().as_millis() as i32,
1502        );
1503
1504        // Step 4: Stability Verification
1505        let verify_start = std::time::Instant::now();
1506        let mut energy = self.step_verify(idx).await?;
1507        self.record_step_quietly(
1508            &self.graph[idx].node_id.clone(),
1509            "verify",
1510            "ok",
1511            Some(&energy),
1512            0,
1513            verify_start.elapsed().as_millis() as i32,
1514        );
1515
1516        // PSP-7: Sheaf pre-check retry loop.
1517        // After convergence succeeds, a lightweight structural check verifies
1518        // output artifacts exist on disk before proceeding to full sheaf
1519        // validation. If pre-check fails, re-enter convergence with sheaf
1520        // evidence (max 1 retry to prevent infinite loops).
1521        let mut sheaf_pre_check_retries = 0u32;
1522        let mut converge_start;
1523        loop {
1524            // Step 5: Convergence & Self-Correction
1525            converge_start = std::time::Instant::now();
1526            if !self.step_converge(idx, energy.clone()).await? {
1527                self.record_step_quietly(
1528                    &self.graph[idx].node_id.clone(),
1529                    "converge",
1530                    "escalated",
1531                    Some(&energy),
1532                    self.graph[idx].monitor.attempt_count as i32,
1533                    converge_start.elapsed().as_millis() as i32,
1534                );
1535                // PSP-5 Phase 5: Classify non-convergence and choose repair action
1536                let category = self.classify_non_convergence(idx);
1537                let action = self.choose_repair_action(idx, &category);
1538
1539                // Persist the escalation report
1540                let node = &self.graph[idx];
1541                let report = EscalationReport {
1542                    node_id: node.node_id.clone(),
1543                    session_id: self.context.session_id.clone(),
1544                    category,
1545                    action: action.clone(),
1546                    energy_snapshot: EnergyComponents {
1547                        v_syn: node.monitor.current_energy(),
1548                        ..Default::default()
1549                    },
1550                    stage_outcomes: self
1551                        .last_verification_result
1552                        .as_ref()
1553                        .map(|vr| vr.stage_outcomes.clone())
1554                        .unwrap_or_default(),
1555                    evidence: self.build_escalation_evidence(idx),
1556                    affected_node_ids: self.affected_dependents(idx),
1557                    timestamp: epoch_seconds(),
1558                };
1559
1560                if let Err(e) = self.ledger.record_escalation_report(&report) {
1561                    log::warn!("Failed to persist escalation report: {}", e);
1562                }
1563
1564                // PSP-5 Phase 9: Also persist artifact bundle on escalation path
1565                if let Some(bundle) = self.last_applied_bundle.take() {
1566                    if let Err(e) = self
1567                        .ledger
1568                        .record_artifact_bundle(&self.graph[idx].node_id, &bundle)
1569                    {
1570                        log::warn!(
1571                            "Failed to persist artifact bundle on escalation for {}: {}",
1572                            self.graph[idx].node_id,
1573                            e
1574                        );
1575                    }
1576                }
1577
1578                self.emit_event(perspt_core::AgentEvent::EscalationClassified {
1579                    node_id: report.node_id.clone(),
1580                    category: report.category.to_string(),
1581                    action: report.action.to_string(),
1582                });
1583
1584                // PSP-5 Phase 6: Flush this branch and all descendant branches
1585                let node_id_for_flush = self.graph[idx].node_id.clone();
1586                if let Some(ref bid) = branch_id {
1587                    self.flush_provisional_branch(bid, &node_id_for_flush);
1588                }
1589                self.flush_descendant_branches(idx);
1590
1591                // Apply the chosen repair action or escalate to user
1592                let applied = self.apply_repair_action(idx, &action).await;
1593
1594                if !applied {
1595                    self.graph[idx].state = NodeState::Escalated;
1596                    self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1597                        node_id: self.graph[idx].node_id.clone(),
1598                        status: perspt_core::NodeStatus::Escalated,
1599                    });
1600                    log::warn!(
1601                        "Node {} escalated to user: {} → {}",
1602                        self.graph[idx].node_id,
1603                        category,
1604                        action
1605                    );
1606                }
1607
1608                return Ok(NodeOutcome::Escalated);
1609            }
1610
1611            // PSP-7: Lightweight sheaf pre-check before full validation.
1612            // Verifies output artifacts exist and are non-empty on disk.
1613            if sheaf_pre_check_retries < 1 {
1614                if let Some(evidence) = self.sheaf_pre_check(idx) {
1615                    sheaf_pre_check_retries += 1;
1616                    log::warn!(
1617                        "Sheaf pre-check failed for {}, retrying convergence: {}",
1618                        self.graph[idx].node_id,
1619                        evidence
1620                    );
1621                    self.emit_log(format!("⚠️ Sheaf pre-check: {}", evidence));
1622                    // Inject sheaf evidence so the correction LLM sees it
1623                    self.context.last_test_output = Some(format!(
1624                    "Structural pre-check failure: {}\nEnsure all declared output files are generated correctly.",
1625                    evidence
1626                ));
1627                    // Re-verify and add sheaf penalty to force correction loop entry
1628                    energy = self.step_verify(idx).await?;
1629                    energy.v_sheaf += 2.0;
1630                    continue;
1631                }
1632            }
1633            break;
1634        } // end PSP-7 sheaf pre-check loop
1635
1636        // Final sheaf pre-check guard: after the retry loop, verify once more.
1637        // If the retry still produced stub/missing artifacts, escalate the node
1638        // instead of proceeding to commit.
1639        if sheaf_pre_check_retries > 0 {
1640            if let Some(evidence) = self.sheaf_pre_check(idx) {
1641                log::warn!(
1642                    "Sheaf pre-check still failing for {} after retry, escalating: {}",
1643                    self.graph[idx].node_id,
1644                    evidence
1645                );
1646                self.emit_log(format!(
1647                    "❌ Sheaf pre-check failed after retry: {}",
1648                    evidence
1649                ));
1650                self.graph[idx].state = NodeState::Escalated;
1651                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1652                    node_id: self.graph[idx].node_id.clone(),
1653                    status: perspt_core::NodeStatus::Escalated,
1654                });
1655                // Flush provisional branch on escalation
1656                let node_id_for_flush = self.graph[idx].node_id.clone();
1657                if let Some(ref bid) = branch_id {
1658                    self.flush_provisional_branch(bid, &node_id_for_flush);
1659                }
1660                self.flush_descendant_branches(idx);
1661                return Ok(NodeOutcome::Escalated);
1662            }
1663        }
1664
1665        // Record converge success (timing from last converge_start)
1666        self.record_step_quietly(
1667            &self.graph[idx].node_id.clone(),
1668            "converge",
1669            "ok",
1670            Some(&energy),
1671            self.graph[idx].monitor.attempt_count as i32,
1672            converge_start.elapsed().as_millis() as i32,
1673        );
1674
1675        // Step 6: Sheaf Validation (Post-Subgraph Consistency)
1676        let sheaf_start = std::time::Instant::now();
1677        self.step_sheaf_validate(idx).await?;
1678        self.record_step_quietly(
1679            &self.graph[idx].node_id.clone(),
1680            "sheaf_validate",
1681            "ok",
1682            None,
1683            0,
1684            sheaf_start.elapsed().as_millis() as i32,
1685        );
1686
1687        // Step 7: Merkle Ledger Commit
1688        let commit_start = std::time::Instant::now();
1689        self.step_commit(idx).await?;
1690        self.record_step_quietly(
1691            &self.graph[idx].node_id.clone(),
1692            "commit",
1693            "ok",
1694            None,
1695            0,
1696            commit_start.elapsed().as_millis() as i32,
1697        );
1698
1699        // PSP-5 Phase 6: Merge provisional branch after successful commit
1700        if let Some(ref bid) = branch_id {
1701            self.merge_provisional_branch(bid, idx);
1702        }
1703
1704        Ok(NodeOutcome::Completed)
1705    }
1706
1707    /// Step 3: Speculative Generation
1708    async fn step_speculate(&mut self, idx: NodeIndex) -> Result<()> {
1709        log::info!("Step 3: Speculation - Generating implementation");
1710
1711        // PSP-5 Phase 3: Build context package for this node.
1712        // Use the sandbox directory when available so the LLM sees files
1713        // it will actually write to, falling back to the workspace root.
1714        let retriever = ContextRetriever::new(self.effective_working_dir(idx))
1715            .with_max_file_bytes(8 * 1024)
1716            .with_max_context_bytes(100 * 1024); // 100KB default budget
1717
1718        let node = &self.graph[idx];
1719        let mut restriction_map =
1720            retriever.build_restriction_map(node, &self.context.ownership_manifest);
1721
1722        // PSP-5 Phase 6: Inject sealed interface digests from parent nodes.
1723        // For each parent Interface node that has a recorded seal, add the
1724        // seal's structural digest to the restriction map so the context
1725        // package uses immutable sealed data instead of mutable parent files.
1726        self.inject_sealed_interfaces(idx, &mut restriction_map);
1727
1728        let node = &self.graph[idx];
1729        let context_package = retriever.assemble_context_package(node, &restriction_map);
1730        let formatted_context = retriever.format_context_package(&context_package);
1731
1732        // PSP-5 Phase 3: Enforce context budget — emit degradation event when
1733        // budget is exceeded or required owned files are missing.
1734        let node = &self.graph[idx];
1735        let missing_owned: Vec<String> = restriction_map
1736            .owned_files
1737            .iter()
1738            .filter(|f| {
1739                // Only treat as missing if not planned for creation by this node
1740                !context_package.included_files.contains_key(*f)
1741                    && !node
1742                        .output_targets
1743                        .iter()
1744                        .any(|ot| ot.to_string_lossy() == **f)
1745            })
1746            .cloned()
1747            .collect();
1748
1749        if context_package.budget_exceeded || !missing_owned.is_empty() {
1750            let reason = if context_package.budget_exceeded && !missing_owned.is_empty() {
1751                format!(
1752                    "Budget exceeded and {} owned file(s) missing",
1753                    missing_owned.len()
1754                )
1755            } else if context_package.budget_exceeded {
1756                "Context budget exceeded; some files replaced with structural digests".to_string()
1757            } else {
1758                format!(
1759                    "{} owned file(s) could not be read: {}",
1760                    missing_owned.len(),
1761                    missing_owned.join(", ")
1762                )
1763            };
1764
1765            log::warn!("Context degraded for node '{}': {}", node.node_id, reason);
1766            self.emit_log(format!("⚠️ Context degraded: {}", reason));
1767            self.emit_event(perspt_core::AgentEvent::ContextDegraded {
1768                node_id: node.node_id.clone(),
1769                budget_exceeded: context_package.budget_exceeded,
1770                missing_owned_files: missing_owned.clone(),
1771                included_file_count: context_package.included_files.len(),
1772                total_bytes: context_package.total_bytes,
1773                reason: reason.clone(),
1774            });
1775
1776            // PSP-5 Phase 3: Block execution when required owned files are missing.
1777            // Budget-exceeded-but-all-owned-files-present is a warning, not a block.
1778            if !missing_owned.is_empty() {
1779                self.emit_event(perspt_core::AgentEvent::ContextBlocked {
1780                    node_id: node.node_id.clone(),
1781                    missing_owned_files: missing_owned,
1782                    reason: reason.clone(),
1783                });
1784                self.graph[idx].state = NodeState::Escalated;
1785                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1786                    node_id: self.graph[idx].node_id.clone(),
1787                    status: perspt_core::NodeStatus::Escalated,
1788                });
1789                let err_msg = format!(
1790                    "Context blocked for node '{}': {}. Node escalated.",
1791                    self.graph[idx].node_id, reason
1792                );
1793                eprintln!("[SRBN-DIAG] {}", err_msg);
1794                return Err(anyhow::anyhow!(err_msg));
1795            }
1796        }
1797
1798        // PSP-5 Phase 3: Pre-execution structural dependency check.
1799        // A node SHALL NOT proceed when only prose exists for a required dependency.
1800        {
1801            let node = &self.graph[idx];
1802            let prose_only_deps = self.check_structural_dependencies(node, &restriction_map);
1803            if !prose_only_deps.is_empty() {
1804                for (dep_node_id, dep_reason) in &prose_only_deps {
1805                    self.emit_event(perspt_core::AgentEvent::StructuralDependencyMissing {
1806                        node_id: node.node_id.clone(),
1807                        dependency_node_id: dep_node_id.clone(),
1808                        reason: dep_reason.clone(),
1809                    });
1810                }
1811                let dep_names: Vec<&str> =
1812                    prose_only_deps.iter().map(|(id, _)| id.as_str()).collect();
1813                let block_reason = format!(
1814                    "Required structural dependencies lack machine-verifiable digests (only prose summaries): [{}]",
1815                    dep_names.join(", ")
1816                );
1817                eprintln!(
1818                    "[SRBN-DIAG] Structural dependency check failed for '{}': {}",
1819                    self.graph[idx].node_id, block_reason
1820                );
1821                self.emit_log(format!("🚫 {}", block_reason));
1822                self.graph[idx].state = NodeState::Escalated;
1823                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1824                    node_id: self.graph[idx].node_id.clone(),
1825                    status: perspt_core::NodeStatus::Escalated,
1826                });
1827                return Err(anyhow::anyhow!(
1828                    "Structural dependency check failed for node '{}': {}",
1829                    self.graph[idx].node_id,
1830                    block_reason
1831                ));
1832            }
1833        }
1834
1835        // Record provenance for later commit
1836        self.last_context_provenance = Some(context_package.provenance());
1837        // Store formatted context for reuse in correction prompts
1838        self.last_formatted_context = formatted_context.clone();
1839
1840        // PSP-5: Speculator lookahead — ask the speculator tier for bounded
1841        // hints about potential risks and downstream impacts before the
1842        // actuator generates code. Stored as ephemeral context, not committed.
1843        // Gated by planning policy: only LargeFeature/Greenfield/ArchitecturalRevision activate it.
1844        let speculator_hints = if self.planning_policy.needs_speculator() {
1845            let node_id = self.graph[idx].node_id.clone();
1846            let node_goal = self.graph[idx].goal.clone();
1847            let child_goals: Vec<String> = self
1848                .graph
1849                .edges(idx)
1850                .filter_map(|edge| {
1851                    let child = &self.graph[edge.target()];
1852                    if child.state == NodeState::TaskQueued {
1853                        Some(format!("- {}: {}", child.node_id, child.goal))
1854                    } else {
1855                        None
1856                    }
1857                })
1858                .collect();
1859
1860            if !child_goals.is_empty() {
1861                let ev = perspt_core::types::PromptEvidence {
1862                    node_goal: Some(node_goal.clone()),
1863                    context_files: vec![node_id.clone()],
1864                    output_files: child_goals.clone(),
1865                    ..Default::default()
1866                };
1867                let speculator_prompt = crate::prompt_compiler::compile(
1868                    perspt_core::types::PromptIntent::SpeculatorLookahead,
1869                    &ev,
1870                )
1871                .text;
1872
1873                log::debug!(
1874                    "Speculator lookahead for node {} using model {}",
1875                    node_id,
1876                    self.speculator_model
1877                );
1878                self.call_llm_with_logging(
1879                    &self.speculator_model.clone(),
1880                    &speculator_prompt,
1881                    Some(&node_id),
1882                )
1883                .await
1884                .unwrap_or_else(|e| {
1885                    log::warn!(
1886                        "Speculator lookahead failed ({}), proceeding without hints",
1887                        e
1888                    );
1889                    String::new()
1890                })
1891            } else {
1892                String::new()
1893            }
1894        } else {
1895            String::new()
1896        };
1897
1898        let actuator = &self.agents[1];
1899        let node = &self.graph[idx];
1900        let node_id = node.node_id.clone();
1901
1902        // Build prompt enriched with context package and speculator hints
1903        let base_prompt = actuator.build_prompt(node, &self.context);
1904        let mut prompt = if formatted_context.is_empty() {
1905            base_prompt
1906        } else {
1907            format!(
1908                "{}\n\n## Node Context (PSP-5 Restriction Map)\n\n{}",
1909                base_prompt, formatted_context
1910            )
1911        };
1912
1913        if !speculator_hints.is_empty() {
1914            prompt = format!(
1915                "{}\n\n## Speculator Lookahead Hints\n\n{}",
1916                prompt, speculator_hints
1917            );
1918        }
1919
1920        // Include sandbox/workspace file tree so the LLM has structural
1921        // awareness of the actual directory layout it is writing into.
1922        let wd = self.effective_working_dir(idx);
1923        if let Ok(tree) = crate::tools::list_sandbox_files(&wd) {
1924            if !tree.is_empty() {
1925                prompt = format!(
1926                    "{}\n\n## Current Project Tree\n\n```\n{}\n```",
1927                    prompt,
1928                    tree.join("\n")
1929                );
1930            }
1931        }
1932
1933        let model = actuator.model().to_string();
1934
1935        let response = self
1936            .call_llm_with_logging(&model, &prompt, Some(&node_id))
1937            .await?;
1938
1939        let message = crate::types::AgentMessage::new(crate::types::ModelTier::Actuator, response);
1940        let content = &message.content;
1941
1942        // Check for [COMMAND] blocks first (for TaskType::Command)
1943        if let Some(command) = self.extract_command_from_response(content) {
1944            log::info!("Extracted command: {}", command);
1945            self.emit_log(format!("🔧 Command proposed: {}", command));
1946
1947            // Request approval before executing command
1948            let node_id = self.graph[idx].node_id.clone();
1949            let approval_result = self
1950                .await_approval_for_node(
1951                    perspt_core::ActionType::Command {
1952                        command: command.clone(),
1953                    },
1954                    format!("Execute shell command: {}", command),
1955                    None,
1956                    Some(&node_id),
1957                )
1958                .await;
1959
1960            if !matches!(
1961                approval_result,
1962                ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
1963            ) {
1964                self.emit_log("⏭️ Command skipped (not approved)");
1965                return Ok(());
1966            }
1967
1968            // Execute command via AgentTools
1969            let mut args = HashMap::new();
1970            args.insert("command".to_string(), command.clone());
1971
1972            let call = ToolCall {
1973                name: "run_command".to_string(),
1974                arguments: args,
1975            };
1976
1977            let result = self.tools.execute(&call).await;
1978            if result.success {
1979                log::info!("✓ Command succeeded: {}", command);
1980                self.emit_log(format!("✅ Command succeeded: {}", command));
1981                self.emit_log(result.output);
1982            } else {
1983                log::warn!("Command failed: {:?}", result.error);
1984                self.emit_log(format!("❌ Command failed: {:?}", result.error));
1985            }
1986        }
1987        // PSP-7: Typed parse pipeline for initial generation
1988        else {
1989            let (bundle_opt, parse_state, record_opt) =
1990                self.parse_artifact_bundle_typed(content, &node_id, 0);
1991
1992            if let Some(ref record) = record_opt {
1993                log::info!(
1994                    "PSP-7 initial gen: parse_state={}, accepted={}",
1995                    record.parse_state,
1996                    record.accepted
1997                );
1998            }
1999
2000            match parse_state {
2001                perspt_core::types::ParseResultState::StrictJsonOk
2002                | perspt_core::types::ParseResultState::TolerantRecoveryOk => {
2003                    let bundle = bundle_opt.expect("Accepted parse must yield a bundle");
2004                    let affected_files: Vec<String> = bundle
2005                        .affected_paths()
2006                        .into_iter()
2007                        .map(ToString::to_string)
2008                        .collect();
2009                    log::info!(
2010                        "Parsed artifact bundle for node {} ({}): {} artifacts, {} commands",
2011                        node_id,
2012                        parse_state,
2013                        bundle.artifacts.len(),
2014                        bundle.commands.len()
2015                    );
2016                    self.emit_log(format!(
2017                        "📝 Bundle proposed: {} artifact(s) across {} file(s)",
2018                        bundle.artifacts.len(),
2019                        affected_files.len()
2020                    ));
2021
2022                    let approval_result = self
2023                        .await_approval_for_node(
2024                            perspt_core::ActionType::BundleWrite {
2025                                node_id: node_id.clone(),
2026                                files: affected_files.clone(),
2027                            },
2028                            format!("Apply bundle touching: {}", affected_files.join(", ")),
2029                            serde_json::to_string_pretty(&bundle).ok(),
2030                            Some(&node_id),
2031                        )
2032                        .await;
2033
2034                    if !matches!(
2035                        approval_result,
2036                        ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
2037                    ) {
2038                        self.emit_log("⏭️ Bundle application skipped (not approved)");
2039                        return Ok(());
2040                    }
2041
2042                    let node_class = self.graph[idx].node_class;
2043                    match self
2044                        .apply_bundle_transactionally(&bundle, &node_id, node_class)
2045                        .await
2046                    {
2047                        Ok(()) => {
2048                            self.last_tool_failure = None;
2049                            self.last_applied_bundle = Some(bundle.clone());
2050                        }
2051                        Err(e) => return Err(e),
2052                    }
2053
2054                    // PSP-5 Phase 9: Execute post-write commands from the effective bundle
2055                    let effective_commands = self
2056                        .last_applied_bundle
2057                        .as_ref()
2058                        .map(|b| b.commands.clone())
2059                        .unwrap_or_default();
2060                    if !effective_commands.is_empty() {
2061                        self.emit_log(format!(
2062                            "🔧 Executing {} bundle command(s)...",
2063                            effective_commands.len()
2064                        ));
2065                        let work_dir = self.effective_working_dir(idx);
2066                        let is_python = self.graph[idx].owner_plugin == "python";
2067                        for raw_command in &effective_commands {
2068                            let command = if is_python {
2069                                Self::normalize_command_to_uv(raw_command)
2070                            } else {
2071                                raw_command.clone()
2072                            };
2073
2074                            let cmd_approval = self
2075                                .await_approval_for_node(
2076                                    perspt_core::ActionType::Command {
2077                                        command: command.clone(),
2078                                    },
2079                                    format!("Execute bundle command: {}", command),
2080                                    None,
2081                                    Some(&node_id),
2082                                )
2083                                .await;
2084
2085                            if !matches!(
2086                                cmd_approval,
2087                                ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
2088                            ) {
2089                                self.emit_log(format!(
2090                                    "⏭️ Bundle command skipped (not approved): {}",
2091                                    command
2092                                ));
2093                                continue;
2094                            }
2095
2096                            let mut args = HashMap::new();
2097                            args.insert("command".to_string(), command.clone());
2098                            args.insert(
2099                                "working_dir".to_string(),
2100                                work_dir.to_string_lossy().to_string(),
2101                            );
2102
2103                            let call = ToolCall {
2104                                name: "run_command".to_string(),
2105                                arguments: args,
2106                            };
2107
2108                            let result = self.tools.execute(&call).await;
2109                            if result.success {
2110                                log::info!("✓ Bundle command succeeded: {}", command);
2111                                self.emit_log(format!("✅ {}", command));
2112                                if !result.output.is_empty() {
2113                                    let truncated: String =
2114                                        result.output.chars().take(500).collect();
2115                                    self.emit_log(truncated);
2116                                }
2117                            } else {
2118                                let err_msg = result.error.unwrap_or_else(|| result.output.clone());
2119                                log::warn!("Bundle command failed: {} — {}", command, err_msg);
2120                                self.emit_log(format!(
2121                                    "❌ Command failed: {} — {}",
2122                                    command, err_msg
2123                                ));
2124                                self.last_tool_failure = Some(format!(
2125                                    "Bundle command '{}' failed: {}",
2126                                    command, err_msg
2127                                ));
2128                            }
2129                        }
2130
2131                        if is_python {
2132                            log::info!("Running uv sync --dev after bundle commands...");
2133                            let sync_result = tokio::process::Command::new("uv")
2134                                .args(["sync", "--dev"])
2135                                .current_dir(&work_dir)
2136                                .stdout(std::process::Stdio::piped())
2137                                .stderr(std::process::Stdio::piped())
2138                                .output()
2139                                .await;
2140                            match sync_result {
2141                                Ok(output) if output.status.success() => {
2142                                    self.emit_log("🐍 uv sync --dev completed".to_string());
2143                                }
2144                                Ok(output) => {
2145                                    let stderr = String::from_utf8_lossy(&output.stderr);
2146                                    log::warn!("uv sync --dev failed: {}", stderr);
2147                                }
2148                                Err(e) => {
2149                                    log::warn!("Failed to run uv sync --dev: {}", e);
2150                                }
2151                            }
2152                        }
2153                    }
2154                }
2155
2156                perspt_core::types::ParseResultState::SemanticallyRejected => {
2157                    // PSP-7: Retarget — extract raw paths and retry with focused prompt
2158                    log::warn!(
2159                        "Bundle for '{}' semantically rejected, retrying with retarget prompt",
2160                        node_id
2161                    );
2162                    self.emit_log(format!(
2163                        "🔄 Bundle for '{}' targeted wrong files — retrying...",
2164                        node_id
2165                    ));
2166
2167                    let raw_paths: Vec<String> =
2168                        perspt_core::normalize::extract_file_markers(content)
2169                            .iter()
2170                            .filter_map(|m| m.path.clone())
2171                            .collect();
2172                    let expected: Vec<String> = self.graph[idx]
2173                        .output_targets
2174                        .iter()
2175                        .map(|p| p.to_string_lossy().to_string())
2176                        .collect();
2177                    let ev = perspt_core::types::PromptEvidence {
2178                        output_files: expected.clone(),
2179                        existing_file_contents: vec![(raw_paths.join(", "), prompt.clone())],
2180                        ..Default::default()
2181                    };
2182                    let retry_prompt = crate::prompt_compiler::compile(
2183                        perspt_core::types::PromptIntent::BundleRetarget,
2184                        &ev,
2185                    )
2186                    .text;
2187
2188                    let retry_response = self
2189                        .call_llm_with_logging(&model, &retry_prompt, Some(&node_id))
2190                        .await?;
2191
2192                    let (retry_bundle_opt, retry_state, _) =
2193                        self.parse_artifact_bundle_typed(&retry_response, &node_id, 1);
2194
2195                    if let Some(retry_bundle) = retry_bundle_opt {
2196                        let node_class = self.graph[idx].node_class;
2197                        self.apply_bundle_transactionally(&retry_bundle, &node_id, node_class)
2198                            .await?;
2199                        self.last_tool_failure = None;
2200                        self.last_applied_bundle = Some(retry_bundle);
2201                    } else {
2202                        return Err(anyhow::anyhow!(
2203                            "Retry for '{}' did not produce a valid bundle ({})",
2204                            node_id,
2205                            retry_state
2206                        ));
2207                    }
2208                }
2209
2210                _ => {
2211                    // NoStructuredPayload, SchemaInvalid, EmptyBundle
2212                    log::debug!(
2213                        "No artifact bundle found in response ({}), response length: {}",
2214                        parse_state,
2215                        content.len()
2216                    );
2217                    self.emit_log("ℹ️ No file changes detected in response".to_string());
2218                }
2219            }
2220        }
2221
2222        self.context.history.push(message);
2223        Ok(())
2224    }
2225
2226    /// Extract command from LLM response
2227    /// Looks for [COMMAND] pattern
2228    fn extract_command_from_response(&self, content: &str) -> Option<String> {
2229        for line in content.lines() {
2230            let trimmed = line.trim();
2231            if trimmed.starts_with("[COMMAND]") {
2232                return Some(trimmed.trim_start_matches("[COMMAND]").trim().to_string());
2233            }
2234            // Also support ```bash blocks with a command annotation
2235            if trimmed.starts_with("$ ") || trimmed.starts_with("➜ ") {
2236                return Some(
2237                    trimmed
2238                        .trim_start_matches("$ ")
2239                        .trim_start_matches("➜ ")
2240                        .trim()
2241                        .to_string(),
2242                );
2243            }
2244        }
2245        None
2246    }
2247
2248    // =========================================================================
2249    // PSP-5 Phase 5: Non-Convergence Classification and Repair
2250    // =========================================================================
2251
2252    /// Get the current session ID
2253    pub fn session_id(&self) -> &str {
2254        &self.context.session_id
2255    }
2256
2257    /// Get node count
2258    pub fn node_count(&self) -> usize {
2259        self.graph.node_count()
2260    }
2261
2262    /// Start LSP clients for the given plugin names.
2263    ///
2264    /// For each name, looks up the plugin's `LspConfig` (with fallback)
2265    /// and starts a client keyed by the plugin name.
2266    pub async fn start_lsp_for_plugins(&mut self, plugin_names: &[&str]) -> Result<()> {
2267        let registry = perspt_core::plugin::PluginRegistry::new();
2268
2269        for &name in plugin_names {
2270            if self.lsp_clients.contains_key(name) {
2271                log::debug!("LSP client already running for {}", name);
2272                continue;
2273            }
2274
2275            let plugin = match registry.get(name) {
2276                Some(p) => p,
2277                None => {
2278                    log::warn!("No plugin found for '{}', skipping LSP startup", name);
2279                    continue;
2280                }
2281            };
2282
2283            let profile = plugin.verifier_profile();
2284            let lsp_config = match profile.lsp.effective_config() {
2285                Some(cfg) => cfg.clone(),
2286                None => {
2287                    log::warn!(
2288                        "No available LSP for {} (primary and fallback unavailable)",
2289                        name
2290                    );
2291                    continue;
2292                }
2293            };
2294
2295            log::info!(
2296                "Starting LSP for {}: {} {:?}",
2297                name,
2298                lsp_config.server_binary,
2299                lsp_config.args
2300            );
2301
2302            let mut client = LspClient::from_config(&lsp_config);
2303            match client
2304                .start_with_config(&lsp_config, &self.context.working_dir)
2305                .await
2306            {
2307                Ok(()) => {
2308                    log::info!("{} LSP started successfully", name);
2309                    self.lsp_clients.insert(name.to_string(), client);
2310                }
2311                Err(e) => {
2312                    log::warn!(
2313                        "Failed to start {} LSP: {} (continuing without it)",
2314                        name,
2315                        e
2316                    );
2317                }
2318            }
2319        }
2320
2321        Ok(())
2322    }
2323
2324    /// Resolve the LSP client key for a given file path.
2325    ///
2326    /// Checks which registered plugin owns the file and returns its name,
2327    /// falling back to the first available LSP client.
2328    fn lsp_key_for_file(&self, path: &str) -> Option<String> {
2329        let registry = perspt_core::plugin::PluginRegistry::new();
2330
2331        // First, try to find a plugin that owns this file
2332        for plugin in registry.all() {
2333            if plugin.owns_file(path) {
2334                let name = plugin.name().to_string();
2335                if self.lsp_clients.contains_key(&name) {
2336                    return Some(name);
2337                }
2338            }
2339        }
2340
2341        // Fallback: return the first available client
2342        self.lsp_clients.keys().next().cloned()
2343    }
2344
2345    // =========================================================================
2346    // PSP-000005: Multi-Artifact Bundle Parsing & Application
2347    // =========================================================================
2348
2349    // =========================================================================
2350    // PSP-5 Phase 6: Provisional Branch Lifecycle
2351    // =========================================================================
2352
2353    /// Resolve the sandbox directory for a node that has a provisional branch.
2354    /// Returns `None` for root nodes or nodes without branches.
2355    fn sandbox_dir_for_node(&self, idx: NodeIndex) -> Option<std::path::PathBuf> {
2356        let branch_id = self.graph[idx].provisional_branch_id.as_ref()?;
2357        let sandbox_path = self
2358            .context
2359            .working_dir
2360            .join(".perspt")
2361            .join("sandboxes")
2362            .join(&self.context.session_id)
2363            .join(branch_id);
2364        if sandbox_path.exists() {
2365            Some(sandbox_path)
2366        } else {
2367            None
2368        }
2369    }
2370
2371    /// PSP-7: Lightweight sheaf pre-check before full sheaf validation.
2372    ///
2373    /// Verifies that every declared output target actually exists on disk and
2374    /// is non-empty, and that files contain real implementations rather than
2375    /// stub/placeholder content. Returns `Some(evidence)` if the pre-check
2376    /// fails, `None` if everything looks good.
2377    fn sheaf_pre_check(&self, idx: NodeIndex) -> Option<String> {
2378        let node = &self.graph[idx];
2379        if node.output_targets.is_empty() {
2380            return None;
2381        }
2382
2383        let work_dir = self.effective_working_dir(idx);
2384        let mut issues = Vec::new();
2385
2386        for path in &node.output_targets {
2387            let full = work_dir.join(path);
2388            match std::fs::metadata(&full) {
2389                Ok(m) if m.len() == 0 => {
2390                    issues.push(format!("empty: {}", path.display()));
2391                }
2392                Err(_) => {
2393                    issues.push(format!("missing: {}", path.display()));
2394                }
2395                Ok(_) => {
2396                    // Check for stub/placeholder content in existing non-empty files.
2397                    if let Some(reason) = detect_stub_content(&full, &node.owner_plugin) {
2398                        issues.push(format!("stub content in {}: {}", path.display(), reason));
2399                    }
2400                }
2401            }
2402        }
2403
2404        if issues.is_empty() {
2405            None
2406        } else {
2407            Some(format!("Output target issues: {}", issues.join(", ")))
2408        }
2409    }
2410
2411    /// Return the effective working directory for a node: sandbox if the node
2412    /// has an active provisional branch, otherwise the live workspace.
2413    fn effective_working_dir(&self, idx: NodeIndex) -> std::path::PathBuf {
2414        self.sandbox_dir_for_node(idx)
2415            .unwrap_or_else(|| self.context.working_dir.clone())
2416    }
2417
2418    /// Create a provisional branch if the node has graph parents (i.e., it
2419    /// depends on another node's output). Returns the branch ID if created.
2420    fn maybe_create_provisional_branch(&mut self, idx: NodeIndex) -> Option<String> {
2421        // Find incoming edges (parents this node depends on)
2422        let parents: Vec<NodeIndex> = self
2423            .graph
2424            .neighbors_directed(idx, petgraph::Direction::Incoming)
2425            .collect();
2426
2427        let node = &self.graph[idx];
2428        let node_id = node.node_id.clone();
2429        let session_id = self.context.session_id.clone();
2430
2431        // Root nodes and child nodes both get sandboxes.  Root nodes use
2432        // "root" as the parent identifier since they have no graph parent.
2433        let parent_node_id = if parents.is_empty() {
2434            "root".to_string()
2435        } else {
2436            self.graph[parents[0]].node_id.clone()
2437        };
2438
2439        let branch_id = format!("branch_{}_{}", node_id, uuid::Uuid::new_v4());
2440        let branch = ProvisionalBranch::new(
2441            branch_id.clone(),
2442            session_id.clone(),
2443            node_id.clone(),
2444            parent_node_id.clone(),
2445        );
2446
2447        // Persist via ledger
2448        if let Err(e) = self.ledger.record_provisional_branch(&branch) {
2449            log::warn!("Failed to record provisional branch: {}", e);
2450        }
2451
2452        // Record lineage edges for every parent (skipped for root nodes)
2453        for pidx in &parents {
2454            let parent_id = self.graph[*pidx].node_id.clone();
2455            // Determine if this parent is an Interface node (seal dependency)
2456            let depends_on_seal = self.graph[*pidx].node_class == NodeClass::Interface;
2457            let lineage = perspt_core::types::BranchLineage {
2458                lineage_id: format!("lin_{}_{}", branch_id, parent_id),
2459                parent_branch_id: parent_id,
2460                child_branch_id: branch_id.clone(),
2461                depends_on_seal,
2462            };
2463            if let Err(e) = self.ledger.record_branch_lineage(&lineage) {
2464                log::warn!("Failed to record branch lineage: {}", e);
2465            }
2466        }
2467
2468        // Store branch ID on the node for tracking
2469        self.graph[idx].provisional_branch_id = Some(branch_id.clone());
2470
2471        // PSP-5 Phase 6: Create sandbox workspace for this branch and seed it
2472        // with any existing files the node will read or modify.
2473        match crate::tools::create_sandbox(&self.context.working_dir, &session_id, &branch_id) {
2474            Ok(sandbox_path) => {
2475                log::debug!("Sandbox created at {}", sandbox_path.display());
2476
2477                // Seed sandbox with plugin-identified project manifests
2478                // (Cargo.toml, pyproject.toml, etc.) so build/test commands work.
2479                let plugin_refs: Vec<&str> = self
2480                    .context
2481                    .active_plugins
2482                    .iter()
2483                    .map(|s| s.as_str())
2484                    .collect();
2485                if let Err(e) = crate::tools::seed_sandbox_manifests(
2486                    &self.context.working_dir,
2487                    &sandbox_path,
2488                    &plugin_refs,
2489                ) {
2490                    log::warn!("Failed to seed sandbox manifests: {}", e);
2491                }
2492
2493                // Copy node's owned output targets into the sandbox so
2494                // verification and builds can find them.
2495                let node = &self.graph[idx];
2496                for target in &node.output_targets {
2497                    if let Some(rel) = target.to_str() {
2498                        if let Err(e) = crate::tools::copy_to_sandbox(
2499                            &self.context.working_dir,
2500                            &sandbox_path,
2501                            rel,
2502                        ) {
2503                            log::debug!("Could not seed sandbox with {}: {}", rel, e);
2504                        }
2505                    }
2506                }
2507                // Also copy output targets from ALL ancestors (not just
2508                // direct parents) so transitive dependencies are available.
2509                // e.g. task_test_solver depends on task_solver which depends
2510                // on task_cfd_core — the solver test sandbox needs cfd-core
2511                // source files to build.
2512                let mut ancestor_queue: Vec<NodeIndex> = parents.clone();
2513                let mut visited = std::collections::HashSet::new();
2514                while let Some(ancestor_idx) = ancestor_queue.pop() {
2515                    if !visited.insert(ancestor_idx) {
2516                        continue;
2517                    }
2518                    for target in &self.graph[ancestor_idx].output_targets {
2519                        if let Some(rel) = target.to_str() {
2520                            if let Err(e) = crate::tools::copy_to_sandbox(
2521                                &self.context.working_dir,
2522                                &sandbox_path,
2523                                rel,
2524                            ) {
2525                                log::debug!(
2526                                    "Could not seed sandbox with ancestor file {}: {}",
2527                                    rel,
2528                                    e
2529                                );
2530                            }
2531                        }
2532                    }
2533                    // Walk further up the graph
2534                    for grandparent in self
2535                        .graph
2536                        .neighbors_directed(ancestor_idx, petgraph::Direction::Incoming)
2537                    {
2538                        ancestor_queue.push(grandparent);
2539                    }
2540                }
2541            }
2542            Err(e) => {
2543                log::warn!("Failed to create sandbox for branch {}: {}", branch_id, e);
2544            }
2545        }
2546
2547        self.emit_event(perspt_core::AgentEvent::BranchCreated {
2548            branch_id: branch_id.clone(),
2549            node_id,
2550            parent_node_id,
2551        });
2552        log::info!("Created provisional branch {} for node", branch_id);
2553
2554        Some(branch_id)
2555    }
2556
2557    /// Merge a provisional branch after successful commit.
2558    fn merge_provisional_branch(&mut self, branch_id: &str, idx: NodeIndex) {
2559        let node_id = self.graph[idx].node_id.clone();
2560        if let Err(e) = self
2561            .ledger
2562            .update_branch_state(branch_id, &ProvisionalBranchState::Merged.to_string())
2563        {
2564            log::warn!("Failed to merge branch {}: {}", branch_id, e);
2565        }
2566
2567        // Clean up sandbox directory — artifacts were already exported in step_commit
2568        let sandbox_path = self
2569            .context
2570            .working_dir
2571            .join(".perspt")
2572            .join("sandboxes")
2573            .join(&self.context.session_id)
2574            .join(branch_id);
2575        if let Err(e) = crate::tools::cleanup_sandbox(&sandbox_path) {
2576            log::warn!(
2577                "Failed to cleanup sandbox for merged branch {}: {}",
2578                branch_id,
2579                e
2580            );
2581        }
2582
2583        self.emit_event(perspt_core::AgentEvent::BranchMerged {
2584            branch_id: branch_id.to_string(),
2585            node_id,
2586        });
2587        log::info!("Merged provisional branch {}", branch_id);
2588    }
2589
2590    /// Flush a provisional branch on escalation / non-convergence.
2591    fn flush_provisional_branch(&mut self, branch_id: &str, node_id: &str) {
2592        if let Err(e) = self
2593            .ledger
2594            .update_branch_state(branch_id, &ProvisionalBranchState::Flushed.to_string())
2595        {
2596            log::warn!("Failed to flush branch {}: {}", branch_id, e);
2597        }
2598
2599        // Clean up sandbox directory — speculative work is discarded
2600        let sandbox_path = self
2601            .context
2602            .working_dir
2603            .join(".perspt")
2604            .join("sandboxes")
2605            .join(&self.context.session_id)
2606            .join(branch_id);
2607        if let Err(e) = crate::tools::cleanup_sandbox(&sandbox_path) {
2608            log::warn!(
2609                "Failed to cleanup sandbox for flushed branch {}: {}",
2610                branch_id,
2611                e
2612            );
2613        }
2614
2615        log::info!(
2616            "Flushed provisional branch {} for node {}",
2617            branch_id,
2618            node_id
2619        );
2620    }
2621
2622    /// Flush all descendant provisional branches when a parent node fails.
2623    ///
2624    /// Walks the DAG outward from `idx`, finds all child nodes that have
2625    /// active provisional branches, flushes them, and persists a
2626    /// BranchFlushRecord documenting the cascade.
2627    fn flush_descendant_branches(&mut self, idx: NodeIndex) {
2628        let parent_node_id = self.graph[idx].node_id.clone();
2629        let session_id = self.context.session_id.clone();
2630
2631        // Collect all transitive dependents
2632        let descendant_indices = self.collect_descendants(idx);
2633
2634        let mut flushed_branch_ids = Vec::new();
2635        let mut requeue_node_ids = Vec::new();
2636
2637        for desc_idx in &descendant_indices {
2638            let desc_node = &self.graph[*desc_idx];
2639            if let Some(ref bid) = desc_node.provisional_branch_id {
2640                // Flush the branch
2641                let bid_clone = bid.clone();
2642                let nid_clone = desc_node.node_id.clone();
2643                self.flush_provisional_branch(&bid_clone, &nid_clone);
2644                flushed_branch_ids.push(bid_clone);
2645                requeue_node_ids.push(nid_clone);
2646            }
2647        }
2648
2649        if flushed_branch_ids.is_empty() {
2650            return;
2651        }
2652
2653        // Persist the flush decision
2654        let flush_record = perspt_core::types::BranchFlushRecord::new(
2655            &session_id,
2656            &parent_node_id,
2657            flushed_branch_ids.clone(),
2658            requeue_node_ids.clone(),
2659            format!(
2660                "Parent node {} failed verification/convergence",
2661                parent_node_id
2662            ),
2663        );
2664        if let Err(e) = self.ledger.record_branch_flush(&flush_record) {
2665            log::warn!("Failed to record branch flush: {}", e);
2666        }
2667
2668        self.emit_event(perspt_core::AgentEvent::BranchFlushed {
2669            parent_node_id: parent_node_id.clone(),
2670            flushed_branch_ids,
2671            reason: format!("Parent {} failed", parent_node_id),
2672        });
2673
2674        log::info!(
2675            "Flushed {} descendant branches for parent {}; {} nodes eligible for requeue",
2676            flush_record.flushed_branch_ids.len(),
2677            parent_node_id,
2678            requeue_node_ids.len(),
2679        );
2680    }
2681
2682    /// Collect all transitive dependent node indices reachable from `idx`
2683    /// via outgoing edges (children, grandchildren, etc.).
2684    fn collect_descendants(&self, idx: NodeIndex) -> Vec<NodeIndex> {
2685        let mut descendants = Vec::new();
2686        let mut stack = vec![idx];
2687        let mut visited = std::collections::HashSet::new();
2688        visited.insert(idx);
2689
2690        while let Some(current) = stack.pop() {
2691            for child in self
2692                .graph
2693                .neighbors_directed(current, petgraph::Direction::Outgoing)
2694            {
2695                if visited.insert(child) {
2696                    descendants.push(child);
2697                    stack.push(child);
2698                }
2699            }
2700        }
2701        descendants
2702    }
2703
2704    /// Emit interface seals from an Interface-class node's output artifacts.
2705    ///
2706    /// Called during step_commit for nodes whose `node_class` is `Interface`.
2707    /// Computes structural digests of owned output files and persists seal
2708    /// records so dependent nodes can assemble context from sealed interfaces.
2709    fn emit_interface_seals(&mut self, idx: NodeIndex) {
2710        let node = &self.graph[idx];
2711        if node.node_class != NodeClass::Interface {
2712            return;
2713        }
2714
2715        let node_id = node.node_id.clone();
2716        let session_id = self.context.session_id.clone();
2717        let output_targets: Vec<_> = node.output_targets.clone();
2718        let mut sealed_paths = Vec::new();
2719        let mut seal_hash = [0u8; 32];
2720
2721        let retriever = ContextRetriever::new(self.context.working_dir.clone());
2722
2723        for target in &output_targets {
2724            let path_str = target.to_string_lossy().to_string();
2725            match retriever.compute_structural_digest(
2726                &path_str,
2727                perspt_core::types::ArtifactKind::InterfaceSeal,
2728                &node_id,
2729            ) {
2730                Ok(digest) => {
2731                    let seal = perspt_core::types::InterfaceSealRecord::from_digest(
2732                        &session_id,
2733                        &node_id,
2734                        &digest,
2735                    );
2736                    seal_hash = seal.seal_hash;
2737                    sealed_paths.push(path_str);
2738
2739                    if let Err(e) = self.ledger.record_interface_seal(&seal) {
2740                        log::warn!("Failed to record interface seal: {}", e);
2741                    }
2742                }
2743                Err(e) => {
2744                    log::debug!("Skipping seal for {}: {}", path_str, e);
2745                }
2746            }
2747        }
2748
2749        if !sealed_paths.is_empty() {
2750            // Store seal hash on the node
2751            self.graph[idx].interface_seal_hash = Some(seal_hash);
2752
2753            self.emit_event(perspt_core::AgentEvent::InterfaceSealed {
2754                node_id: node_id.clone(),
2755                sealed_paths: sealed_paths.clone(),
2756                seal_hash: seal_hash
2757                    .iter()
2758                    .map(|b| format!("{:02x}", b))
2759                    .collect::<String>(),
2760            });
2761            log::info!(
2762                "Sealed {} interface artifact(s) for node {}",
2763                sealed_paths.len(),
2764                node_id
2765            );
2766        }
2767    }
2768
2769    /// Unblock child nodes that were waiting on this node's interface seal.
2770    fn unblock_dependents(&mut self, idx: NodeIndex) {
2771        let node_id = self.graph[idx].node_id.clone();
2772
2773        // Drain blocked dependencies that match this parent
2774        let (unblocked, remaining): (Vec<_>, Vec<_>) = self
2775            .blocked_dependencies
2776            .drain(..)
2777            .partition(|dep| dep.parent_node_id == node_id);
2778
2779        self.blocked_dependencies = remaining;
2780
2781        for dep in unblocked {
2782            self.emit_event(perspt_core::AgentEvent::DependentUnblocked {
2783                child_node_id: dep.child_node_id.clone(),
2784                parent_node_id: node_id.clone(),
2785            });
2786            log::info!(
2787                "Unblocked dependent {} (parent {} sealed)",
2788                dep.child_node_id,
2789                node_id
2790            );
2791        }
2792    }
2793
2794    /// Check whether a node should be blocked because a parent Interface node
2795    /// has not yet produced a seal.  Returns `true` if the node is blocked.
2796    fn check_seal_prerequisites(&mut self, idx: NodeIndex) -> bool {
2797        let parents: Vec<NodeIndex> = self
2798            .graph
2799            .neighbors_directed(idx, petgraph::Direction::Incoming)
2800            .collect();
2801
2802        for pidx in parents {
2803            let parent = &self.graph[pidx];
2804            if parent.node_class == NodeClass::Interface
2805                && parent.interface_seal_hash.is_none()
2806                && parent.state != NodeState::Completed
2807            {
2808                // Parent Interface node hasn't sealed yet — block this child
2809                let child_node_id = self.graph[idx].node_id.clone();
2810                let parent_node_id = parent.node_id.clone();
2811                let sealed_paths: Vec<String> = parent
2812                    .output_targets
2813                    .iter()
2814                    .map(|p| p.to_string_lossy().to_string())
2815                    .collect();
2816
2817                let dep = perspt_core::types::BlockedDependency::new(
2818                    &child_node_id,
2819                    &parent_node_id,
2820                    sealed_paths,
2821                );
2822                self.blocked_dependencies.push(dep);
2823
2824                log::info!(
2825                    "Node {} blocked: waiting on interface seal from {}",
2826                    child_node_id,
2827                    parent_node_id
2828                );
2829                return true;
2830            }
2831        }
2832        false
2833    }
2834
2835    /// PSP-5 Phase 3: Check that required structural dependencies have
2836    /// machine-verifiable digests, not just prose summaries.
2837    ///
2838    /// Returns a list of (dependency_node_id, reason) for dependencies that
2839    /// only have semantic/advisory summaries with no structural evidence.
2840    fn check_structural_dependencies(
2841        &self,
2842        node: &SRBNNode,
2843        restriction_map: &perspt_core::types::RestrictionMap,
2844    ) -> Vec<(String, String)> {
2845        use perspt_core::types::{ArtifactKind, NodeClass};
2846
2847        let mut prose_only = Vec::new();
2848
2849        // Only enforce for Implementation nodes that depend on Interface nodes
2850        if node.node_class != NodeClass::Implementation {
2851            return prose_only;
2852        }
2853
2854        // Collect parent Interface node IDs from the DAG
2855        let idx = match self.node_indices.get(&node.node_id) {
2856            Some(i) => *i,
2857            None => return prose_only,
2858        };
2859
2860        let parents: Vec<NodeIndex> = self
2861            .graph
2862            .neighbors_directed(idx, petgraph::Direction::Incoming)
2863            .collect();
2864
2865        for pidx in parents {
2866            let parent = &self.graph[pidx];
2867            if parent.node_class != NodeClass::Interface {
2868                continue;
2869            }
2870
2871            // Check if we have at least one structural digest from this parent
2872            let has_structural = restriction_map.structural_digests.iter().any(|d| {
2873                d.source_node_id == parent.node_id
2874                    && matches!(
2875                        d.artifact_kind,
2876                        ArtifactKind::Signature
2877                            | ArtifactKind::Schema
2878                            | ArtifactKind::InterfaceSeal
2879                    )
2880            });
2881
2882            if !has_structural {
2883                prose_only.push((
2884                    parent.node_id.clone(),
2885                    format!(
2886                        "Interface node '{}' has no Signature/Schema/InterfaceSeal digest in the restriction map",
2887                        parent.node_id
2888                    ),
2889                ));
2890            }
2891        }
2892
2893        prose_only
2894    }
2895
2896    /// Inject sealed interface digests from parent nodes into a restriction map.
2897    ///
2898    /// For each parent that has a recorded interface seal in the ledger, replace
2899    /// the mutable file reference in the sealed_interfaces list with a
2900    /// structural digest derived from the persisted seal.  This ensures the
2901    /// child context is assembled from immutable sealed data.
2902    fn inject_sealed_interfaces(
2903        &self,
2904        idx: NodeIndex,
2905        restriction_map: &mut perspt_core::types::RestrictionMap,
2906    ) {
2907        let parents: Vec<NodeIndex> = self
2908            .graph
2909            .neighbors_directed(idx, petgraph::Direction::Incoming)
2910            .collect();
2911
2912        for pidx in parents {
2913            let parent = &self.graph[pidx];
2914            if parent.interface_seal_hash.is_none() {
2915                continue;
2916            }
2917
2918            let parent_node_id = &parent.node_id;
2919
2920            // Query persisted seal records for this parent
2921            let seals = match self.ledger.get_interface_seals(parent_node_id) {
2922                Ok(rows) => rows,
2923                Err(e) => {
2924                    log::debug!("Could not query seals for {}: {}", parent_node_id, e);
2925                    continue;
2926                }
2927            };
2928
2929            for seal in seals {
2930                // Remove the path from sealed_interfaces (it will be replaced by digest)
2931                restriction_map
2932                    .sealed_interfaces
2933                    .retain(|p| *p != seal.sealed_path);
2934
2935                // Convert Vec<u8> seal_hash to [u8; 32]
2936                let mut hash = [0u8; 32];
2937                let len = seal.seal_hash.len().min(32);
2938                hash[..len].copy_from_slice(&seal.seal_hash[..len]);
2939
2940                // Add a structural digest instead
2941                let digest = perspt_core::types::StructuralDigest {
2942                    digest_id: format!("seal_{}_{}", seal.node_id, seal.sealed_path),
2943                    source_node_id: seal.node_id.clone(),
2944                    source_path: seal.sealed_path.clone(),
2945                    artifact_kind: perspt_core::types::ArtifactKind::InterfaceSeal,
2946                    hash,
2947                    version: seal.version as u32,
2948                };
2949                restriction_map.structural_digests.push(digest);
2950
2951                log::debug!(
2952                    "Injected sealed digest for {} from parent {}",
2953                    seal.sealed_path,
2954                    parent_node_id,
2955                );
2956            }
2957        }
2958    }
2959}
2960
2961/// Parse a persisted state string back into a NodeState enum
2962fn parse_node_state(s: &str) -> NodeState {
2963    NodeState::from_display_str(s)
2964}
2965
2966/// Parse a persisted node class string back into a NodeClass enum
2967fn parse_node_class(s: &str) -> NodeClass {
2968    match s {
2969        "Interface" => NodeClass::Interface,
2970        "Implementation" => NodeClass::Implementation,
2971        "Integration" => NodeClass::Integration,
2972        _ => NodeClass::default(),
2973    }
2974}
2975
2976#[cfg(test)]
2977mod tests {
2978    use super::verification::verification_stages_for_node;
2979    use super::*;
2980    use std::path::PathBuf;
2981
2982    #[tokio::test]
2983    async fn test_orchestrator_creation() {
2984        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
2985        assert_eq!(orch.node_count(), 0);
2986    }
2987
2988    #[tokio::test]
2989    async fn test_add_nodes() {
2990        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
2991
2992        let node1 = SRBNNode::new(
2993            "node1".to_string(),
2994            "Test task 1".to_string(),
2995            ModelTier::Architect,
2996        );
2997        let node2 = SRBNNode::new(
2998            "node2".to_string(),
2999            "Test task 2".to_string(),
3000            ModelTier::Actuator,
3001        );
3002
3003        orch.add_node(node1);
3004        orch.add_node(node2);
3005        orch.add_dependency("node1", "node2", "depends_on").unwrap();
3006
3007        assert_eq!(orch.node_count(), 2);
3008    }
3009    #[tokio::test]
3010    async fn test_lsp_key_for_file_resolves_by_plugin() {
3011        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3012        // Insert a dummy LSP client key so the lookup has something to match
3013        orch.lsp_clients.insert(
3014            "rust".to_string(),
3015            crate::lsp::LspClient::new("rust-analyzer"),
3016        );
3017        orch.lsp_clients
3018            .insert("python".to_string(), crate::lsp::LspClient::new("pylsp"));
3019
3020        // Rust plugin owns .rs files
3021        assert_eq!(
3022            orch.lsp_key_for_file("src/main.rs"),
3023            Some("rust".to_string())
3024        );
3025        // Python plugin owns .py files
3026        assert_eq!(orch.lsp_key_for_file("app.py"), Some("python".to_string()));
3027        // Unknown extension falls back to first available client
3028        let key = orch.lsp_key_for_file("data.csv");
3029        assert!(key.is_some()); // Falls back to first available
3030    }
3031
3032    // =========================================================================
3033    // Phase 5: Graph rewrite & sheaf validator tests
3034    // =========================================================================
3035
3036    #[tokio::test]
3037    async fn test_split_node_creates_children() {
3038        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3039        let mut node = SRBNNode::new("parent".into(), "Do everything".into(), ModelTier::Actuator);
3040        node.output_targets = vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")];
3041        orch.add_node(node);
3042
3043        let idx = orch.node_indices["parent"];
3044        let applied = orch.split_node(idx, &["handle a.rs".into(), "handle b.rs".into()]);
3045        assert!(!applied.is_empty());
3046        // Parent should be gone
3047        assert!(!orch.node_indices.contains_key("parent"));
3048        // Two children should exist
3049        assert!(orch.node_indices.contains_key("parent__split_0"));
3050        assert!(orch.node_indices.contains_key("parent__split_1"));
3051    }
3052
3053    #[tokio::test]
3054    async fn test_split_node_empty_children_is_noop() {
3055        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3056        let node = SRBNNode::new("n".into(), "g".into(), ModelTier::Actuator);
3057        orch.add_node(node);
3058        let idx = orch.node_indices["n"];
3059        let applied = orch.split_node(idx, &[]);
3060        // Should not apply — return empty vec but not panic
3061        assert!(applied.is_empty());
3062    }
3063
3064    #[tokio::test]
3065    async fn test_insert_interface_node() {
3066        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3067        let n1 = SRBNNode::new("a".into(), "source".into(), ModelTier::Actuator);
3068        let n2 = SRBNNode::new("b".into(), "dest".into(), ModelTier::Actuator);
3069        orch.add_node(n1);
3070        orch.add_node(n2);
3071        orch.add_dependency("a", "b", "data_flow").unwrap();
3072
3073        let idx_a = orch.node_indices["a"];
3074        let applied = orch.insert_interface_node(idx_a, "API boundary");
3075        assert!(applied.is_some());
3076        assert!(orch.node_indices.contains_key("a__iface"));
3077        // Should now have 3 nodes
3078        assert_eq!(orch.node_count(), 3);
3079    }
3080
3081    #[tokio::test]
3082    async fn test_replan_subgraph_resets_nodes() {
3083        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3084        let mut n1 = SRBNNode::new("trigger".into(), "g1".into(), ModelTier::Actuator);
3085        n1.state = NodeState::Coding;
3086        let mut n2 = SRBNNode::new("dep".into(), "g2".into(), ModelTier::Actuator);
3087        n2.state = NodeState::Completed;
3088        orch.add_node(n1);
3089        orch.add_node(n2);
3090
3091        let trigger_idx = orch.node_indices["trigger"];
3092        let applied = orch.replan_subgraph(trigger_idx, &["dep".into()]);
3093        assert!(applied);
3094
3095        let dep_idx = orch.node_indices["dep"];
3096        assert_eq!(orch.graph[dep_idx].state, NodeState::TaskQueued);
3097        assert_eq!(orch.graph[trigger_idx].state, NodeState::Retry);
3098    }
3099
3100    #[tokio::test]
3101    async fn test_select_validators_always_includes_dependency_graph() {
3102        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3103        let node = SRBNNode::new("n".into(), "g".into(), ModelTier::Actuator);
3104        orch.add_node(node);
3105        let idx = orch.node_indices["n"];
3106
3107        let validators = orch.select_validators(idx);
3108        assert!(validators.contains(&SheafValidatorClass::DependencyGraphConsistency));
3109    }
3110
3111    #[tokio::test]
3112    async fn test_select_validators_interface_node() {
3113        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3114        let mut node = SRBNNode::new("iface".into(), "g".into(), ModelTier::Actuator);
3115        node.node_class = perspt_core::types::NodeClass::Interface;
3116        orch.add_node(node);
3117        let idx = orch.node_indices["iface"];
3118
3119        let validators = orch.select_validators(idx);
3120        assert!(validators.contains(&SheafValidatorClass::ExportImportConsistency));
3121    }
3122
3123    #[tokio::test]
3124    async fn test_run_sheaf_validator_dependency_graph_no_cycles() {
3125        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3126        let n1 = SRBNNode::new("a".into(), "g".into(), ModelTier::Actuator);
3127        let n2 = SRBNNode::new("b".into(), "g".into(), ModelTier::Actuator);
3128        orch.add_node(n1);
3129        orch.add_node(n2);
3130        orch.add_dependency("a", "b", "dep").unwrap();
3131
3132        let idx = orch.node_indices["a"];
3133        let result = orch.run_sheaf_validator(idx, SheafValidatorClass::DependencyGraphConsistency);
3134        assert!(result.passed);
3135        assert_eq!(result.v_sheaf_contribution, 0.0);
3136    }
3137
3138    #[tokio::test]
3139    async fn test_classify_non_convergence_default() {
3140        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3141        let node = SRBNNode::new("n".into(), "g".into(), ModelTier::Actuator);
3142        orch.add_node(node);
3143        let idx = orch.node_indices["n"];
3144
3145        // With no verification results or policy failures, should default to ImplementationError
3146        let category = orch.classify_non_convergence(idx);
3147        assert_eq!(category, EscalationCategory::ImplementationError);
3148    }
3149
3150    #[tokio::test]
3151    async fn test_affected_dependents() {
3152        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3153        let n1 = SRBNNode::new("root".into(), "g".into(), ModelTier::Actuator);
3154        let n2 = SRBNNode::new("child1".into(), "g".into(), ModelTier::Actuator);
3155        let n3 = SRBNNode::new("child2".into(), "g".into(), ModelTier::Actuator);
3156        orch.add_node(n1);
3157        orch.add_node(n2);
3158        orch.add_node(n3);
3159        orch.add_dependency("root", "child1", "dep").unwrap();
3160        orch.add_dependency("root", "child2", "dep").unwrap();
3161
3162        let idx = orch.node_indices["root"];
3163        let deps = orch.affected_dependents(idx);
3164        assert_eq!(deps.len(), 2);
3165        assert!(deps.contains(&"child1".to_string()));
3166        assert!(deps.contains(&"child2".to_string()));
3167    }
3168
3169    // =========================================================================
3170    // PSP-5 Phase 6: Provisional Branch Tests
3171    // =========================================================================
3172
3173    #[tokio::test]
3174    async fn test_maybe_create_provisional_branch_root_node() {
3175        let temp_dir =
3176            std::env::temp_dir().join(format!("perspt_root_branch_{}", uuid::Uuid::new_v4()));
3177        std::fs::create_dir_all(&temp_dir).unwrap();
3178
3179        let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
3180        orch.context.session_id = "test_session".into();
3181        let node = SRBNNode::new("root".into(), "root goal".into(), ModelTier::Actuator);
3182        orch.add_node(node);
3183
3184        let idx = orch.node_indices["root"];
3185        // Root nodes now also get a provisional branch with sandbox
3186        let branch = orch.maybe_create_provisional_branch(idx);
3187        assert!(branch.is_some());
3188        assert!(orch.graph[idx].provisional_branch_id.is_some());
3189
3190        let _ = std::fs::remove_dir_all(&temp_dir);
3191    }
3192
3193    #[tokio::test]
3194    async fn test_maybe_create_provisional_branch_child_node() {
3195        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_phase6"));
3196        orch.context.session_id = "test_session".into();
3197        let parent = SRBNNode::new("parent".into(), "parent goal".into(), ModelTier::Actuator);
3198        let child = SRBNNode::new("child".into(), "child goal".into(), ModelTier::Actuator);
3199        orch.add_node(parent);
3200        orch.add_node(child);
3201        orch.add_dependency("parent", "child", "dep").unwrap();
3202
3203        let idx = orch.node_indices["child"];
3204        let branch = orch.maybe_create_provisional_branch(idx);
3205        assert!(branch.is_some());
3206        assert!(orch.graph[idx].provisional_branch_id.is_some());
3207    }
3208
3209    #[tokio::test]
3210    async fn test_collect_descendants() {
3211        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3212        let n1 = SRBNNode::new("a".into(), "g".into(), ModelTier::Actuator);
3213        let n2 = SRBNNode::new("b".into(), "g".into(), ModelTier::Actuator);
3214        let n3 = SRBNNode::new("c".into(), "g".into(), ModelTier::Actuator);
3215        let n4 = SRBNNode::new("d".into(), "g".into(), ModelTier::Actuator);
3216        orch.add_node(n1);
3217        orch.add_node(n2);
3218        orch.add_node(n3);
3219        orch.add_node(n4);
3220        orch.add_dependency("a", "b", "dep").unwrap();
3221        orch.add_dependency("b", "c", "dep").unwrap();
3222        orch.add_dependency("a", "d", "dep").unwrap();
3223
3224        let idx_a = orch.node_indices["a"];
3225        let descendants = orch.collect_descendants(idx_a);
3226        assert_eq!(descendants.len(), 3); // b, c, d
3227    }
3228
3229    #[tokio::test]
3230    async fn test_check_seal_prerequisites_no_interface_parent() {
3231        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3232        let parent = SRBNNode::new("parent".into(), "g".into(), ModelTier::Actuator);
3233        let child = SRBNNode::new("child".into(), "g".into(), ModelTier::Actuator);
3234        orch.add_node(parent);
3235        orch.add_node(child);
3236        orch.add_dependency("parent", "child", "dep").unwrap();
3237
3238        let idx = orch.node_indices["child"];
3239        // Parent is Implementation (default), not Interface — should not block
3240        assert!(!orch.check_seal_prerequisites(idx));
3241        assert!(orch.blocked_dependencies.is_empty());
3242    }
3243
3244    #[tokio::test]
3245    async fn test_check_seal_prerequisites_unsealed_interface() {
3246        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3247        let mut parent = SRBNNode::new("iface".into(), "g".into(), ModelTier::Actuator);
3248        parent.node_class = perspt_core::types::NodeClass::Interface;
3249        let child = SRBNNode::new("impl".into(), "g".into(), ModelTier::Actuator);
3250        orch.add_node(parent);
3251        orch.add_node(child);
3252        orch.add_dependency("iface", "impl", "dep").unwrap();
3253
3254        let idx = orch.node_indices["impl"];
3255        // Interface parent not sealed and not completed — should block
3256        assert!(orch.check_seal_prerequisites(idx));
3257        assert_eq!(orch.blocked_dependencies.len(), 1);
3258        assert_eq!(orch.blocked_dependencies[0].parent_node_id, "iface");
3259    }
3260
3261    #[tokio::test]
3262    async fn test_check_seal_prerequisites_sealed_interface() {
3263        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3264        let mut parent = SRBNNode::new("iface".into(), "g".into(), ModelTier::Actuator);
3265        parent.node_class = perspt_core::types::NodeClass::Interface;
3266        parent.interface_seal_hash = Some([1u8; 32]); // Already sealed
3267        let child = SRBNNode::new("impl".into(), "g".into(), ModelTier::Actuator);
3268        orch.add_node(parent);
3269        orch.add_node(child);
3270        orch.add_dependency("iface", "impl", "dep").unwrap();
3271
3272        let idx = orch.node_indices["impl"];
3273        // Interface parent is sealed — should not block
3274        assert!(!orch.check_seal_prerequisites(idx));
3275        assert!(orch.blocked_dependencies.is_empty());
3276    }
3277
3278    #[tokio::test]
3279    async fn test_unblock_dependents() {
3280        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3281        let parent = SRBNNode::new("parent".into(), "g".into(), ModelTier::Actuator);
3282        let child = SRBNNode::new("child".into(), "g".into(), ModelTier::Actuator);
3283        orch.add_node(parent);
3284        orch.add_node(child);
3285
3286        // Manually add a blocked dependency
3287        orch.blocked_dependencies
3288            .push(perspt_core::types::BlockedDependency::new(
3289                "child",
3290                "parent",
3291                vec!["src/api.rs".into()],
3292            ));
3293        assert_eq!(orch.blocked_dependencies.len(), 1);
3294
3295        let idx = orch.node_indices["parent"];
3296        orch.unblock_dependents(idx);
3297        assert!(orch.blocked_dependencies.is_empty());
3298    }
3299
3300    #[tokio::test]
3301    async fn test_flush_descendant_branches() {
3302        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_phase6_flush"));
3303        orch.context.session_id = "test_session".into();
3304
3305        let parent = SRBNNode::new("parent".into(), "g".into(), ModelTier::Actuator);
3306        let mut child1 = SRBNNode::new("child1".into(), "g".into(), ModelTier::Actuator);
3307        child1.provisional_branch_id = Some("branch_c1".into());
3308        let mut child2 = SRBNNode::new("child2".into(), "g".into(), ModelTier::Actuator);
3309        child2.provisional_branch_id = Some("branch_c2".into());
3310        let grandchild = SRBNNode::new("grandchild".into(), "g".into(), ModelTier::Actuator);
3311        orch.add_node(parent);
3312        orch.add_node(child1);
3313        orch.add_node(child2);
3314        orch.add_node(grandchild);
3315        orch.add_dependency("parent", "child1", "dep").unwrap();
3316        orch.add_dependency("parent", "child2", "dep").unwrap();
3317        orch.add_dependency("child1", "grandchild", "dep").unwrap();
3318
3319        let idx = orch.node_indices["parent"];
3320        // This will try to flush branches but ledger may not find them —
3321        // the important thing is it doesn't panic and traverses correctly
3322        orch.flush_descendant_branches(idx);
3323    }
3324
3325    // =========================================================================
3326    // PSP-5 Completion Tests
3327    // =========================================================================
3328
3329    #[tokio::test]
3330    async fn test_effective_working_dir_no_branch() {
3331        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/test/workspace"));
3332        // No nodes, but we can test the helper directly by adding one
3333        let mut orch = orch;
3334        let node = SRBNNode::new("n1".into(), "goal".into(), ModelTier::Actuator);
3335        orch.add_node(node);
3336        let idx = orch.node_indices["n1"];
3337        // No provisional branch → returns live workspace
3338        assert_eq!(
3339            orch.effective_working_dir(idx),
3340            PathBuf::from("/test/workspace")
3341        );
3342    }
3343
3344    #[tokio::test]
3345    async fn test_sandbox_dir_for_node_none_without_branch() {
3346        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/test/workspace"));
3347        let mut orch = orch;
3348        let node = SRBNNode::new("n1".into(), "goal".into(), ModelTier::Actuator);
3349        orch.add_node(node);
3350        let idx = orch.node_indices["n1"];
3351        assert!(orch.sandbox_dir_for_node(idx).is_none());
3352    }
3353
3354    #[tokio::test]
3355    async fn test_rewrite_churn_guardrail() {
3356        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_churn"));
3357        let mut orch = orch;
3358        let node = SRBNNode::new("node_a".into(), "goal".into(), ModelTier::Actuator);
3359        orch.add_node(node);
3360        // count_lineage_rewrites should return 0 for a fresh node
3361        let count = orch.count_lineage_rewrites("node_a");
3362        assert_eq!(count, 0);
3363    }
3364
3365    #[tokio::test]
3366    async fn test_run_resumed_skips_terminal_nodes() {
3367        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_resume"));
3368
3369        let mut n1 = SRBNNode::new("done".into(), "completed".into(), ModelTier::Actuator);
3370        n1.state = NodeState::Completed;
3371        let mut n2 = SRBNNode::new("failed".into(), "failed".into(), ModelTier::Actuator);
3372        n2.state = NodeState::Failed;
3373        orch.add_node(n1);
3374        orch.add_node(n2);
3375
3376        // Both nodes are terminal, so run_resumed should do nothing and succeed
3377        let result = orch.run_resumed().await;
3378        assert!(result.is_ok());
3379    }
3380
3381    #[tokio::test]
3382    async fn test_persist_review_decision_no_panic() {
3383        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_review"));
3384        // Should not panic even without a real ledger session —
3385        // it gracefully logs errors
3386        orch.persist_review_decision("node_x", "approved", None);
3387    }
3388
3389    // =========================================================================
3390    // PSP-5 Gap Tests
3391    // =========================================================================
3392
3393    #[tokio::test]
3394    async fn test_check_structural_dependencies_blocks_prose_only() {
3395        use perspt_core::types::{NodeClass, RestrictionMap};
3396
3397        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_struct_dep"));
3398
3399        // Parent: Interface node (no structural digests)
3400        let mut parent = SRBNNode::new("iface_1".into(), "Define API".into(), ModelTier::Architect);
3401        parent.node_class = NodeClass::Interface;
3402
3403        // Child: Implementation node depending on the interface
3404        let mut child = SRBNNode::new("impl_1".into(), "Implement API".into(), ModelTier::Actuator);
3405        child.node_class = NodeClass::Implementation;
3406
3407        let parent_idx = orch.add_node(parent);
3408        let child_idx = orch.add_node(child.clone());
3409        orch.graph
3410            .add_edge(parent_idx, child_idx, Dependency { kind: "dep".into() });
3411
3412        // Empty restriction map — no structural digests at all
3413        let rmap = RestrictionMap::for_node("impl_1");
3414        let gaps = orch.check_structural_dependencies(&child, &rmap);
3415
3416        assert_eq!(gaps.len(), 1);
3417        assert_eq!(gaps[0].0, "iface_1");
3418        assert!(gaps[0].1.contains("no Signature/Schema/InterfaceSeal"));
3419    }
3420
3421    #[tokio::test]
3422    async fn test_check_structural_dependencies_passes_with_digest() {
3423        use perspt_core::types::{ArtifactKind, NodeClass, RestrictionMap, StructuralDigest};
3424
3425        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_struct_ok"));
3426
3427        let mut parent = SRBNNode::new("iface_2".into(), "Define API".into(), ModelTier::Architect);
3428        parent.node_class = NodeClass::Interface;
3429
3430        let mut child = SRBNNode::new("impl_2".into(), "Implement API".into(), ModelTier::Actuator);
3431        child.node_class = NodeClass::Implementation;
3432
3433        let parent_idx = orch.add_node(parent);
3434        let child_idx = orch.add_node(child.clone());
3435        orch.graph
3436            .add_edge(parent_idx, child_idx, Dependency { kind: "dep".into() });
3437
3438        // Restriction map with a Signature digest from the Interface node
3439        let mut rmap = RestrictionMap::for_node("impl_2");
3440        rmap.structural_digests.push(StructuralDigest::from_content(
3441            "iface_2",
3442            "api.rs",
3443            ArtifactKind::Signature,
3444            b"fn do_thing(x: i32) -> bool;",
3445        ));
3446
3447        let gaps = orch.check_structural_dependencies(&child, &rmap);
3448        assert!(gaps.is_empty(), "Expected no gaps when digest present");
3449    }
3450
3451    #[tokio::test]
3452    async fn test_check_structural_dependencies_skips_non_implementation() {
3453        use perspt_core::types::{NodeClass, RestrictionMap};
3454
3455        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_struct_skip"));
3456
3457        // An Integration node should NOT be checked
3458        let mut node = SRBNNode::new("integ_1".into(), "Wire modules".into(), ModelTier::Actuator);
3459        node.node_class = NodeClass::Integration;
3460        orch.add_node(node.clone());
3461
3462        let rmap = RestrictionMap::for_node("integ_1");
3463        let gaps = orch.check_structural_dependencies(&node, &rmap);
3464        assert!(gaps.is_empty(), "Integration nodes should skip the check");
3465    }
3466
3467    #[tokio::test]
3468    async fn test_tier_default_models_are_differentiated() {
3469        // PSP-5 Fix D: each tier should map to a different default model
3470        let arch = ModelTier::Architect.default_model();
3471        let act = ModelTier::Actuator.default_model();
3472        let spec = ModelTier::Speculator.default_model();
3473
3474        // Architect and Actuator should NOT be the same tier default
3475        assert_ne!(arch, act, "Architect and Actuator defaults should differ");
3476        // Speculator should be the lightest
3477        assert_ne!(spec, arch, "Speculator should differ from Architect");
3478    }
3479
3480    // =========================================================================
3481    // PSP-5: Tier Wiring and Plan Validation Tests
3482    // =========================================================================
3483
3484    #[tokio::test]
3485    async fn test_orchestrator_stores_all_four_tier_models() {
3486        let orch = SRBNOrchestrator::new_with_models(
3487            PathBuf::from("/tmp/test_tiers"),
3488            false,
3489            Some("arch-model".into()),
3490            Some("act-model".into()),
3491            Some("ver-model".into()),
3492            Some("spec-model".into()),
3493            None,
3494            None,
3495            None,
3496            None,
3497        );
3498        assert_eq!(orch.architect_model, "arch-model");
3499        assert_eq!(orch.actuator_model, "act-model");
3500        assert_eq!(orch.verifier_model, "ver-model");
3501        assert_eq!(orch.speculator_model, "spec-model");
3502    }
3503
3504    #[tokio::test]
3505    async fn test_orchestrator_default_tier_models() {
3506        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_tier_defaults"));
3507        assert_eq!(orch.architect_model, ModelTier::Architect.default_model());
3508        assert_eq!(orch.actuator_model, ModelTier::Actuator.default_model());
3509        assert_eq!(orch.verifier_model, ModelTier::Verifier.default_model());
3510        assert_eq!(orch.speculator_model, ModelTier::Speculator.default_model());
3511    }
3512
3513    #[tokio::test]
3514    async fn test_create_nodes_rejects_duplicate_output_files() {
3515        use perspt_core::types::PlannedTask;
3516
3517        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_dup_outputs"));
3518
3519        let plan = TaskPlan {
3520            tasks: vec![
3521                PlannedTask {
3522                    id: "task_1".into(),
3523                    goal: "Create math".into(),
3524                    output_files: vec!["src/math.py".into(), "tests/test_math.py".into()],
3525                    ..PlannedTask::new("task_1", "Create math")
3526                },
3527                PlannedTask {
3528                    id: "task_2".into(),
3529                    goal: "Create tests".into(),
3530                    output_files: vec!["tests/test_math.py".into()],
3531                    ..PlannedTask::new("task_2", "Create tests")
3532                },
3533            ],
3534        };
3535
3536        let result = orch.create_nodes_from_plan(&plan);
3537        assert!(result.is_err(), "Should reject duplicate output_files");
3538        let err = result.unwrap_err().to_string();
3539        assert!(
3540            err.contains("tests/test_math.py"),
3541            "Error should mention the duplicate file: {}",
3542            err
3543        );
3544    }
3545
3546    #[tokio::test]
3547    async fn test_create_nodes_accepts_unique_output_files() {
3548        use perspt_core::types::PlannedTask;
3549
3550        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_unique_outputs"));
3551
3552        let plan = TaskPlan {
3553            tasks: vec![
3554                PlannedTask {
3555                    id: "task_1".into(),
3556                    goal: "Create math".into(),
3557                    output_files: vec!["src/math.py".into()],
3558                    ..PlannedTask::new("task_1", "Create math")
3559                },
3560                PlannedTask {
3561                    id: "test_1".into(),
3562                    goal: "Test math".into(),
3563                    output_files: vec!["tests/test_math.py".into()],
3564                    dependencies: vec!["task_1".into()],
3565                    ..PlannedTask::new("test_1", "Test math")
3566                },
3567            ],
3568        };
3569
3570        let result = orch.create_nodes_from_plan(&plan);
3571        assert!(result.is_ok(), "Should accept unique output_files");
3572        assert_eq!(orch.graph.node_count(), 2);
3573    }
3574
3575    #[tokio::test]
3576    async fn test_ownership_manifest_built_with_majority_plugin_vote() {
3577        use perspt_core::types::PlannedTask;
3578
3579        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_plugin_vote"));
3580
3581        let plan = TaskPlan {
3582            tasks: vec![PlannedTask {
3583                id: "task_1".into(),
3584                goal: "Create Python module".into(),
3585                output_files: vec![
3586                    "src/main.py".into(),
3587                    "src/helper.py".into(),
3588                    "src/__init__.py".into(),
3589                ],
3590                ..PlannedTask::new("task_1", "Create Python module")
3591            }],
3592        };
3593
3594        orch.create_nodes_from_plan(&plan).unwrap();
3595
3596        // All three files should be in the manifest
3597        assert_eq!(orch.context.ownership_manifest.len(), 3);
3598        // The node should have the python plugin assigned
3599        let idx = orch.node_indices["task_1"];
3600        assert_eq!(orch.graph[idx].owner_plugin, "python");
3601    }
3602
3603    #[tokio::test]
3604    async fn test_apply_bundle_strips_paths_outside_node_output_targets() {
3605        use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
3606
3607        let temp_dir = std::env::temp_dir().join(format!(
3608            "perspt_bundle_target_guard_{}",
3609            uuid::Uuid::new_v4()
3610        ));
3611        std::fs::create_dir_all(temp_dir.join("src")).unwrap();
3612
3613        let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
3614        let plan = TaskPlan {
3615            tasks: vec![
3616                PlannedTask {
3617                    id: "validate_module".into(),
3618                    goal: "Create validation module".into(),
3619                    output_files: vec!["src/validate.rs".into()],
3620                    ..PlannedTask::new("validate_module", "Create validation module")
3621                },
3622                PlannedTask {
3623                    id: "lib_module".into(),
3624                    goal: "Export validation module".into(),
3625                    output_files: vec!["src/lib.rs".into()],
3626                    dependencies: vec!["validate_module".into()],
3627                    ..PlannedTask::new("lib_module", "Export validation module")
3628                },
3629            ],
3630        };
3631
3632        orch.create_nodes_from_plan(&plan).unwrap();
3633
3634        let bundle = ArtifactBundle {
3635            artifacts: vec![
3636                ArtifactOperation::Write {
3637                    path: "src/validate.rs".into(),
3638                    content: "pub fn ok() {}".into(),
3639                },
3640                ArtifactOperation::Write {
3641                    path: "src/lib.rs".into(),
3642                    content: "pub mod validate;".into(),
3643                },
3644            ],
3645            commands: vec![],
3646        };
3647
3648        // Should succeed — the undeclared path src/lib.rs is stripped, but
3649        // src/validate.rs is applied.
3650        orch.apply_bundle_transactionally(
3651            &bundle,
3652            "validate_module",
3653            perspt_core::types::NodeClass::Implementation,
3654        )
3655        .await
3656        .expect("Should apply valid artifacts after stripping undeclared paths");
3657
3658        // The declared file should be written
3659        assert!(temp_dir.join("src/validate.rs").exists());
3660        // The undeclared file should NOT be written
3661        assert!(!temp_dir.join("src/lib.rs").exists());
3662    }
3663
3664    #[tokio::test]
3665    async fn test_apply_bundle_keeps_legal_support_file() {
3666        use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
3667
3668        let temp_dir = std::env::temp_dir().join(format!(
3669            "perspt_bundle_support_file_{}",
3670            uuid::Uuid::new_v4()
3671        ));
3672        std::fs::create_dir_all(temp_dir.join("src")).unwrap();
3673
3674        let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
3675        let plan = TaskPlan {
3676            tasks: vec![PlannedTask {
3677                id: "main_module".into(),
3678                goal: "Create Rust main".into(),
3679                output_files: vec!["src/main.rs".into()],
3680                ..PlannedTask::new("main_module", "Create Rust main")
3681            }],
3682        };
3683        orch.create_nodes_from_plan(&plan).unwrap();
3684
3685        let bundle = ArtifactBundle {
3686            artifacts: vec![
3687                ArtifactOperation::Write {
3688                    path: "src/main.rs".into(),
3689                    content: "fn main() {}".into(),
3690                },
3691                ArtifactOperation::Write {
3692                    path: "build.rs".into(),
3693                    content: "fn main() {}".into(),
3694                },
3695            ],
3696            commands: vec![],
3697        };
3698
3699        orch.apply_bundle_transactionally(
3700            &bundle,
3701            "main_module",
3702            perspt_core::types::NodeClass::Implementation,
3703        )
3704        .await
3705        .expect("legal support files should survive semantic filtering");
3706
3707        assert!(temp_dir.join("src/main.rs").exists());
3708        assert!(temp_dir.join("build.rs").exists());
3709        let _ = std::fs::remove_dir_all(&temp_dir);
3710    }
3711
3712    #[tokio::test]
3713    async fn test_apply_bundle_denies_root_manifest_mutation() {
3714        use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
3715
3716        let temp_dir = std::env::temp_dir().join(format!(
3717            "perspt_bundle_manifest_policy_{}",
3718            uuid::Uuid::new_v4()
3719        ));
3720        std::fs::create_dir_all(temp_dir.join("src")).unwrap();
3721
3722        let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
3723        let plan = TaskPlan {
3724            tasks: vec![PlannedTask {
3725                id: "main_module".into(),
3726                goal: "Create Rust main".into(),
3727                output_files: vec!["src/main.rs".into()],
3728                ..PlannedTask::new("main_module", "Create Rust main")
3729            }],
3730        };
3731        orch.create_nodes_from_plan(&plan).unwrap();
3732
3733        let bundle = ArtifactBundle {
3734            artifacts: vec![
3735                ArtifactOperation::Write {
3736                    path: "src/main.rs".into(),
3737                    content: "fn main() {}".into(),
3738                },
3739                ArtifactOperation::Write {
3740                    path: "Cargo.toml".into(),
3741                    content: "[package]\nname = \"bad\"\n".into(),
3742                },
3743            ],
3744            commands: vec![],
3745        };
3746
3747        orch.apply_bundle_transactionally(
3748            &bundle,
3749            "main_module",
3750            perspt_core::types::NodeClass::Implementation,
3751        )
3752        .await
3753        .expect("declared artifact should still apply after denied manifest is stripped");
3754
3755        assert!(temp_dir.join("src/main.rs").exists());
3756        assert!(!temp_dir.join("Cargo.toml").exists());
3757        let _ = std::fs::remove_dir_all(&temp_dir);
3758    }
3759
3760    #[tokio::test]
3761    async fn test_apply_bundle_writes_into_branch_sandbox() {
3762        use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
3763
3764        let temp_dir = std::env::temp_dir().join(format!(
3765            "perspt_branch_sandbox_write_{}",
3766            uuid::Uuid::new_v4()
3767        ));
3768        std::fs::create_dir_all(temp_dir.join("src")).unwrap();
3769        std::fs::write(temp_dir.join("src/lib.rs"), "pub fn old() {}\n").unwrap();
3770
3771        let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
3772        orch.context.session_id = uuid::Uuid::new_v4().to_string();
3773
3774        let plan = TaskPlan {
3775            tasks: vec![
3776                PlannedTask {
3777                    id: "parent".into(),
3778                    goal: "Parent node".into(),
3779                    output_files: vec!["src/lib.rs".into()],
3780                    ..PlannedTask::new("parent", "Parent node")
3781                },
3782                PlannedTask {
3783                    id: "child".into(),
3784                    goal: "Child node".into(),
3785                    context_files: vec!["src/lib.rs".into()],
3786                    output_files: vec!["src/child.rs".into()],
3787                    dependencies: vec!["parent".into()],
3788                    ..PlannedTask::new("child", "Child node")
3789                },
3790            ],
3791        };
3792
3793        orch.create_nodes_from_plan(&plan).unwrap();
3794        let child_idx = orch.node_indices["child"];
3795        let branch_id = orch.maybe_create_provisional_branch(child_idx).unwrap();
3796        let sandbox_dir = orch.sandbox_dir_for_node(child_idx).unwrap();
3797
3798        let bundle = ArtifactBundle {
3799            artifacts: vec![ArtifactOperation::Write {
3800                path: "src/child.rs".into(),
3801                content: "pub fn child() {}\n".into(),
3802            }],
3803            commands: vec![],
3804        };
3805
3806        orch.apply_bundle_transactionally(
3807            &bundle,
3808            "child",
3809            perspt_core::types::NodeClass::Implementation,
3810        )
3811        .await
3812        .unwrap();
3813
3814        assert!(sandbox_dir.join("src/child.rs").exists());
3815        assert!(!temp_dir.join("src/child.rs").exists());
3816
3817        orch.merge_provisional_branch(&branch_id, child_idx);
3818    }
3819
3820    #[test]
3821    fn test_verification_stages_for_node_classes() {
3822        use perspt_core::plugin::VerifierStage;
3823
3824        // Interface → SyntaxCheck only
3825        let interface_node =
3826            SRBNNode::new("iface".into(), "Define trait".into(), ModelTier::Actuator);
3827        // Default is Implementation, so override:
3828        let mut interface_node = interface_node;
3829        interface_node.node_class = perspt_core::types::NodeClass::Interface;
3830        let stages = verification_stages_for_node(&interface_node);
3831        assert_eq!(stages, vec![VerifierStage::SyntaxCheck]);
3832
3833        // Implementation without tests → SyntaxCheck + Build
3834        let mut implementation_node = SRBNNode::new(
3835            "impl".into(),
3836            "Implement feature".into(),
3837            ModelTier::Actuator,
3838        );
3839        implementation_node.node_class = perspt_core::types::NodeClass::Implementation;
3840        let stages = verification_stages_for_node(&implementation_node);
3841        assert_eq!(
3842            stages,
3843            vec![VerifierStage::SyntaxCheck, VerifierStage::Build]
3844        );
3845
3846        // Implementation with weighted tests → SyntaxCheck + Build + Test
3847        implementation_node
3848            .contract
3849            .weighted_tests
3850            .push(perspt_core::types::WeightedTest {
3851                test_name: "test_feature".into(),
3852                criticality: perspt_core::types::Criticality::High,
3853            });
3854        let stages = verification_stages_for_node(&implementation_node);
3855        assert_eq!(
3856            stages,
3857            vec![
3858                VerifierStage::SyntaxCheck,
3859                VerifierStage::Build,
3860                VerifierStage::Test
3861            ]
3862        );
3863
3864        // Integration → full pipeline
3865        let mut integration_node =
3866            SRBNNode::new("test".into(), "Verify feature".into(), ModelTier::Actuator);
3867        integration_node.node_class = perspt_core::types::NodeClass::Integration;
3868        integration_node
3869            .contract
3870            .weighted_tests
3871            .push(perspt_core::types::WeightedTest {
3872                test_name: "test_feature".into(),
3873                criticality: perspt_core::types::Criticality::High,
3874            });
3875        let stages = verification_stages_for_node(&integration_node);
3876        assert_eq!(
3877            stages,
3878            vec![
3879                VerifierStage::SyntaxCheck,
3880                VerifierStage::Build,
3881                VerifierStage::Test,
3882                VerifierStage::Lint,
3883            ]
3884        );
3885    }
3886
3887    // =========================================================================
3888    // Workspace Classification Tests
3889    // =========================================================================
3890
3891    #[tokio::test]
3892    async fn test_classify_workspace_empty_dir() {
3893        let temp = tempfile::tempdir().unwrap();
3894        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3895        let state = orch.classify_workspace("build a web app");
3896        // Empty dir with language keywords → Greenfield
3897        assert!(matches!(state, WorkspaceState::Greenfield { .. }));
3898    }
3899
3900    #[tokio::test]
3901    async fn test_classify_workspace_empty_dir_no_lang() {
3902        let temp = tempfile::tempdir().unwrap();
3903        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3904        let state = orch.classify_workspace("do something");
3905        // Empty dir, no keywords → Greenfield with no lang
3906        match state {
3907            WorkspaceState::Greenfield { inferred_lang } => assert!(inferred_lang.is_none()),
3908            _ => panic!("expected Greenfield, got {:?}", state),
3909        }
3910    }
3911
3912    #[tokio::test]
3913    async fn test_classify_workspace_existing_rust_project() {
3914        let temp = tempfile::tempdir().unwrap();
3915        // Create a Cargo.toml to make it look like a Rust project
3916        std::fs::write(
3917            temp.path().join("Cargo.toml"),
3918            "[package]\nname = \"test\"\nversion = \"0.1.0\"",
3919        )
3920        .unwrap();
3921        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3922        let state = orch.classify_workspace("add a feature");
3923        match state {
3924            WorkspaceState::ExistingProject { plugins } => {
3925                assert!(plugins.contains(&"rust".to_string()));
3926            }
3927            _ => panic!("expected ExistingProject, got {:?}", state),
3928        }
3929    }
3930
3931    #[tokio::test]
3932    async fn test_classify_workspace_existing_python_project() {
3933        let temp = tempfile::tempdir().unwrap();
3934        std::fs::write(
3935            temp.path().join("pyproject.toml"),
3936            "[project]\nname = \"test\"",
3937        )
3938        .unwrap();
3939        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3940        let state = orch.classify_workspace("add a feature");
3941        match state {
3942            WorkspaceState::ExistingProject { plugins } => {
3943                assert!(plugins.contains(&"python".to_string()));
3944            }
3945            _ => panic!("expected ExistingProject, got {:?}", state),
3946        }
3947    }
3948
3949    #[tokio::test]
3950    async fn test_classify_workspace_existing_js_project() {
3951        let temp = tempfile::tempdir().unwrap();
3952        std::fs::write(temp.path().join("package.json"), "{}").unwrap();
3953        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3954        let state = orch.classify_workspace("add auth");
3955        match state {
3956            WorkspaceState::ExistingProject { plugins } => {
3957                assert!(plugins.contains(&"javascript".to_string()));
3958            }
3959            _ => panic!("expected ExistingProject, got {:?}", state),
3960        }
3961    }
3962
3963    #[tokio::test]
3964    async fn test_classify_workspace_ambiguous_with_misc_files() {
3965        let temp = tempfile::tempdir().unwrap();
3966        // Non-empty dir with misc files that don't match any plugin
3967        std::fs::write(temp.path().join("notes.txt"), "hello").unwrap();
3968        std::fs::write(temp.path().join("data.csv"), "a,b,c").unwrap();
3969        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3970        let state = orch.classify_workspace("do something");
3971        assert!(matches!(state, WorkspaceState::Ambiguous));
3972    }
3973
3974    #[tokio::test]
3975    async fn test_classify_workspace_greenfield_with_rust_task() {
3976        let temp = tempfile::tempdir().unwrap();
3977        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3978        let state = orch.classify_workspace("create a rust CLI tool");
3979        match state {
3980            WorkspaceState::Greenfield { inferred_lang } => {
3981                assert_eq!(inferred_lang, Some("rust".to_string()));
3982            }
3983            _ => panic!("expected Greenfield, got {:?}", state),
3984        }
3985    }
3986
3987    #[tokio::test]
3988    async fn test_classify_workspace_greenfield_with_python_task() {
3989        let temp = tempfile::tempdir().unwrap();
3990        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3991        let state = orch.classify_workspace("build a python flask API");
3992        match state {
3993            WorkspaceState::Greenfield { inferred_lang } => {
3994                assert_eq!(inferred_lang, Some("python".to_string()));
3995            }
3996            _ => panic!("expected Greenfield, got {:?}", state),
3997        }
3998    }
3999
4000    // =========================================================================
4001    // Tool Prerequisite Tests
4002    // =========================================================================
4003
4004    #[tokio::test]
4005    async fn test_check_prerequisites_returns_true_when_tools_available() {
4006        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
4007        let registry = perspt_core::plugin::PluginRegistry::new();
4008        // Rust plugin — cargo/rustc should be available in dev environment
4009        if let Some(plugin) = registry.get("rust") {
4010            let result = orch.check_tool_prerequisites(plugin);
4011            // We can't assert true (CI might not have rust-analyzer)
4012            // but the method should not panic
4013            let _ = result;
4014        }
4015    }
4016
4017    #[test]
4018    fn test_required_binaries_rust_includes_cargo() {
4019        let registry = perspt_core::plugin::PluginRegistry::new();
4020        let plugin = registry.get("rust").unwrap();
4021        let bins = plugin.required_binaries();
4022        assert!(bins.iter().any(|(name, _, _)| *name == "cargo"));
4023        assert!(bins.iter().any(|(name, _, _)| *name == "rustc"));
4024    }
4025
4026    #[test]
4027    fn test_required_binaries_python_includes_uv() {
4028        let registry = perspt_core::plugin::PluginRegistry::new();
4029        let plugin = registry.get("python").unwrap();
4030        let bins = plugin.required_binaries();
4031        assert!(bins.iter().any(|(name, _, _)| *name == "uv"));
4032        assert!(bins.iter().any(|(name, _, _)| *name == "python3"));
4033    }
4034
4035    #[test]
4036    fn test_required_binaries_js_includes_node() {
4037        let registry = perspt_core::plugin::PluginRegistry::new();
4038        let plugin = registry.get("javascript").unwrap();
4039        let bins = plugin.required_binaries();
4040        assert!(bins.iter().any(|(name, _, _)| *name == "node"));
4041        assert!(bins.iter().any(|(name, _, _)| *name == "npm"));
4042    }
4043
4044    // =========================================================================
4045    // Fallback Resolution Tests
4046    // =========================================================================
4047
4048    #[tokio::test]
4049    async fn test_fallback_defaults_to_none_without_explicit_config() {
4050        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
4051        assert!(orch.architect_fallback_model.is_none());
4052        assert!(orch.actuator_fallback_model.is_none());
4053        assert!(orch.verifier_fallback_model.is_none());
4054        assert!(orch.speculator_fallback_model.is_none());
4055    }
4056
4057    #[tokio::test]
4058    async fn test_explicit_fallback_stored_correctly() {
4059        let orch = SRBNOrchestrator::new_with_models(
4060            PathBuf::from("/tmp/test_fallback"),
4061            false,
4062            None,
4063            None,
4064            None,
4065            None,
4066            Some("gpt-4o".into()),
4067            Some("gpt-4o-mini".into()),
4068            Some("gpt-4o".into()),
4069            Some("gpt-4o-mini".into()),
4070        );
4071        assert_eq!(orch.architect_fallback_model, Some("gpt-4o".to_string()));
4072        assert_eq!(
4073            orch.actuator_fallback_model,
4074            Some("gpt-4o-mini".to_string())
4075        );
4076        assert_eq!(orch.verifier_fallback_model, Some("gpt-4o".to_string()));
4077        assert_eq!(
4078            orch.speculator_fallback_model,
4079            Some("gpt-4o-mini".to_string())
4080        );
4081    }
4082
4083    #[tokio::test]
4084    async fn test_per_tier_models_independent() {
4085        let orch = SRBNOrchestrator::new_with_models(
4086            PathBuf::from("/tmp/test_tiers_independent"),
4087            false,
4088            Some("arch".into()),
4089            Some("act".into()),
4090            Some("ver".into()),
4091            Some("spec".into()),
4092            None,
4093            None,
4094            None,
4095            None,
4096        );
4097        // Each tier stores its own model, not shared
4098        assert_ne!(orch.architect_model, orch.actuator_model);
4099        assert_ne!(orch.verifier_model, orch.speculator_model);
4100    }
4101
4102    // =========================================================================
4103    // Python auto-dependency repair tests
4104    // =========================================================================
4105
4106    #[test]
4107    fn test_extract_missing_python_modules_basic() {
4108        let output = r#"
4109FAILED tests/test_core.py::TestPipeline::test_run - ModuleNotFoundError: No module named 'httpx'
4110E   ModuleNotFoundError: No module named 'pydantic'
4111ImportError: No module named 'pyarrow'
4112"#;
4113        let mut missing = SRBNOrchestrator::extract_missing_python_modules(output);
4114        missing.sort();
4115        assert_eq!(missing, vec!["httpx", "pyarrow", "pydantic"]);
4116    }
4117
4118    #[test]
4119    fn test_extract_missing_python_modules_subpackage() {
4120        let output = "ModuleNotFoundError: No module named 'foo.bar.baz'";
4121        let missing = SRBNOrchestrator::extract_missing_python_modules(output);
4122        assert_eq!(missing, vec!["foo"]);
4123    }
4124
4125    #[test]
4126    fn test_extract_missing_python_modules_stdlib_filtered() {
4127        let output = r#"
4128ModuleNotFoundError: No module named 'numpy'
4129ModuleNotFoundError: No module named 'os'
4130ModuleNotFoundError: No module named 'json'
4131"#;
4132        let missing = SRBNOrchestrator::extract_missing_python_modules(output);
4133        assert_eq!(missing, vec!["numpy"]);
4134    }
4135
4136    #[test]
4137    fn test_extract_missing_python_modules_empty() {
4138        let output = "All tests passed!\n3 passed in 0.5s";
4139        let missing = SRBNOrchestrator::extract_missing_python_modules(output);
4140        assert!(missing.is_empty());
4141    }
4142
4143    #[test]
4144    fn test_python_import_to_package_mapping() {
4145        assert_eq!(SRBNOrchestrator::python_import_to_package("PIL"), "pillow");
4146        assert_eq!(SRBNOrchestrator::python_import_to_package("yaml"), "pyyaml");
4147        assert_eq!(
4148            SRBNOrchestrator::python_import_to_package("cv2"),
4149            "opencv-python"
4150        );
4151        assert_eq!(
4152            SRBNOrchestrator::python_import_to_package("sklearn"),
4153            "scikit-learn"
4154        );
4155        assert_eq!(
4156            SRBNOrchestrator::python_import_to_package("bs4"),
4157            "beautifulsoup4"
4158        );
4159        // Direct passthrough for unknown
4160        assert_eq!(SRBNOrchestrator::python_import_to_package("httpx"), "httpx");
4161        assert_eq!(
4162            SRBNOrchestrator::python_import_to_package("fastapi"),
4163            "fastapi"
4164        );
4165    }
4166
4167    #[test]
4168    fn test_normalize_command_to_uv_pip_install() {
4169        assert_eq!(
4170            SRBNOrchestrator::normalize_command_to_uv("pip install httpx"),
4171            "uv add httpx"
4172        );
4173        assert_eq!(
4174            SRBNOrchestrator::normalize_command_to_uv("pip3 install httpx pydantic"),
4175            "uv add httpx pydantic"
4176        );
4177        assert_eq!(
4178            SRBNOrchestrator::normalize_command_to_uv("python -m pip install requests"),
4179            "uv add requests"
4180        );
4181        assert_eq!(
4182            SRBNOrchestrator::normalize_command_to_uv("python3 -m pip install flask"),
4183            "uv add flask"
4184        );
4185    }
4186
4187    #[test]
4188    fn test_normalize_command_to_uv_requirements_file() {
4189        assert_eq!(
4190            SRBNOrchestrator::normalize_command_to_uv("pip install -r requirements.txt"),
4191            "uv pip install -r requirements.txt"
4192        );
4193    }
4194
4195    #[test]
4196    fn test_normalize_command_to_uv_passthrough() {
4197        // Already uv commands pass through unchanged
4198        assert_eq!(
4199            SRBNOrchestrator::normalize_command_to_uv("uv add httpx"),
4200            "uv add httpx"
4201        );
4202        // Non-Python commands pass through unchanged
4203        assert_eq!(
4204            SRBNOrchestrator::normalize_command_to_uv("cargo add serde"),
4205            "cargo add serde"
4206        );
4207        assert_eq!(
4208            SRBNOrchestrator::normalize_command_to_uv("npm install lodash"),
4209            "npm install lodash"
4210        );
4211    }
4212
4213    #[test]
4214    fn test_extract_commands_from_correction_rust_plugin_policy() {
4215        let response = r#"Here's the fix:
4216Commands:
4217```
4218uv add httpx
4219cargo add serde
4220pip install numpy
4221```
4222File: main.rs
4223```rust
4224use serde;
4225```"#;
4226        // Rust plugin allows cargo commands, denies uv/pip
4227        let commands = SRBNOrchestrator::extract_commands_from_correction(response, "rust");
4228        assert!(
4229            commands.contains(&"cargo add serde".to_string()),
4230            "{:?}",
4231            commands
4232        );
4233        assert!(
4234            !commands.contains(&"uv add httpx".to_string()),
4235            "Rust plugin should deny uv commands: {:?}",
4236            commands
4237        );
4238        assert!(
4239            !commands.contains(&"pip install numpy".to_string()),
4240            "Rust plugin should deny pip commands: {:?}",
4241            commands
4242        );
4243    }
4244
4245    #[test]
4246    fn test_extract_commands_from_correction_python_plugin_policy() {
4247        let response = r#"Commands:
4248```
4249uv add httpx
4250cargo add serde
4251pip install numpy
4252```"#;
4253        // Python plugin allows uv/pip commands, denies cargo
4254        let commands = SRBNOrchestrator::extract_commands_from_correction(response, "python");
4255        assert!(
4256            commands.contains(&"uv add httpx".to_string()),
4257            "{:?}",
4258            commands
4259        );
4260        assert!(
4261            commands.contains(&"pip install numpy".to_string()),
4262            "{:?}",
4263            commands
4264        );
4265        assert!(
4266            !commands.contains(&"cargo add serde".to_string()),
4267            "Python plugin should deny cargo commands: {:?}",
4268            commands
4269        );
4270    }
4271
4272    #[test]
4273    fn test_typed_parse_pipeline_multiple_files() {
4274        let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4275        let content = r#"Here are the files:
4276
4277File: src/etl_pipeline/core.py
4278```python
4279def run_pipeline():
4280    pass
4281```
4282
4283File: src/etl_pipeline/validator.py
4284```python
4285def validate(data):
4286    return True
4287```
4288
4289File: tests/test_core.py
4290```python
4291from etl_pipeline.core import run_pipeline
4292
4293def test_run():
4294    run_pipeline()
4295```
4296"#;
4297        let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4298        assert!(state.is_ok(), "Expected successful parse, got {}", state);
4299        let bundle = bundle_opt.unwrap();
4300        assert_eq!(bundle.artifacts.len(), 3, "Expected 3 artifacts");
4301        assert_eq!(bundle.artifacts[0].path(), "src/etl_pipeline/core.py");
4302        assert_eq!(bundle.artifacts[1].path(), "src/etl_pipeline/validator.py");
4303        assert_eq!(bundle.artifacts[2].path(), "tests/test_core.py");
4304    }
4305
4306    #[test]
4307    fn test_typed_parse_pipeline_single_file() {
4308        let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4309        let content = r#"File: main.py
4310```python
4311print("hello")
4312```"#;
4313        let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4314        assert!(state.is_ok());
4315        let bundle = bundle_opt.unwrap();
4316        assert_eq!(bundle.artifacts.len(), 1);
4317        assert_eq!(bundle.artifacts[0].path(), "main.py");
4318    }
4319
4320    #[test]
4321    fn test_typed_parse_pipeline_mixed_file_and_diff() {
4322        let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4323        let content = r#"File: new_module.py
4324```python
4325def new_fn():
4326    pass
4327```
4328
4329Diff: existing.py
4330```diff
4331--- existing.py
4332+++ existing.py
4333@@ -1 +1,2 @@
4334+import new_module
4335 def old_fn():
4336```"#;
4337        let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4338        assert!(state.is_ok());
4339        let bundle = bundle_opt.unwrap();
4340        assert_eq!(bundle.artifacts.len(), 2);
4341        assert_eq!(bundle.artifacts[0].path(), "new_module.py");
4342        assert!(
4343            bundle.artifacts[0].is_write(),
4344            "new_module.py should be a write"
4345        );
4346        assert_eq!(bundle.artifacts[1].path(), "existing.py");
4347        assert!(
4348            bundle.artifacts[1].is_diff(),
4349            "existing.py should be a diff"
4350        );
4351    }
4352
4353    #[test]
4354    fn test_typed_parse_pipeline_legacy_multi_file() {
4355        let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4356        let content = r#"File: core.py
4357```python
4358def core():
4359    pass
4360```
4361
4362File: utils.py
4363```python
4364def util():
4365    pass
4366```"#;
4367        let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4368        assert!(state.is_ok(), "Should parse multi-file response");
4369        let bundle = bundle_opt.unwrap();
4370        assert_eq!(bundle.artifacts.len(), 2, "Should have 2 artifacts");
4371        assert_eq!(bundle.artifacts[0].path(), "core.py");
4372        assert_eq!(bundle.artifacts[1].path(), "utils.py");
4373    }
4374
4375    // =========================================================================
4376    // Baseline regression tests — freeze pre-refactor behavior
4377    // =========================================================================
4378
4379    #[test]
4380    fn test_typed_parse_pipeline_structured_json() {
4381        let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4382        let content = r#"Here is the output:
4383```json
4384{
4385  "artifacts": [
4386    {"operation": "write", "path": "src/main.py", "content": "print('hello')"},
4387    {"operation": "diff", "path": "src/lib.py", "patch": "--- a\n+++ b\n@@ -1 +1 @@\n-old\n+new"}
4388  ],
4389  "commands": ["uv add requests"]
4390}
4391```"#;
4392        let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4393        assert!(state.is_ok(), "Should parse structured JSON bundle");
4394        let bundle = bundle_opt.unwrap();
4395        assert_eq!(bundle.artifacts.len(), 2);
4396        assert!(bundle.artifacts[0].is_write());
4397        assert_eq!(bundle.artifacts[0].path(), "src/main.py");
4398        assert!(bundle.artifacts[1].is_diff());
4399        assert_eq!(bundle.artifacts[1].path(), "src/lib.py");
4400        assert_eq!(bundle.commands, vec!["uv add requests"]);
4401    }
4402
4403    #[test]
4404    fn test_typed_parse_pipeline_schema_invalid_classified() {
4405        let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4406        let content = r#"```json
4407{"foo":"bar"}
4408```"#;
4409        let (bundle_opt, state, record_opt) = orch.parse_artifact_bundle_typed(content, "test", 1);
4410        assert!(bundle_opt.is_none());
4411        assert!(matches!(
4412            state,
4413            perspt_core::types::ParseResultState::SchemaInvalid
4414        ));
4415        let record = record_opt.expect("schema failure should be recorded");
4416        assert!(matches!(
4417            record.retry_classification,
4418            Some(perspt_core::types::RetryClassification::MalformedRetry)
4419        ));
4420    }
4421
4422    #[test]
4423    fn test_typed_parse_pipeline_semantic_rejection_classified() {
4424        use perspt_core::types::PlannedTask;
4425
4426        let mut orch = SRBNOrchestrator::new_for_testing(std::path::PathBuf::from("/tmp/test"));
4427        let plan = TaskPlan {
4428            tasks: vec![PlannedTask {
4429                id: "parser".into(),
4430                goal: "Create parser".into(),
4431                output_files: vec!["src/parser.rs".into()],
4432                ..PlannedTask::new("parser", "Create parser")
4433            }],
4434        };
4435        orch.create_nodes_from_plan(&plan).unwrap();
4436
4437        let content = r#"```json
4438{
4439  "artifacts": [
4440    {"operation": "write", "path": "src/wrong.rs", "content": "pub fn wrong() {}"}
4441  ],
4442  "commands": []
4443}
4444```"#;
4445        let (bundle_opt, state, record_opt) =
4446            orch.parse_artifact_bundle_typed(content, "parser", 1);
4447        assert!(bundle_opt.is_none());
4448        assert!(matches!(
4449            state,
4450            perspt_core::types::ParseResultState::SemanticallyRejected
4451        ));
4452        let record = record_opt.expect("semantic rejection should be recorded");
4453        assert!(matches!(
4454            record.retry_classification,
4455            Some(perspt_core::types::RetryClassification::Retarget)
4456        ));
4457    }
4458
4459    #[test]
4460    fn test_typed_parse_pipeline_json_empty_path_rejected() {
4461        let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4462        let content = r#"```json
4463{
4464  "artifacts": [
4465    {"operation": "write", "path": "", "content": "bad"}
4466  ],
4467  "commands": []
4468}
4469```"#;
4470        let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4471        assert!(
4472            bundle_opt.is_none(),
4473            "Invalid bundle with empty path should be rejected"
4474        );
4475        assert!(
4476            !state.is_ok(),
4477            "Parse state should not be Ok for invalid bundle: {}",
4478            state
4479        );
4480    }
4481
4482    #[test]
4483    fn test_typed_parse_pipeline_json_absolute_path_rejected() {
4484        let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4485        let content = r#"```json
4486{
4487  "artifacts": [
4488    {"operation": "write", "path": "/etc/passwd", "content": "bad"}
4489  ],
4490  "commands": []
4491}
4492```"#;
4493        let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4494        assert!(
4495            bundle_opt.is_none(),
4496            "Invalid bundle with absolute path should be rejected"
4497        );
4498        assert!(
4499            !state.is_ok(),
4500            "Parse state should not be Ok for path traversal: {}",
4501            state
4502        );
4503    }
4504
4505    #[test]
4506    fn test_typed_parse_pipeline_returns_no_payload_for_garbage() {
4507        let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4508        let content = "This is just a plain text response with no code blocks at all.";
4509        let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4510        assert!(bundle_opt.is_none());
4511        assert!(
4512            matches!(
4513                state,
4514                perspt_core::types::ParseResultState::NoStructuredPayload
4515            ),
4516            "Expected NoStructuredPayload, got {}",
4517            state
4518        );
4519    }
4520
4521    #[tokio::test]
4522    async fn test_effective_working_dir_with_sandbox() {
4523        // When a node has a provisional branch AND the sandbox directory exists,
4524        // effective_working_dir should return the sandbox path instead of workspace.
4525        let temp_dir = std::env::temp_dir().join(format!(
4526            "perspt_eff_workdir_sandbox_{}",
4527            uuid::Uuid::new_v4()
4528        ));
4529        std::fs::create_dir_all(&temp_dir).unwrap();
4530
4531        let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
4532        orch.context.session_id = "test_session".into();
4533
4534        let parent = SRBNNode::new("root".into(), "root goal".into(), ModelTier::Actuator);
4535        let child = SRBNNode::new("child".into(), "child goal".into(), ModelTier::Actuator);
4536        orch.add_node(parent);
4537        orch.add_node(child);
4538        orch.add_dependency("root", "child", "dep").unwrap();
4539
4540        let child_idx = orch.node_indices["child"];
4541        let branch_id = orch.maybe_create_provisional_branch(child_idx).unwrap();
4542
4543        let sandbox_path = temp_dir
4544            .join(".perspt")
4545            .join("sandboxes")
4546            .join("test_session")
4547            .join(&branch_id);
4548        assert!(sandbox_path.exists(), "Sandbox should have been created");
4549
4550        // effective_working_dir should now return the sandbox
4551        let eff = orch.effective_working_dir(child_idx);
4552        assert_eq!(eff, sandbox_path);
4553
4554        // Cleanup
4555        let _ = std::fs::remove_dir_all(&temp_dir);
4556    }
4557
4558    #[tokio::test]
4559    async fn test_sandbox_dir_for_node_returns_path_when_exists() {
4560        let temp_dir = std::env::temp_dir().join(format!(
4561            "perspt_sandbox_dir_exists_{}",
4562            uuid::Uuid::new_v4()
4563        ));
4564        std::fs::create_dir_all(&temp_dir).unwrap();
4565
4566        let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
4567        orch.context.session_id = "sess".into();
4568
4569        let parent = SRBNNode::new("p".into(), "g".into(), ModelTier::Actuator);
4570        let child = SRBNNode::new("c".into(), "g".into(), ModelTier::Actuator);
4571        orch.add_node(parent);
4572        orch.add_node(child);
4573        orch.add_dependency("p", "c", "dep").unwrap();
4574
4575        let child_idx = orch.node_indices["c"];
4576        let branch_id = orch.maybe_create_provisional_branch(child_idx).unwrap();
4577
4578        let sandbox = orch.sandbox_dir_for_node(child_idx);
4579        assert!(sandbox.is_some());
4580        let sandbox_path = sandbox.unwrap();
4581        assert!(sandbox_path.ends_with(&branch_id));
4582
4583        let _ = std::fs::remove_dir_all(&temp_dir);
4584    }
4585
4586    #[tokio::test]
4587    async fn test_root_node_bypasses_sandbox() {
4588        // Root nodes (no graph parents) should NOT get provisional branches,
4589        // and effective_working_dir should return the live workspace.
4590        let temp_dir =
4591            std::env::temp_dir().join(format!("perspt_root_bypass_{}", uuid::Uuid::new_v4()));
4592        std::fs::create_dir_all(&temp_dir).unwrap();
4593
4594        let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
4595
4596        let root = SRBNNode::new("root".into(), "root goal".into(), ModelTier::Actuator);
4597        orch.add_node(root);
4598
4599        let root_idx = orch.node_indices["root"];
4600        // Root nodes now get a provisional branch with sandbox isolation
4601        let branch = orch.maybe_create_provisional_branch(root_idx);
4602        assert!(
4603            branch.is_some(),
4604            "Root node should now get a provisional branch for sandbox isolation"
4605        );
4606
4607        // effective_working_dir should point to the sandbox, not the raw workspace
4608        let wd = orch.effective_working_dir(root_idx);
4609        assert_ne!(wd, temp_dir, "Root should use sandbox, not raw workspace");
4610        assert!(wd.to_string_lossy().contains("sandboxes"));
4611
4612        let _ = std::fs::remove_dir_all(&temp_dir);
4613    }
4614
4615    #[tokio::test]
4616    async fn test_step_commit_copies_sandbox_to_workspace() {
4617        // Verify the commit path: files written to sandbox should appear in
4618        // the workspace after step_commit runs its copy-from-sandbox logic.
4619        use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
4620
4621        let temp_dir =
4622            std::env::temp_dir().join(format!("perspt_commit_copy_{}", uuid::Uuid::new_v4()));
4623        std::fs::create_dir_all(temp_dir.join("src")).unwrap();
4624
4625        let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
4626        orch.context.session_id = uuid::Uuid::new_v4().to_string();
4627
4628        let plan = TaskPlan {
4629            tasks: vec![
4630                PlannedTask {
4631                    id: "parent".into(),
4632                    goal: "Parent".into(),
4633                    output_files: vec!["src/parent.rs".into()],
4634                    ..PlannedTask::new("parent", "Parent")
4635                },
4636                PlannedTask {
4637                    id: "child".into(),
4638                    goal: "Child".into(),
4639                    output_files: vec!["src/child.rs".into()],
4640                    dependencies: vec!["parent".into()],
4641                    ..PlannedTask::new("child", "Child")
4642                },
4643            ],
4644        };
4645        orch.create_nodes_from_plan(&plan).unwrap();
4646
4647        let child_idx = orch.node_indices["child"];
4648        let _branch_id = orch.maybe_create_provisional_branch(child_idx).unwrap();
4649
4650        // Write a file into sandbox via apply_bundle_transactionally
4651        let bundle = ArtifactBundle {
4652            artifacts: vec![ArtifactOperation::Write {
4653                path: "src/child.rs".into(),
4654                content: "pub fn child_fn() {}\n".into(),
4655            }],
4656            commands: vec![],
4657        };
4658        orch.apply_bundle_transactionally(
4659            &bundle,
4660            "child",
4661            perspt_core::types::NodeClass::Implementation,
4662        )
4663        .await
4664        .unwrap();
4665
4666        // Before commit: file should be in sandbox, NOT in workspace
4667        let sandbox = orch.sandbox_dir_for_node(child_idx).unwrap();
4668        assert!(sandbox.join("src/child.rs").exists());
4669        assert!(!temp_dir.join("src/child.rs").exists());
4670
4671        // Now run step_commit to promote
4672        let child_idx = orch.node_indices["child"];
4673        let _ = orch.step_commit(child_idx).await;
4674
4675        // After commit: file should now be in workspace
4676        assert!(
4677            temp_dir.join("src/child.rs").exists(),
4678            "step_commit should copy sandbox files to workspace"
4679        );
4680        let content = std::fs::read_to_string(temp_dir.join("src/child.rs")).unwrap();
4681        assert_eq!(content, "pub fn child_fn() {}\n");
4682
4683        let _ = std::fs::remove_dir_all(&temp_dir);
4684    }
4685
4686    #[test]
4687    fn test_typed_parse_pipeline_json_path_traversal_rejected() {
4688        let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4689        let content = r#"```json
4690{
4691  "artifacts": [
4692    {"operation": "write", "path": "../../../etc/shadow", "content": "bad"}
4693  ],
4694  "commands": []
4695}
4696```"#;
4697        let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4698        assert!(
4699            bundle_opt.is_none(),
4700            "Invalid bundle with path traversal should be rejected"
4701        );
4702        assert!(
4703            !state.is_ok(),
4704            "Parse state should not be Ok for path traversal: {}",
4705            state
4706        );
4707    }
4708
4709    // --- Step 6: Greenfield bootstrap ordering & dependency determinism ---
4710
4711    #[test]
4712    fn test_dependency_expectations_threaded_to_nodes() {
4713        use perspt_core::types::{DependencyExpectation, PlannedTask, TaskPlan};
4714
4715        let mut plan = TaskPlan::new();
4716        let mut t1 = PlannedTask::new("t1", "Create server module");
4717        t1.output_files = vec!["src/server.py".to_string()];
4718        t1.dependency_expectations = DependencyExpectation {
4719            required_packages: vec!["flask".to_string(), "pydantic".to_string()],
4720            setup_commands: vec![],
4721            min_toolchain_version: Some("3.11".to_string()),
4722        };
4723        plan.tasks.push(t1);
4724
4725        let mut orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4726        orch.create_nodes_from_plan(&plan).unwrap();
4727
4728        // Verify the node carries dependency expectations
4729        let idx = orch.node_indices["t1"];
4730        let node = &orch.graph[idx];
4731        assert_eq!(node.dependency_expectations.required_packages.len(), 2);
4732        assert_eq!(node.dependency_expectations.required_packages[0], "flask");
4733        assert_eq!(
4734            node.dependency_expectations
4735                .min_toolchain_version
4736                .as_deref(),
4737            Some("3.11")
4738        );
4739    }
4740
4741    #[test]
4742    fn test_verifier_readiness_gate_no_plugins() {
4743        let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4744        // Should not panic with empty plugins
4745        orch.check_verifier_readiness_gate();
4746    }
4747
4748    #[test]
4749    fn test_architect_prompt_includes_dependency_expectations() {
4750        let ev = perspt_core::types::PromptEvidence {
4751            user_goal: Some("Build a web server".to_string()),
4752            project_summary: Some("empty project".to_string()),
4753            working_dir: Some("/tmp".to_string()),
4754            ..Default::default()
4755        };
4756        let prompt = crate::prompt_compiler::compile(
4757            perspt_core::types::PromptIntent::ArchitectExisting,
4758            &ev,
4759        )
4760        .text;
4761        assert!(
4762            prompt.contains("dependency_expectations"),
4763            "Architect prompt must include dependency_expectations in the JSON schema"
4764        );
4765        assert!(
4766            prompt.contains("required_packages"),
4767            "Architect prompt must mention required_packages"
4768        );
4769        assert!(
4770            prompt.contains("min_toolchain_version"),
4771            "Architect prompt must mention min_toolchain_version"
4772        );
4773    }
4774
4775    // --- Step 8: Budget enforcement & plan revision tracking ---
4776
4777    #[test]
4778    fn test_budget_gate_stops_execution_when_exhausted() {
4779        let mut orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4780        // Set a budget of 0 steps — should be immediately exhausted
4781        orch.set_budget(Some(0), None, None);
4782        assert!(
4783            orch.budget.any_exhausted(),
4784            "Budget with max_steps=0 should be immediately exhausted"
4785        );
4786    }
4787
4788    #[test]
4789    fn test_budget_step_recording() {
4790        let mut budget = perspt_core::types::BudgetEnvelope::new("test-session");
4791        budget.max_steps = Some(3);
4792        assert!(!budget.any_exhausted());
4793        budget.record_step();
4794        budget.record_step();
4795        assert!(!budget.any_exhausted());
4796        budget.record_step();
4797        assert!(budget.steps_exhausted());
4798        assert!(budget.any_exhausted());
4799    }
4800
4801    #[test]
4802    fn test_set_budget_configures_envelope() {
4803        let mut orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4804        orch.set_budget(Some(10), Some(5), Some(2.50));
4805        assert_eq!(orch.budget.max_steps, Some(10));
4806        assert_eq!(orch.budget.max_revisions, Some(5));
4807        assert_eq!(orch.budget.max_cost_usd, Some(2.50));
4808        assert!(!orch.budget.any_exhausted());
4809    }
4810
4811    #[test]
4812    fn test_node_outcome_equality() {
4813        assert_eq!(NodeOutcome::Completed, NodeOutcome::Completed);
4814        assert_eq!(NodeOutcome::Escalated, NodeOutcome::Escalated);
4815        assert_ne!(NodeOutcome::Completed, NodeOutcome::Escalated);
4816    }
4817
4818    #[test]
4819    fn test_session_outcome_from_counts() {
4820        // The outcome derivation must account for total_nodes so that
4821        // unattempted nodes (budget/abort stop) are never counted as success.
4822        fn derive_outcome(
4823            completed: usize,
4824            escalated: usize,
4825            total: usize,
4826        ) -> perspt_core::SessionOutcome {
4827            if escalated == 0 && completed >= total {
4828                perspt_core::SessionOutcome::Success
4829            } else if completed > 0 {
4830                perspt_core::SessionOutcome::PartialSuccess
4831            } else {
4832                perspt_core::SessionOutcome::Failed
4833            }
4834        }
4835
4836        // All completed → Success
4837        assert_eq!(
4838            derive_outcome(3, 0, 3),
4839            perspt_core::SessionOutcome::Success,
4840        );
4841        // Some completed, some escalated → PartialSuccess
4842        assert_eq!(
4843            derive_outcome(2, 1, 3),
4844            perspt_core::SessionOutcome::PartialSuccess,
4845        );
4846        // All escalated → Failed
4847        assert_eq!(derive_outcome(0, 3, 3), perspt_core::SessionOutcome::Failed,);
4848        // Budget-stopped: 5 of 20 completed, 0 escalated → PartialSuccess (not Success!)
4849        assert_eq!(
4850            derive_outcome(5, 0, 20),
4851            perspt_core::SessionOutcome::PartialSuccess,
4852        );
4853        // Budget-stopped: 0 of 20 completed, 0 escalated → Failed
4854        assert_eq!(
4855            derive_outcome(0, 0, 20),
4856            perspt_core::SessionOutcome::Failed,
4857        );
4858    }
4859
4860    #[test]
4861    fn test_resumed_outcome_from_counts() {
4862        // Resumed sessions derive outcome the same way: unattempted nodes
4863        // prevent Success, and terminal_count offsets the total.
4864        fn derive_resumed_outcome(
4865            executed: usize,
4866            escalated: usize,
4867            terminal_count: usize,
4868            total: usize,
4869        ) -> perspt_core::SessionOutcome {
4870            if escalated == 0 && executed + terminal_count >= total {
4871                perspt_core::SessionOutcome::Success
4872            } else if executed > 0 {
4873                perspt_core::SessionOutcome::PartialSuccess
4874            } else {
4875                perspt_core::SessionOutcome::Failed
4876            }
4877        }
4878
4879        // All resumable nodes completed, 2 already terminal
4880        assert_eq!(
4881            derive_resumed_outcome(3, 0, 2, 5),
4882            perspt_core::SessionOutcome::Success,
4883        );
4884        // Some escalated on resume
4885        assert_eq!(
4886            derive_resumed_outcome(2, 1, 2, 5),
4887            perspt_core::SessionOutcome::PartialSuccess,
4888        );
4889        // Budget stopped mid-resume: 2 of 5 completed, 2 terminal, 1 not attempted
4890        assert_eq!(
4891            derive_resumed_outcome(1, 0, 2, 5),
4892            perspt_core::SessionOutcome::PartialSuccess,
4893        );
4894        // Nothing executed on resume (all blocked/seal-gated)
4895        assert_eq!(
4896            derive_resumed_outcome(0, 0, 5, 5),
4897            perspt_core::SessionOutcome::Success,
4898        );
4899        // Nothing executed, not all terminal → Failed
4900        assert_eq!(
4901            derive_resumed_outcome(0, 0, 2, 5),
4902            perspt_core::SessionOutcome::Failed,
4903        );
4904    }
4905
4906    #[test]
4907    fn test_sheaf_pre_check_stub_escalates_after_retry() {
4908        let dir = tempfile::tempdir().unwrap();
4909        let stub_path = dir.path().join("stub.rs");
4910        std::fs::write(&stub_path, "fn main() {\n    todo!()\n}\n").unwrap();
4911
4912        let (mut orch, idx) = orch_with_node(dir.path().to_path_buf());
4913        orch.graph[idx]
4914            .output_targets
4915            .push(std::path::PathBuf::from("stub.rs"));
4916        orch.graph[idx].owner_plugin = "rust".to_string();
4917
4918        // First call detects stub
4919        let first = orch.sheaf_pre_check(idx);
4920        assert!(first.is_some(), "First pre-check should detect stub");
4921
4922        // Simulate: after retry, the file is still a stub.
4923        // The final guard should also detect it.
4924        let second = orch.sheaf_pre_check(idx);
4925        assert!(
4926            second.is_some(),
4927            "Final guard should still detect stub after retry"
4928        );
4929    }
4930
4931    /// Helper: create an orchestrator with a single default node for testing.
4932    fn orch_with_node(
4933        working_dir: std::path::PathBuf,
4934    ) -> (SRBNOrchestrator, petgraph::graph::NodeIndex) {
4935        let mut orch = SRBNOrchestrator::new(working_dir, false);
4936        let node = SRBNNode::new(
4937            "test-node".to_string(),
4938            "test goal".to_string(),
4939            perspt_core::ModelTier::Actuator,
4940        );
4941        let idx = orch.add_node(node);
4942        (orch, idx)
4943    }
4944
4945    #[test]
4946    fn test_sheaf_pre_check_passes_when_no_outputs() {
4947        let (orch, idx) = orch_with_node(std::path::PathBuf::from("/tmp/test"));
4948        assert!(orch.sheaf_pre_check(idx).is_none());
4949    }
4950
4951    #[test]
4952    fn test_sheaf_pre_check_detects_missing_files() {
4953        let (mut orch, idx) = orch_with_node(std::path::PathBuf::from("/tmp/test"));
4954        orch.graph[idx]
4955            .output_targets
4956            .push(std::path::PathBuf::from("nonexistent_file_xyz.rs"));
4957        let result = orch.sheaf_pre_check(idx);
4958        assert!(result.is_some());
4959        assert!(result.unwrap().contains("missing"));
4960    }
4961
4962    #[test]
4963    fn test_sheaf_pre_check_detects_empty_files() {
4964        let dir = tempfile::tempdir().unwrap();
4965        std::fs::File::create(dir.path().join("empty.rs")).unwrap();
4966
4967        let (mut orch, idx) = orch_with_node(dir.path().to_path_buf());
4968        orch.graph[idx]
4969            .output_targets
4970            .push(std::path::PathBuf::from("empty.rs"));
4971        let result = orch.sheaf_pre_check(idx);
4972        assert!(result.is_some());
4973        assert!(result.unwrap().contains("empty"));
4974    }
4975
4976    #[test]
4977    fn test_sheaf_pre_check_passes_for_valid_files() {
4978        let dir = tempfile::tempdir().unwrap();
4979        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4980
4981        let (mut orch, idx) = orch_with_node(dir.path().to_path_buf());
4982        orch.graph[idx]
4983            .output_targets
4984            .push(std::path::PathBuf::from("main.rs"));
4985        assert!(orch.sheaf_pre_check(idx).is_none());
4986    }
4987
4988    #[test]
4989    fn test_v_boot_energy_from_degraded_sensors() {
4990        use perspt_core::types::{
4991            EnergyComponents, SensorStatus, StageOutcome, VerificationResult,
4992        };
4993
4994        // Simulate a verification result with one fallback and one unavailable sensor
4995        let vr = VerificationResult {
4996            syntax_ok: true,
4997            build_ok: true,
4998            tests_ok: true,
4999            lint_ok: true,
5000            diagnostics_count: 0,
5001            tests_passed: 5,
5002            tests_failed: 0,
5003            summary: String::new(),
5004            raw_output: None,
5005            degraded: true,
5006            degraded_reason: Some("test sensor fallback".into()),
5007            stage_outcomes: vec![
5008                StageOutcome {
5009                    stage: "syntax_check".into(),
5010                    passed: true,
5011                    sensor_status: SensorStatus::Available,
5012                    output: None,
5013                },
5014                StageOutcome {
5015                    stage: "build".into(),
5016                    passed: true,
5017                    sensor_status: SensorStatus::Fallback {
5018                        actual: "cargo check".into(),
5019                        reason: "primary not found".into(),
5020                    },
5021                    output: None,
5022                },
5023                StageOutcome {
5024                    stage: "test".into(),
5025                    passed: true,
5026                    sensor_status: SensorStatus::Unavailable {
5027                        reason: "no test runner".into(),
5028                    },
5029                    output: None,
5030                },
5031            ],
5032        };
5033
5034        // Compute V_boot the same way verification.rs does
5035        let mut energy = EnergyComponents::default();
5036        for so in &vr.stage_outcomes {
5037            match &so.sensor_status {
5038                SensorStatus::Unavailable { .. } => energy.v_boot += 3.0,
5039                SensorStatus::Fallback { .. } => energy.v_boot += 1.0,
5040                SensorStatus::Available => {}
5041            }
5042        }
5043        // 1 fallback (+1.0) + 1 unavailable (+3.0) = 4.0
5044        assert!(
5045            (energy.v_boot - 4.0).abs() < f32::EPSILON,
5046            "Expected V_boot=4.0, got {}",
5047            energy.v_boot
5048        );
5049    }
5050
5051    // ── Stub detection tests ──────────────────────────────────────────
5052
5053    #[test]
5054    fn test_detect_stub_rust_todo() {
5055        let dir = tempfile::tempdir().unwrap();
5056        let path = dir.path().join("lib.rs");
5057        std::fs::write(&path, "fn main() {\n    todo!()\n}\n").unwrap();
5058        let result = detect_stub_content(&path, "rust");
5059        assert!(result.is_some(), "Should detect todo!() stub");
5060        assert!(result.unwrap().contains("todo!()"));
5061    }
5062
5063    #[test]
5064    fn test_detect_stub_rust_unimplemented() {
5065        let dir = tempfile::tempdir().unwrap();
5066        let path = dir.path().join("lib.rs");
5067        std::fs::write(&path, "fn run() {\n    unimplemented!()\n}\n").unwrap();
5068        let result = detect_stub_content(&path, "rust");
5069        assert!(result.is_some(), "Should detect unimplemented!() stub");
5070    }
5071
5072    #[test]
5073    fn test_detect_stub_rust_real_code_not_flagged() {
5074        let dir = tempfile::tempdir().unwrap();
5075        let path = dir.path().join("lib.rs");
5076        let real_code = r#"
5077use std::collections::HashMap;
5078
5079fn add(a: i32, b: i32) -> i32 {
5080    a + b
5081}
5082
5083fn multiply(a: i32, b: i32) -> i32 {
5084    a * b
5085}
5086
5087fn compute(data: &[i32]) -> i32 {
5088    data.iter().sum()
5089}
5090
5091fn transform(input: &str) -> String {
5092    input.to_uppercase()
5093}
5094
5095fn process() {
5096    let x = add(1, 2);
5097    let y = multiply(x, 3);
5098    println!("{}", y);
5099    // todo!() in a comment should not trigger
5100}
5101"#;
5102        std::fs::write(&path, real_code).unwrap();
5103        let result = detect_stub_content(&path, "rust");
5104        assert!(
5105            result.is_none(),
5106            "Real code with comment-only todo should not be flagged"
5107        );
5108    }
5109
5110    #[test]
5111    fn test_detect_stub_rust_real_code_with_one_todo_branch() {
5112        let dir = tempfile::tempdir().unwrap();
5113        let path = dir.path().join("lib.rs");
5114        let code = r#"
5115fn add(a: i32, b: i32) -> i32 { a + b }
5116fn sub(a: i32, b: i32) -> i32 { a - b }
5117fn mul(a: i32, b: i32) -> i32 { a * b }
5118fn div(a: i32, b: i32) -> i32 { a / b }
5119fn modulo(a: i32, b: i32) -> i32 { todo!() }
5120"#;
5121        std::fs::write(&path, code).unwrap();
5122        let result = detect_stub_content(&path, "rust");
5123        assert!(
5124            result.is_none(),
5125            "File with 5+ real lines and one todo!() should NOT be flagged"
5126        );
5127    }
5128
5129    #[test]
5130    fn test_detect_stub_python_pass_body() {
5131        let dir = tempfile::tempdir().unwrap();
5132        let path = dir.path().join("main.py");
5133        std::fs::write(&path, "def run():\n    pass\n").unwrap();
5134        let result = detect_stub_content(&path, "python");
5135        assert!(result.is_some(), "Should detect pass-only Python function");
5136    }
5137
5138    #[test]
5139    fn test_detect_stub_python_not_implemented() {
5140        let dir = tempfile::tempdir().unwrap();
5141        let path = dir.path().join("main.py");
5142        std::fs::write(&path, "def run():\n    raise NotImplementedError()\n").unwrap();
5143        let result = detect_stub_content(&path, "python");
5144        assert!(result.is_some(), "Should detect NotImplementedError stub");
5145    }
5146
5147    #[test]
5148    fn test_detect_stub_python_ellipsis_body() {
5149        let dir = tempfile::tempdir().unwrap();
5150        let path = dir.path().join("main.py");
5151        std::fs::write(&path, "def run():\n    ...\n").unwrap();
5152        let result = detect_stub_content(&path, "python");
5153        assert!(
5154            result.is_some(),
5155            "Should detect ellipsis-only Python function"
5156        );
5157    }
5158
5159    #[test]
5160    fn test_detect_stub_python_real_code_not_flagged() {
5161        let dir = tempfile::tempdir().unwrap();
5162        let path = dir.path().join("main.py");
5163        let code = "import os\n\ndef run():\n    data = os.listdir('.')\n    filtered = [f for f in data if f.endswith('.py')]\n    for f in filtered:\n        print(f)\n    return filtered\n";
5164        std::fs::write(&path, code).unwrap();
5165        let result = detect_stub_content(&path, "python");
5166        assert!(result.is_none(), "Real Python code should not be flagged");
5167    }
5168
5169    #[test]
5170    fn test_detect_stub_js_throw_not_implemented() {
5171        let dir = tempfile::tempdir().unwrap();
5172        let path = dir.path().join("index.js");
5173        std::fs::write(
5174            &path,
5175            "function run() {\n  throw new Error(\"not implemented\");\n}\n",
5176        )
5177        .unwrap();
5178        let result = detect_stub_content(&path, "javascript");
5179        assert!(
5180            result.is_some(),
5181            "Should detect JS throw not-implemented stub"
5182        );
5183    }
5184
5185    #[test]
5186    fn test_detect_stub_universal_comment() {
5187        let dir = tempfile::tempdir().unwrap();
5188        let path = dir.path().join("lib.rs");
5189        std::fs::write(&path, "// stub — will be replaced by agent\n").unwrap();
5190        let result = detect_stub_content(&path, "rust");
5191        assert!(result.is_some(), "Should detect universal stub comment");
5192    }
5193
5194    #[test]
5195    fn test_detect_stub_extension_fallback() {
5196        let dir = tempfile::tempdir().unwrap();
5197        let path = dir.path().join("main.py");
5198        std::fs::write(&path, "# placeholder\ndef run():\n    pass\n").unwrap();
5199        // Use "unknown" plugin hint — should fall back to .py extension
5200        let result = detect_stub_content(&path, "unknown");
5201        assert!(
5202            result.is_some(),
5203            "Should detect stub via extension fallback"
5204        );
5205    }
5206
5207    #[test]
5208    fn test_detect_stub_empty_file_returns_none() {
5209        let dir = tempfile::tempdir().unwrap();
5210        let path = dir.path().join("empty.rs");
5211        std::fs::write(&path, "").unwrap();
5212        // detect_stub_content focuses on stub patterns, not emptiness
5213        // (emptiness is handled by the metadata check in sheaf_pre_check)
5214        let result = detect_stub_content(&path, "rust");
5215        assert!(result.is_none(), "Empty file has no stub pattern to match");
5216    }
5217}