Skip to main content

perspt_agent/
orchestrator.rs

1//! SRBN Orchestrator
2//!
3//! Manages the Task DAG and orchestrates agent execution following the 7-step control loop.
4
5use crate::agent::{ActuatorAgent, Agent, ArchitectAgent, SpeculatorAgent, VerifierAgent};
6use crate::context_retriever::ContextRetriever;
7use crate::lsp::LspClient;
8use crate::test_runner::{self, PythonTestRunner, TestResults};
9use crate::tools::{AgentTools, ToolCall};
10use crate::types::{AgentContext, EnergyComponents, ModelTier, NodeState, SRBNNode, TaskPlan};
11use anyhow::{Context, Result};
12use perspt_core::types::{
13    EscalationCategory, EscalationReport, NodeClass, ProvisionalBranch, ProvisionalBranchState,
14    RewriteAction, RewriteRecord, SheafValidationResult, SheafValidatorClass, WorkspaceState,
15};
16use petgraph::graph::{DiGraph, NodeIndex};
17use petgraph::visit::{EdgeRef, Topo, Walker};
18use std::collections::HashMap;
19use std::path::PathBuf;
20use std::time::Instant;
21
22/// Dependency edge type
23#[derive(Debug, Clone)]
24pub struct Dependency {
25    /// Dependency type description
26    pub kind: String,
27}
28
29/// Result of an approval request
30#[derive(Debug, Clone)]
31pub enum ApprovalResult {
32    /// User approved the action
33    Approved,
34    /// User approved with an edited value (e.g., project name)
35    ApprovedWithEdit(String),
36    /// User rejected the action
37    Rejected,
38}
39
40/// The SRBN Orchestrator - manages the agent workflow
41pub struct SRBNOrchestrator {
42    /// Task DAG managed by petgraph
43    pub graph: DiGraph<SRBNNode, Dependency>,
44    /// Node ID to graph index mapping
45    node_indices: HashMap<String, NodeIndex>,
46    /// Agent context
47    pub context: AgentContext,
48    /// Auto-approve mode
49    pub auto_approve: bool,
50    /// LSP clients per language
51    lsp_clients: HashMap<String, LspClient>,
52    /// Agents for different roles
53    agents: Vec<Box<dyn Agent>>,
54    /// Agent tools for file/command operations
55    tools: AgentTools,
56    /// Last written file path (for LSP tracking)
57    last_written_file: Option<PathBuf>,
58    /// File version counter for LSP
59    file_version: i32,
60    /// LLM provider for correction calls
61    provider: std::sync::Arc<perspt_core::llm_provider::GenAIProvider>,
62    /// Architect model name for planning
63    architect_model: String,
64    /// Actuator model name for corrections
65    actuator_model: String,
66    /// Verifier model name for correction guidance
67    verifier_model: String,
68    /// Speculator model name for lookahead hints
69    speculator_model: String,
70    /// PSP-5: Fallback model for Architect tier (used when primary fails structured-output contract)
71    architect_fallback_model: Option<String>,
72    /// PSP-5: Fallback model for Actuator tier
73    actuator_fallback_model: Option<String>,
74    /// PSP-5: Fallback model for Verifier tier
75    verifier_fallback_model: Option<String>,
76    /// PSP-5: Fallback model for Speculator tier
77    speculator_fallback_model: Option<String>,
78    /// Event sender for TUI updates (optional)
79    event_sender: Option<perspt_core::events::channel::EventSender>,
80    /// Action receiver for TUI commands (optional)
81    action_receiver: Option<perspt_core::events::channel::ActionReceiver>,
82    /// Persistence ledger
83    pub ledger: crate::ledger::MerkleLedger,
84    /// Last tool failure message (for energy calculation)
85    pub last_tool_failure: Option<String>,
86    /// PSP-5 Phase 3: Last assembled context provenance (for commit recording)
87    last_context_provenance: Option<perspt_core::types::ContextProvenance>,
88    /// PSP-5 Phase 3: Last formatted context from restriction map (for correction prompts)
89    last_formatted_context: String,
90    /// PSP-5 Phase 4: Last plugin-driven verification result (for convergence checks)
91    last_verification_result: Option<perspt_core::types::VerificationResult>,
92    /// PSP-5 Phase 9: Last applied artifact bundle (for persistence in step_commit)
93    last_applied_bundle: Option<perspt_core::types::ArtifactBundle>,
94    /// PSP-5 Phase 6: Blocked dependencies awaiting parent interface seals
95    blocked_dependencies: Vec<perspt_core::types::BlockedDependency>,
96}
97
98/// Get current timestamp as epoch seconds.
99fn epoch_seconds() -> i64 {
100    use std::time::{SystemTime, UNIX_EPOCH};
101    SystemTime::now()
102        .duration_since(UNIX_EPOCH)
103        .unwrap()
104        .as_secs() as i64
105}
106
107impl SRBNOrchestrator {
108    /// Create a new orchestrator with default models
109    pub fn new(working_dir: PathBuf, auto_approve: bool) -> Self {
110        Self::new_with_models(
111            working_dir,
112            auto_approve,
113            None,
114            None,
115            None,
116            None,
117            None,
118            None,
119            None,
120            None,
121        )
122    }
123
124    /// Create a new orchestrator with custom model configuration
125    #[allow(clippy::too_many_arguments)]
126    pub fn new_with_models(
127        working_dir: PathBuf,
128        auto_approve: bool,
129        architect_model: Option<String>,
130        actuator_model: Option<String>,
131        verifier_model: Option<String>,
132        speculator_model: Option<String>,
133        architect_fallback_model: Option<String>,
134        actuator_fallback_model: Option<String>,
135        verifier_fallback_model: Option<String>,
136        speculator_fallback_model: Option<String>,
137    ) -> Self {
138        let context = AgentContext {
139            working_dir: working_dir.clone(),
140            auto_approve,
141            ..Default::default()
142        };
143
144        // Create a shared LLM provider - agents will use this for LLM calls
145        // In production, this would be configured from environment/config
146        let provider = std::sync::Arc::new(
147            perspt_core::llm_provider::GenAIProvider::new().unwrap_or_else(|e| {
148                log::warn!("Failed to create GenAIProvider: {}, using default", e);
149                perspt_core::llm_provider::GenAIProvider::new().expect("GenAI must initialize")
150            }),
151        );
152
153        // Create agent tools for file/command operations
154        let tools = AgentTools::new(working_dir.clone(), !auto_approve);
155
156        // Store model names for direct LLM calls
157        let stored_architect_model = architect_model
158            .clone()
159            .unwrap_or_else(|| ModelTier::Architect.default_model().to_string());
160        let stored_actuator_model = actuator_model
161            .clone()
162            .unwrap_or_else(|| ModelTier::Actuator.default_model().to_string());
163        let stored_verifier_model = verifier_model
164            .clone()
165            .unwrap_or_else(|| ModelTier::Verifier.default_model().to_string());
166        let stored_speculator_model = speculator_model
167            .clone()
168            .unwrap_or_else(|| ModelTier::Speculator.default_model().to_string());
169
170        Self {
171            graph: DiGraph::new(),
172            node_indices: HashMap::new(),
173            context,
174            auto_approve,
175            lsp_clients: HashMap::new(),
176            agents: vec![
177                Box::new(ArchitectAgent::new(provider.clone(), architect_model)),
178                Box::new(ActuatorAgent::new(provider.clone(), actuator_model)),
179                Box::new(VerifierAgent::new(provider.clone(), verifier_model)),
180                Box::new(SpeculatorAgent::new(provider.clone(), speculator_model)),
181            ],
182            tools,
183            last_written_file: None,
184            file_version: 0,
185            provider,
186            architect_model: stored_architect_model,
187            actuator_model: stored_actuator_model,
188            verifier_model: stored_verifier_model,
189            speculator_model: stored_speculator_model,
190            architect_fallback_model,
191            actuator_fallback_model,
192            verifier_fallback_model,
193            speculator_fallback_model,
194            event_sender: None,
195            action_receiver: None,
196            #[cfg(test)]
197            ledger: crate::ledger::MerkleLedger::in_memory().expect("Failed to create test ledger"),
198            #[cfg(not(test))]
199            ledger: crate::ledger::MerkleLedger::new().expect("Failed to create ledger"),
200            last_tool_failure: None,
201            last_context_provenance: None,
202            last_formatted_context: String::new(),
203            last_verification_result: None,
204            last_applied_bundle: None,
205            blocked_dependencies: Vec::new(),
206        }
207    }
208
209    /// Create a new orchestrator for testing with an in-memory ledger
210    #[cfg(test)]
211    pub fn new_for_testing(working_dir: PathBuf) -> Self {
212        let context = AgentContext {
213            working_dir: working_dir.clone(),
214            auto_approve: true,
215            ..Default::default()
216        };
217
218        let provider = std::sync::Arc::new(
219            perspt_core::llm_provider::GenAIProvider::new().unwrap_or_else(|e| {
220                log::warn!("Failed to create GenAIProvider: {}, using default", e);
221                perspt_core::llm_provider::GenAIProvider::new().expect("GenAI must initialize")
222            }),
223        );
224
225        let tools = AgentTools::new(working_dir.clone(), false);
226
227        Self {
228            graph: DiGraph::new(),
229            node_indices: HashMap::new(),
230            context,
231            auto_approve: true,
232            lsp_clients: HashMap::new(),
233            agents: vec![
234                Box::new(ArchitectAgent::new(provider.clone(), None)),
235                Box::new(ActuatorAgent::new(provider.clone(), None)),
236                Box::new(VerifierAgent::new(provider.clone(), None)),
237                Box::new(SpeculatorAgent::new(provider.clone(), None)),
238            ],
239            tools,
240            last_written_file: None,
241            file_version: 0,
242            provider,
243            architect_model: ModelTier::Architect.default_model().to_string(),
244            actuator_model: ModelTier::Actuator.default_model().to_string(),
245            verifier_model: ModelTier::Verifier.default_model().to_string(),
246            speculator_model: ModelTier::Speculator.default_model().to_string(),
247            architect_fallback_model: None,
248            actuator_fallback_model: None,
249            verifier_fallback_model: None,
250            speculator_fallback_model: None,
251            event_sender: None,
252            action_receiver: None,
253            ledger: crate::ledger::MerkleLedger::in_memory().expect("Failed to create test ledger"),
254            last_tool_failure: None,
255            last_context_provenance: None,
256            last_formatted_context: String::new(),
257            last_verification_result: None,
258            last_applied_bundle: None,
259            blocked_dependencies: Vec::new(),
260        }
261    }
262
263    /// Add a node to the task DAG
264    pub fn add_node(&mut self, node: SRBNNode) -> NodeIndex {
265        let node_id = node.node_id.clone();
266        let idx = self.graph.add_node(node);
267        self.node_indices.insert(node_id, idx);
268        idx
269    }
270
271    /// Connect TUI channels for interactive control
272    pub fn connect_tui(
273        &mut self,
274        event_sender: perspt_core::events::channel::EventSender,
275        action_receiver: perspt_core::events::channel::ActionReceiver,
276    ) {
277        self.tools.set_event_sender(event_sender.clone());
278        self.event_sender = Some(event_sender);
279        self.action_receiver = Some(action_receiver);
280    }
281
282    // =========================================================================
283    // PSP-5 Phase 8: Session Rehydration for Resume
284    // =========================================================================
285
286    /// Rehydrate the orchestrator from a persisted session, rebuilding the
287    /// DAG from stored node snapshots and graph edges.
288    ///
289    /// Terminal nodes (Completed, Failed, Aborted) will be skipped during
290    /// the subsequent `run_resumed()` execution. Non-terminal nodes are
291    /// placed back in their persisted state so the executor can continue
292    /// from the last durable boundary.
293    ///
294    /// Returns `Ok(snapshot)` with the loaded session snapshot on success,
295    /// or an error when the session cannot be reconstructed.
296    pub fn rehydrate_session(
297        &mut self,
298        session_id: &str,
299    ) -> Result<crate::ledger::SessionSnapshot> {
300        // Attach the ledger to this session so facades read the right data
301        self.context.session_id = session_id.to_string();
302        self.ledger.current_session = Some(crate::ledger::SessionRecordLegacy {
303            session_id: session_id.to_string(),
304            task: String::new(),
305            started_at: epoch_seconds(),
306            ended_at: None,
307            status: "RESUMING".to_string(),
308            total_nodes: 0,
309            completed_nodes: 0,
310        });
311
312        let snapshot = self.ledger.load_session_snapshot()?;
313
314        // PSP-5 Phase 8: Corruption / backward-compatibility checks
315        if snapshot.node_details.is_empty() {
316            anyhow::bail!(
317                "Session {} has no persisted nodes — cannot resume",
318                session_id
319            );
320        }
321
322        // Detect orphaned edges (references to nodes not in snapshot)
323        let node_ids: std::collections::HashSet<&str> = snapshot
324            .node_details
325            .iter()
326            .map(|d| d.record.node_id.as_str())
327            .collect();
328        let orphaned_edges = snapshot
329            .graph_edges
330            .iter()
331            .filter(|e| {
332                !node_ids.contains(e.parent_node_id.as_str())
333                    || !node_ids.contains(e.child_node_id.as_str())
334            })
335            .count();
336        if orphaned_edges > 0 {
337            log::warn!(
338                "Session {} has {} orphaned edge(s) referencing unknown nodes — \
339                 edges will be dropped during resume",
340                session_id,
341                orphaned_edges
342            );
343            self.emit_log(format!(
344                "⚠️ Resume: dropping {} orphaned graph edge(s)",
345                orphaned_edges
346            ));
347        }
348
349        // Rebuild graph: first add all nodes
350        let mut node_map: HashMap<String, NodeIndex> = HashMap::new();
351
352        for detail in &snapshot.node_details {
353            let rec = &detail.record;
354
355            let state = parse_node_state(&rec.state);
356            let node_class = rec
357                .node_class
358                .as_deref()
359                .map(parse_node_class)
360                .unwrap_or_default();
361
362            let mut node = SRBNNode::new(
363                rec.node_id.clone(),
364                rec.goal.clone().unwrap_or_default(),
365                ModelTier::Actuator,
366            );
367            node.state = state;
368            node.node_class = node_class;
369            node.owner_plugin = rec.owner_plugin.clone().unwrap_or_default();
370            node.parent_id = rec.parent_id.clone();
371            node.children = rec
372                .children
373                .as_deref()
374                .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
375                .unwrap_or_default();
376            node.monitor.attempt_count = rec.attempt_count as usize;
377
378            // Restore latest energy if available
379            if let Some(last_energy) = detail.energy_history.last() {
380                node.monitor.energy_history.push(last_energy.v_total);
381            }
382
383            // Restore interface seal hash from persisted seals
384            if let Some(seal) = detail.interface_seals.last() {
385                if seal.seal_hash.len() == 32 {
386                    let mut hash = [0u8; 32];
387                    hash.copy_from_slice(&seal.seal_hash);
388                    node.interface_seal_hash = Some(hash);
389                }
390            }
391
392            let idx = self.add_node(node);
393            node_map.insert(rec.node_id.clone(), idx);
394        }
395
396        // Rebuild edges from persisted graph topology
397        for edge in &snapshot.graph_edges {
398            if let (Some(&from_idx), Some(&to_idx)) = (
399                node_map.get(&edge.parent_node_id),
400                node_map.get(&edge.child_node_id),
401            ) {
402                self.graph.add_edge(
403                    from_idx,
404                    to_idx,
405                    Dependency {
406                        kind: edge.edge_type.clone(),
407                    },
408                );
409            }
410        }
411
412        // Restore blocked dependencies from non-completed parents of Interface class
413        for (child_id, &child_idx) in &node_map {
414            let parents: Vec<NodeIndex> = self
415                .graph
416                .neighbors_directed(child_idx, petgraph::Direction::Incoming)
417                .collect();
418
419            for parent_idx in parents {
420                let parent = &self.graph[parent_idx];
421                if parent.node_class == NodeClass::Interface
422                    && parent.interface_seal_hash.is_none()
423                    && !parent.state.is_terminal()
424                {
425                    self.blocked_dependencies
426                        .push(perspt_core::types::BlockedDependency {
427                            child_node_id: child_id.clone(),
428                            parent_node_id: parent.node_id.clone(),
429                            required_seal_paths: Vec::new(),
430                            blocked_at: epoch_seconds(),
431                        });
432                }
433            }
434        }
435
436        let terminal = snapshot
437            .node_details
438            .iter()
439            .filter(|d| {
440                let s = parse_node_state(&d.record.state);
441                s.is_terminal()
442            })
443            .count();
444        let resumable = snapshot.node_details.len() - terminal;
445
446        log::info!(
447            "Rehydrated session {}: {} nodes ({} terminal, {} resumable), {} edges",
448            session_id,
449            snapshot.node_details.len(),
450            terminal,
451            resumable,
452            snapshot.graph_edges.len()
453        );
454
455        // Update legacy session tracker
456        if let Some(ref mut sess) = self.ledger.current_session {
457            sess.total_nodes = snapshot.node_details.len();
458            sess.completed_nodes = terminal;
459            sess.status = "RUNNING".to_string();
460        }
461
462        // PSP-5 Phase 3: Validate context provenance for non-terminal nodes.
463        // Check that files referenced in persisted provenance still exist on
464        // disk so the resumed run has a chance to rebuild equivalent context.
465        for detail in &snapshot.node_details {
466            let state = parse_node_state(&detail.record.state);
467            if state.is_terminal() {
468                continue;
469            }
470
471            if let Some(ref prov) = detail.context_provenance {
472                let retriever = ContextRetriever::new(self.context.working_dir.clone());
473                let drift = retriever.validate_provenance_record(prov);
474                if !drift.is_empty() {
475                    log::warn!(
476                        "Provenance drift for node '{}': {} file(s) missing: {}",
477                        detail.record.node_id,
478                        drift.len(),
479                        drift.join(", ")
480                    );
481                    self.emit_log(format!(
482                        "⚠️ Provenance drift: node '{}' has {} missing file(s)",
483                        detail.record.node_id,
484                        drift.len()
485                    ));
486                    self.emit_event(perspt_core::AgentEvent::ProvenanceDrift {
487                        node_id: detail.record.node_id.clone(),
488                        missing_files: drift,
489                        reason: "Files referenced in persisted context no longer exist".to_string(),
490                    });
491                }
492            }
493        }
494
495        Ok(snapshot)
496    }
497
498    /// Resume execution from a rehydrated session.
499    ///
500    /// Walks the DAG in topological order, skipping terminal nodes and
501    /// executing any node whose state is not completed/failed/aborted.
502    /// Emits a differential resume summary so users can see what will
503    /// be replayed vs. skipped.
504    pub async fn run_resumed(&mut self) -> Result<()> {
505        let topo = Topo::new(&self.graph);
506        let indices: Vec<_> = topo.iter(&self.graph).collect();
507        let total_nodes = indices.len();
508        let mut executed = 0;
509
510        // PSP-5 Phase 8: Emit differential resume summary
511        let terminal_count = indices
512            .iter()
513            .filter(|i| self.graph[**i].state.is_terminal())
514            .count();
515        let blocked_count = indices
516            .iter()
517            .filter(|i| !self.graph[**i].state.is_terminal() && self.check_seal_prerequisites(**i))
518            .count();
519        let resumable_count = total_nodes - terminal_count - blocked_count;
520        self.emit_log(format!(
521            "📊 Differential resume: {} total, {} skipped (terminal), {} blocked (seal), {} to execute",
522            total_nodes, terminal_count, blocked_count, resumable_count
523        ));
524
525        for (i, idx) in indices.iter().enumerate() {
526            let node = &self.graph[*idx];
527
528            // Skip terminal nodes
529            if node.state.is_terminal() {
530                log::debug!("Skipping terminal node {} ({:?})", node.node_id, node.state);
531                continue;
532            }
533
534            // Check seal prerequisites
535            if self.check_seal_prerequisites(*idx) {
536                log::warn!(
537                    "Node {} blocked on seal prerequisite — skipping",
538                    self.graph[*idx].node_id
539                );
540                continue;
541            }
542
543            let node = &self.graph[*idx];
544            self.emit_log(format!(
545                "📝 [resume {}/{}] {}",
546                i + 1,
547                total_nodes,
548                node.goal
549            ));
550            self.emit_event(perspt_core::AgentEvent::NodeSelected {
551                node_id: node.node_id.clone(),
552                goal: node.goal.clone(),
553                node_class: node.node_class.to_string(),
554            });
555            self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
556                node_id: node.node_id.clone(),
557                status: perspt_core::NodeStatus::Running,
558            });
559
560            match self.execute_node(*idx).await {
561                Ok(()) => {
562                    if let Some(node) = self.graph.node_weight(*idx) {
563                        self.emit_event(perspt_core::AgentEvent::NodeCompleted {
564                            node_id: node.node_id.clone(),
565                            goal: node.goal.clone(),
566                        });
567                    }
568                    executed += 1;
569                }
570                Err(e) => {
571                    let node_id = self.graph[*idx].node_id.clone();
572                    log::error!("Node {} failed on resume: {}", node_id, e);
573                    self.emit_log(format!("❌ Node {} failed: {}", node_id, e));
574                    self.graph[*idx].state = NodeState::Escalated;
575                    self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
576                        node_id,
577                        status: perspt_core::NodeStatus::Escalated,
578                    });
579                    continue;
580                }
581            }
582        }
583
584        log::info!(
585            "Resumed execution completed: {} of {} nodes executed",
586            executed,
587            total_nodes
588        );
589        self.emit_event(perspt_core::AgentEvent::Complete {
590            success: true,
591            message: format!("Resumed: executed {} of {} nodes", executed, total_nodes),
592        });
593        Ok(())
594    }
595
596    /// Emit an event to the TUI (if connected)
597    fn emit_event(&self, event: perspt_core::AgentEvent) {
598        if let Some(ref sender) = self.event_sender {
599            let _ = sender.send(event);
600        }
601    }
602
603    /// Emit a log message to TUI
604    fn emit_log(&self, msg: impl Into<String>) {
605        self.emit_event(perspt_core::AgentEvent::Log(msg.into()));
606    }
607
608    /// Request approval from user and await response
609    /// Returns ApprovalResult with optional edited value.
610    /// `review_node_id` is used for persisting the review audit record.
611    async fn await_approval(
612        &mut self,
613        action_type: perspt_core::ActionType,
614        description: String,
615        diff: Option<String>,
616    ) -> ApprovalResult {
617        self.await_approval_for_node(action_type, description, diff, None)
618            .await
619    }
620
621    /// Internal approval with optional node_id for audit persistence.
622    async fn await_approval_for_node(
623        &mut self,
624        action_type: perspt_core::ActionType,
625        description: String,
626        diff: Option<String>,
627        review_node_id: Option<&str>,
628    ) -> ApprovalResult {
629        // If auto_approve is enabled, skip approval
630        if self.auto_approve {
631            if let Some(nid) = review_node_id {
632                self.persist_review_decision(nid, "auto_approved", None);
633            }
634            return ApprovalResult::Approved;
635        }
636
637        // If no TUI connected, default to approve (headless with --yes)
638        if self.action_receiver.is_none() {
639            if let Some(nid) = review_node_id {
640                self.persist_review_decision(nid, "auto_approved", None);
641            }
642            return ApprovalResult::Approved;
643        }
644
645        // Generate unique request ID
646        let request_id = uuid::Uuid::new_v4().to_string();
647
648        // Emit approval request
649        self.emit_event(perspt_core::AgentEvent::ApprovalRequest {
650            request_id: request_id.clone(),
651            node_id: review_node_id.unwrap_or("current").to_string(),
652            action_type,
653            description,
654            diff,
655        });
656
657        // Wait for response
658        if let Some(ref mut receiver) = self.action_receiver {
659            while let Some(action) = receiver.recv().await {
660                match action {
661                    perspt_core::AgentAction::Approve { request_id: rid } if rid == request_id => {
662                        self.emit_log("✓ Approved by user");
663                        if let Some(nid) = review_node_id {
664                            self.persist_review_decision(nid, "approved", None);
665                        }
666                        return ApprovalResult::Approved;
667                    }
668                    perspt_core::AgentAction::ApproveWithEdit {
669                        request_id: rid,
670                        edited_value,
671                    } if rid == request_id => {
672                        self.emit_log(format!("✓ Approved with edit: {}", edited_value));
673                        if let Some(nid) = review_node_id {
674                            self.persist_review_decision(nid, "approved_with_edit", None);
675                        }
676                        return ApprovalResult::ApprovedWithEdit(edited_value);
677                    }
678                    perspt_core::AgentAction::Reject {
679                        request_id: rid,
680                        reason,
681                    } if rid == request_id => {
682                        let msg = reason.unwrap_or_else(|| "User rejected".to_string());
683                        self.emit_log(format!("✗ Rejected: {}", msg));
684                        if let Some(nid) = review_node_id {
685                            self.persist_review_decision(nid, "rejected", Some(&msg));
686                        }
687                        return ApprovalResult::Rejected;
688                    }
689                    perspt_core::AgentAction::RequestCorrection {
690                        request_id: rid,
691                        feedback,
692                    } if rid == request_id => {
693                        self.emit_log(format!("🔄 Correction requested: {}", feedback));
694                        if let Some(nid) = review_node_id {
695                            self.persist_review_decision(
696                                nid,
697                                "correction_requested",
698                                Some(&feedback),
699                            );
700                        }
701                        return ApprovalResult::Rejected;
702                    }
703                    perspt_core::AgentAction::Abort => {
704                        self.emit_log("⚠️ Session aborted by user");
705                        if let Some(nid) = review_node_id {
706                            self.persist_review_decision(nid, "aborted", None);
707                        }
708                        return ApprovalResult::Rejected;
709                    }
710                    _ => {
711                        // Ignore other actions while waiting for this specific approval
712                        continue;
713                    }
714                }
715            }
716        }
717
718        ApprovalResult::Rejected // Channel closed
719    }
720
721    /// Persist a review decision to the audit trail.
722    fn persist_review_decision(&self, node_id: &str, outcome: &str, note: Option<&str>) {
723        let degraded = self.last_verification_result.as_ref().map(|vr| vr.degraded);
724        if let Err(e) = self
725            .ledger
726            .record_review_outcome(node_id, outcome, note, None, degraded, None)
727        {
728            log::warn!("Failed to persist review decision for {}: {}", node_id, e);
729        }
730    }
731
732    /// Add a dependency edge between nodes
733    pub fn add_dependency(&mut self, from_id: &str, to_id: &str, kind: &str) -> Result<()> {
734        let from_idx = self
735            .node_indices
736            .get(from_id)
737            .context(format!("Node not found: {}", from_id))?;
738        let to_idx = self
739            .node_indices
740            .get(to_id)
741            .context(format!("Node not found: {}", to_id))?;
742
743        self.graph.add_edge(
744            *from_idx,
745            *to_idx,
746            Dependency {
747                kind: kind.to_string(),
748            },
749        );
750        Ok(())
751    }
752
753    /// Run the complete SRBN control loop
754    pub async fn run(&mut self, task: String) -> Result<()> {
755        log::info!("Starting SRBN execution for task: {}", task);
756        self.emit_log(format!("🚀 Starting task: {}", task));
757
758        // Step 0: Start session first
759        let session_id = uuid::Uuid::new_v4().to_string();
760        self.context.session_id = session_id.clone();
761        self.ledger.start_session(
762            &session_id,
763            &task,
764            &self.context.working_dir.to_string_lossy(),
765        )?;
766
767        // Log that LLM request logging is enabled (persistence happens immediately per-request)
768        if self.context.log_llm {
769            self.emit_log("📝 LLM request logging enabled".to_string());
770        }
771
772        // PSP-5: Detect execution mode (Project is default, Solo only on explicit keywords)
773        let execution_mode = self.detect_execution_mode(&task);
774        self.context.execution_mode = execution_mode;
775        self.emit_log(format!("🎯 Execution mode: {}", execution_mode));
776
777        if execution_mode == perspt_core::types::ExecutionMode::Solo {
778            // Solo Mode: Single-file execution without DAG
779            log::info!("Using Solo Mode for explicit single-file task");
780            self.emit_log("⚡ Solo Mode: Single-file execution".to_string());
781            return self.run_solo_mode(task).await;
782        }
783
784        // PSP-5: Classify workspace state before deciding plugin/init strategy
785        let workspace_state = self.classify_workspace(&task);
786        self.context.workspace_state = workspace_state.clone();
787        self.emit_log(format!("📋 Workspace: {}", workspace_state));
788
789        // For existing projects, detect plugins and probe verifier readiness now.
790        // For greenfield/ambiguous, defer until after step_init_project().
791        if let WorkspaceState::ExistingProject { ref plugins } = workspace_state {
792            self.context.active_plugins = plugins.clone();
793            self.emit_log(format!("🔌 Detected plugins: {}", plugins.join(", ")));
794            self.emit_plugin_readiness();
795        }
796
797        // Team Mode: Full project initialization and DAG sheafification
798        self.step_init_project(&task).await?;
799
800        // PSP-5: For greenfield/ambiguous workspaces, re-detect plugins after init
801        // and probe verifier readiness against the newly initialized project.
802        if !matches!(workspace_state, WorkspaceState::ExistingProject { .. }) {
803            self.redetect_plugins_after_init();
804        }
805
806        // Start LSP for detected plugins (after classification + init so we
807        // use the authoritative plugin set, not a provisional one).
808        {
809            let plugin_refs: Vec<String> = self.context.active_plugins.clone();
810            let refs: Vec<&str> = plugin_refs.iter().map(|s| s.as_str()).collect();
811            if !refs.is_empty() {
812                self.emit_log("🔍 Starting language servers...".to_string());
813                if let Err(e) = self.start_lsp_for_plugins(&refs).await {
814                    log::warn!("Failed to start LSP: {}", e);
815                    self.emit_log("⚠️ Continuing without LSP".to_string());
816                } else {
817                    self.emit_log("✅ Language servers ready".to_string());
818                }
819            }
820        }
821
822        self.step_sheafify(task).await?;
823
824        // PSP-5: Emit PlanReady event after sheafification
825        let node_count = self.graph.node_count();
826        self.emit_event(perspt_core::AgentEvent::PlanReady {
827            nodes: node_count,
828            plugins: self.context.active_plugins.clone(),
829            execution_mode: execution_mode.to_string(),
830        });
831
832        // Emit task nodes to TUI after sheafification
833        for node_id in self.node_indices.keys() {
834            if let Some(idx) = self.node_indices.get(node_id) {
835                if let Some(node) = self.graph.node_weight(*idx) {
836                    self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
837                        node_id: node.node_id.clone(),
838                        status: perspt_core::NodeStatus::Pending,
839                    });
840                }
841            }
842        }
843
844        // Step 2-7: Execute nodes in topological order
845        let topo = Topo::new(&self.graph);
846        let indices: Vec<_> = topo.iter(&self.graph).collect();
847        let total_nodes = indices.len();
848
849        for (i, idx) in indices.iter().enumerate() {
850            // PSP-5 Phase 6: Check if node is blocked on a parent interface seal.
851            // In the current sequential topo-order execution this should not fire
852            // (parents commit before children), but it establishes the gating
853            // contract for when speculative parallelism is introduced later.
854            if self.check_seal_prerequisites(*idx) {
855                log::warn!(
856                    "Node {} blocked on seal prerequisite — skipping in this iteration",
857                    self.graph[*idx].node_id
858                );
859                continue;
860            }
861
862            // PSP-5: Emit NodeSelected event before execution
863            if let Some(node) = self.graph.node_weight(*idx) {
864                self.emit_log(format!("📝 [{}/{}] {}", i + 1, total_nodes, node.goal));
865                self.emit_event(perspt_core::AgentEvent::NodeSelected {
866                    node_id: node.node_id.clone(),
867                    goal: node.goal.clone(),
868                    node_class: node.node_class.to_string(),
869                });
870                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
871                    node_id: node.node_id.clone(),
872                    status: perspt_core::NodeStatus::Running,
873                });
874            }
875
876            match self.execute_node(*idx).await {
877                Ok(()) => {
878                    // Emit completed status
879                    if let Some(node) = self.graph.node_weight(*idx) {
880                        self.emit_event(perspt_core::AgentEvent::NodeCompleted {
881                            node_id: node.node_id.clone(),
882                            goal: node.goal.clone(),
883                        });
884                    }
885                }
886                Err(e) => {
887                    let node_id = self.graph[*idx].node_id.clone();
888                    log::error!("Node {} failed: {}", node_id, e);
889                    self.emit_log(format!("❌ Node {} failed: {}", node_id, e));
890                    self.graph[*idx].state = NodeState::Escalated;
891                    self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
892                        node_id: node_id.clone(),
893                        status: perspt_core::NodeStatus::Escalated,
894                    });
895                    // Continue to next node instead of stopping all execution
896                    continue;
897                }
898            }
899        }
900
901        log::info!("SRBN execution completed");
902
903        // PSP-5 Phase 6: Clean up all session sandboxes
904        if let Err(e) = crate::tools::cleanup_session_sandboxes(
905            &self.context.working_dir,
906            &self.context.session_id,
907        ) {
908            log::warn!("Failed to clean up session sandboxes: {}", e);
909        }
910
911        self.emit_event(perspt_core::AgentEvent::Complete {
912            success: true,
913            message: format!("Completed {} nodes", total_nodes),
914        });
915        Ok(())
916    }
917
918    /// Step 0: Project Initialization
919    ///
920    /// Check that required OS and language tools are available before
921    /// running init commands. Emits install instructions for any missing tools.
922    ///
923    /// Returns `true` if all critical tools (needed for init) are present.
924    /// Optional tools (LSP, linters) emit warnings but don't block.
925    fn check_tool_prerequisites(&self, plugin: &dyn perspt_core::plugin::LanguagePlugin) -> bool {
926        // Common OS tools that perspt uses for context retrieval and code manipulation
927        let common_tools: &[(&str, &str)] = &[
928            (
929                "grep",
930                "Install coreutils: brew install grep (macOS) or apt install grep (Linux)",
931            ),
932            (
933                "sed",
934                "Install coreutils: brew install gnu-sed (macOS) or apt install sed (Linux)",
935            ),
936            (
937                "awk",
938                "Install coreutils: brew install gawk (macOS) or apt install gawk (Linux)",
939            ),
940        ];
941
942        let mut missing_critical = Vec::new();
943        let mut missing_optional = Vec::new();
944        let mut install_instructions = Vec::new();
945
946        // Check common OS tools
947        for (binary, hint) in common_tools {
948            if !perspt_core::plugin::host_binary_available(binary) {
949                missing_optional.push((*binary, "OS utility"));
950                install_instructions.push(format!("  {} ({}): {}", binary, "OS utility", hint));
951            }
952        }
953
954        // Check language-specific tools
955        let required = plugin.required_binaries();
956        for (binary, role, hint) in &required {
957            if !perspt_core::plugin::host_binary_available(binary) {
958                // init and build tools are critical; LSP and linters are optional
959                if *role == "language server" || role.contains("lint") {
960                    missing_optional.push((*binary, role));
961                } else {
962                    missing_critical.push((*binary, role));
963                }
964                install_instructions.push(format!("  {} ({}): {}", binary, role, hint));
965            }
966        }
967
968        // Emit results
969        if !missing_critical.is_empty() {
970            let names: Vec<String> = missing_critical
971                .iter()
972                .map(|(b, r)| format!("{} ({})", b, r))
973                .collect();
974            self.emit_log(format!("🚫 Missing critical tools: {}", names.join(", ")));
975        }
976        if !missing_optional.is_empty() {
977            let names: Vec<String> = missing_optional
978                .iter()
979                .map(|(b, r)| format!("{} ({})", b, r))
980                .collect();
981            self.emit_log(format!(
982                "⚠️ Missing optional tools (degraded mode): {}",
983                names.join(", ")
984            ));
985        }
986        if !install_instructions.is_empty() {
987            self.emit_log(format!(
988                "📋 Install instructions:\n{}",
989                install_instructions.join("\n")
990            ));
991        }
992
993        if missing_critical.is_empty() {
994            if missing_optional.is_empty() {
995                self.emit_log(format!("✅ All {} tools available", plugin.name()));
996            }
997            true
998        } else {
999            self.emit_log("❌ Cannot proceed with project initialization — install missing critical tools first.".to_string());
1000            false
1001        }
1002    }
1003
1004    /// Uses the pre-computed `WorkspaceState` to branch between:
1005    /// - ExistingProject: check tooling sync, gather context, never re-init.
1006    /// - Greenfield: create isolated project dir, run language-native init.
1007    /// - Ambiguous: create a child project dir to avoid polluting misc files.
1008    async fn step_init_project(&mut self, task: &str) -> Result<()> {
1009        let registry = perspt_core::plugin::PluginRegistry::new();
1010        log::info!(
1011            "step_init_project: workspace_state={}",
1012            self.context.workspace_state
1013        );
1014
1015        match self.context.workspace_state.clone() {
1016            WorkspaceState::ExistingProject { ref plugins } => {
1017                // Existing project — check tooling sync, never re-init
1018                let plugin_name = plugins.first().map(|s| s.as_str()).unwrap_or("");
1019                if let Some(plugin) = registry.get(plugin_name) {
1020                    self.emit_log(format!("📂 Detected existing {} project", plugin.name()));
1021
1022                    // Pre-flight: check required tools (non-blocking for existing projects)
1023                    self.check_tool_prerequisites(plugin);
1024
1025                    match plugin.check_tooling_action(&self.context.working_dir) {
1026                        perspt_core::plugin::ProjectAction::ExecCommand {
1027                            command,
1028                            description,
1029                        } => {
1030                            self.emit_log(format!("🔧 Tooling action needed: {}", description));
1031
1032                            let approval_result = self
1033                                .await_approval(
1034                                    perspt_core::ActionType::Command {
1035                                        command: command.clone(),
1036                                    },
1037                                    description.clone(),
1038                                    None,
1039                                )
1040                                .await;
1041
1042                            if matches!(
1043                                approval_result,
1044                                ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
1045                            ) {
1046                                let mut args = HashMap::new();
1047                                args.insert("command".to_string(), command.clone());
1048                                let call = ToolCall {
1049                                    name: "run_command".to_string(),
1050                                    arguments: args,
1051                                };
1052                                let result = self.tools.execute(&call).await;
1053                                if result.success {
1054                                    self.emit_log(format!("✅ {}", description));
1055                                } else {
1056                                    self.emit_log(format!("❌ Failed: {:?}", result.error));
1057                                }
1058                            }
1059                        }
1060                        perspt_core::plugin::ProjectAction::NoAction => {
1061                            self.emit_log("✓ Project tooling is up to date".to_string());
1062                        }
1063                    }
1064                }
1065            }
1066
1067            WorkspaceState::Greenfield { ref inferred_lang } => {
1068                let lang = match inferred_lang.as_deref() {
1069                    Some(l) => l,
1070                    None => {
1071                        // Try to infer from task at init time
1072                        match self.detect_language_from_task(task) {
1073                            Some(l) => l,
1074                            None => {
1075                                self.emit_log(
1076                                    "ℹ️ No language detected, skipping project init".to_string(),
1077                                );
1078                                return Ok(());
1079                            }
1080                        }
1081                    }
1082                };
1083
1084                // In Team/Project mode we always scaffold — the Solo check
1085                // already ran in run() and would have short-circuited.
1086                // Only consult the LLM heuristic when execution mode was not
1087                // explicitly set (defensive guard for future callers).
1088                if self.context.execution_mode == perspt_core::types::ExecutionMode::Solo {
1089                    self.emit_log("ℹ️ Solo mode, skipping project scaffolding.".to_string());
1090                    return Ok(());
1091                }
1092
1093                if let Some(plugin) = registry.get(lang) {
1094                    log::info!(
1095                        "step_init_project: Greenfield lang={}, initializing project",
1096                        lang
1097                    );
1098                    self.emit_log(format!("🌱 Initializing new {} project", lang));
1099
1100                    // Pre-flight: check required tools before attempting init
1101                    if !self.check_tool_prerequisites(plugin) {
1102                        log::warn!(
1103                            "step_init_project: tool prerequisites check failed for {}",
1104                            lang
1105                        );
1106                        return Ok(());
1107                    }
1108
1109                    // Determine if working directory is empty
1110                    let is_empty = std::fs::read_dir(&self.context.working_dir)
1111                        .map(|mut i| i.next().is_none())
1112                        .unwrap_or(true);
1113
1114                    // Isolation: if dir is non-empty, create a child directory
1115                    // to avoid polluting existing files.
1116                    let project_name = if is_empty {
1117                        ".".to_string()
1118                    } else {
1119                        self.suggest_project_name(task).await
1120                    };
1121
1122                    let opts = perspt_core::plugin::InitOptions {
1123                        name: project_name.clone(),
1124                        is_empty_dir: is_empty,
1125                        ..Default::default()
1126                    };
1127
1128                    match plugin.get_init_action(&opts) {
1129                        perspt_core::plugin::ProjectAction::ExecCommand {
1130                            command,
1131                            description,
1132                        } => {
1133                            log::info!(
1134                                "step_init_project: init command='{}', awaiting approval",
1135                                command
1136                            );
1137                            let result = self
1138                                .await_approval(
1139                                    perspt_core::ActionType::ProjectInit {
1140                                        command: command.clone(),
1141                                        suggested_name: project_name.clone(),
1142                                    },
1143                                    description.clone(),
1144                                    None,
1145                                )
1146                                .await;
1147
1148                            let final_name = match &result {
1149                                ApprovalResult::ApprovedWithEdit(edited) => edited.clone(),
1150                                _ => project_name.clone(),
1151                            };
1152                            log::info!(
1153                                "step_init_project: approval result={:?}, final_name={}",
1154                                result,
1155                                final_name
1156                            );
1157
1158                            if matches!(
1159                                result,
1160                                ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
1161                            ) {
1162                                let final_command = if final_name != project_name {
1163                                    let edited_opts = perspt_core::plugin::InitOptions {
1164                                        name: final_name.clone(),
1165                                        is_empty_dir: is_empty,
1166                                        ..Default::default()
1167                                    };
1168                                    match plugin.get_init_action(&edited_opts) {
1169                                        perspt_core::plugin::ProjectAction::ExecCommand {
1170                                            command,
1171                                            ..
1172                                        } => command,
1173                                        _ => command.clone(),
1174                                    }
1175                                } else {
1176                                    command.clone()
1177                                };
1178
1179                                let mut args = HashMap::new();
1180                                args.insert("command".to_string(), final_command.clone());
1181                                let call = ToolCall {
1182                                    name: "run_command".to_string(),
1183                                    arguments: args,
1184                                };
1185                                let exec_result = self.tools.execute(&call).await;
1186                                if exec_result.success {
1187                                    self.emit_log(format!(
1188                                        "✅ Project '{}' initialized",
1189                                        final_name
1190                                    ));
1191
1192                                    // Update working directory to point at the
1193                                    // isolated project root if we created a child dir.
1194                                    if final_name != "." {
1195                                        let new_dir = self.context.working_dir.join(&final_name);
1196                                        if new_dir.is_dir() {
1197                                            self.context.working_dir = new_dir.clone();
1198                                            self.tools =
1199                                                AgentTools::new(new_dir, !self.auto_approve);
1200                                            if let Some(ref sender) = self.event_sender {
1201                                                self.tools.set_event_sender(sender.clone());
1202                                            }
1203                                            self.emit_log(format!(
1204                                                "📁 Working directory: {}",
1205                                                self.context.working_dir.display()
1206                                            ));
1207                                        }
1208                                    }
1209                                } else {
1210                                    self.emit_log(format!(
1211                                        "❌ Init failed: {:?}",
1212                                        exec_result.error
1213                                    ));
1214                                }
1215                            }
1216                        }
1217                        perspt_core::plugin::ProjectAction::NoAction => {
1218                            self.emit_log("ℹ️ No initialization action needed".to_string());
1219                        }
1220                    }
1221                }
1222            }
1223
1224            WorkspaceState::Ambiguous => {
1225                // Ambiguous workspace — try to infer language and create a child
1226                // project directory rather than initializing in place.
1227                if let Some(lang) = self.detect_language_from_task(task) {
1228                    // Same as Greenfield: in Team/Project mode always scaffold.
1229                    if self.context.execution_mode == perspt_core::types::ExecutionMode::Solo {
1230                        self.emit_log("ℹ️ Solo mode, skipping project scaffolding.".to_string());
1231                        return Ok(());
1232                    }
1233
1234                    if let Some(plugin) = registry.get(lang) {
1235                        let project_name = self.suggest_project_name(task).await;
1236                        self.emit_log(format!(
1237                            "🌱 Ambiguous workspace — creating isolated {} project '{}'",
1238                            lang, project_name
1239                        ));
1240
1241                        // Pre-flight: check required tools before attempting init
1242                        if !self.check_tool_prerequisites(plugin) {
1243                            return Ok(());
1244                        }
1245
1246                        let opts = perspt_core::plugin::InitOptions {
1247                            name: project_name.clone(),
1248                            is_empty_dir: false,
1249                            ..Default::default()
1250                        };
1251
1252                        match plugin.get_init_action(&opts) {
1253                            perspt_core::plugin::ProjectAction::ExecCommand {
1254                                command,
1255                                description,
1256                            } => {
1257                                let result = self
1258                                    .await_approval(
1259                                        perspt_core::ActionType::ProjectInit {
1260                                            command: command.clone(),
1261                                            suggested_name: project_name.clone(),
1262                                        },
1263                                        description.clone(),
1264                                        None,
1265                                    )
1266                                    .await;
1267
1268                                let final_name = match &result {
1269                                    ApprovalResult::ApprovedWithEdit(edited) => edited.clone(),
1270                                    _ => project_name.clone(),
1271                                };
1272
1273                                if matches!(
1274                                    result,
1275                                    ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
1276                                ) {
1277                                    let final_command = if final_name != project_name {
1278                                        let edited_opts = perspt_core::plugin::InitOptions {
1279                                            name: final_name.clone(),
1280                                            is_empty_dir: false,
1281                                            ..Default::default()
1282                                        };
1283                                        match plugin.get_init_action(&edited_opts) {
1284                                            perspt_core::plugin::ProjectAction::ExecCommand {
1285                                                command,
1286                                                ..
1287                                            } => command,
1288                                            _ => command.clone(),
1289                                        }
1290                                    } else {
1291                                        command.clone()
1292                                    };
1293
1294                                    let mut args = HashMap::new();
1295                                    args.insert("command".to_string(), final_command.clone());
1296                                    let call = ToolCall {
1297                                        name: "run_command".to_string(),
1298                                        arguments: args,
1299                                    };
1300                                    let exec_result = self.tools.execute(&call).await;
1301                                    if exec_result.success {
1302                                        self.emit_log(format!(
1303                                            "✅ Project '{}' initialized",
1304                                            final_name
1305                                        ));
1306
1307                                        let new_dir = self.context.working_dir.join(&final_name);
1308                                        if new_dir.is_dir() {
1309                                            self.context.working_dir = new_dir.clone();
1310                                            self.tools =
1311                                                AgentTools::new(new_dir, !self.auto_approve);
1312                                            if let Some(ref sender) = self.event_sender {
1313                                                self.tools.set_event_sender(sender.clone());
1314                                            }
1315                                            self.emit_log(format!(
1316                                                "📁 Working directory: {}",
1317                                                self.context.working_dir.display()
1318                                            ));
1319                                        }
1320                                    } else {
1321                                        self.emit_log(format!(
1322                                            "❌ Init failed: {:?}",
1323                                            exec_result.error
1324                                        ));
1325                                    }
1326                                }
1327                            }
1328                            perspt_core::plugin::ProjectAction::NoAction => {
1329                                self.emit_log("ℹ️ No initialization action needed".to_string());
1330                            }
1331                        }
1332                    }
1333                } else {
1334                    self.emit_log(
1335                        "ℹ️ Ambiguous workspace and no language detected, skipping project init"
1336                            .to_string(),
1337                    );
1338                }
1339            }
1340        }
1341
1342        Ok(())
1343    }
1344
1345    /// Determine if Solo Mode should be used for this task
1346    /// Solo Mode is for simple single-file tasks that don't need project scaffolding
1347    /// PSP-5: Detect execution mode from task description
1348    ///
1349    /// Project mode is the DEFAULT. Solo mode only activates on explicit
1350    /// single-file intent keywords or via the `--single-file` CLI flag.
1351    fn detect_execution_mode(&self, task: &str) -> perspt_core::types::ExecutionMode {
1352        // If execution mode was set explicitly (e.g., CLI flag), honor it
1353        if self.context.execution_mode != perspt_core::types::ExecutionMode::Project {
1354            return self.context.execution_mode;
1355        }
1356
1357        let task_lower = task.to_lowercase();
1358
1359        // Only these explicit keywords trigger Solo mode
1360        let solo_keywords = [
1361            "single file",
1362            "single-file",
1363            "snippet",
1364            "standalone script",
1365            "standalone file",
1366            "one file only",
1367            "just a file",
1368        ];
1369
1370        if solo_keywords.iter().any(|&k| task_lower.contains(k)) {
1371            log::debug!("Task contains explicit solo keyword, using Solo Mode");
1372            return perspt_core::types::ExecutionMode::Solo;
1373        }
1374
1375        // Default: Project mode for everything else
1376        log::debug!("Defaulting to Project Mode (PSP-5)");
1377        perspt_core::types::ExecutionMode::Project
1378    }
1379
1380    /// PSP-5: Classify the workspace as existing project, greenfield, or ambiguous.
1381    ///
1382    /// This is the single source of truth that drives init/bootstrap/context
1383    /// strategy for the session. Called once at the start of `run()`.
1384    fn classify_workspace(&self, task: &str) -> WorkspaceState {
1385        let registry = perspt_core::plugin::PluginRegistry::new();
1386        let detected: Vec<String> = registry
1387            .detect_all(&self.context.working_dir)
1388            .iter()
1389            .map(|p| p.name().to_string())
1390            .collect();
1391
1392        if !detected.is_empty() {
1393            return WorkspaceState::ExistingProject { plugins: detected };
1394        }
1395
1396        // No project metadata found — check if we can infer language from task
1397        let inferred = self.detect_language_from_task(task).map(|s| s.to_string());
1398
1399        if inferred.is_some() {
1400            return WorkspaceState::Greenfield {
1401                inferred_lang: inferred,
1402            };
1403        }
1404
1405        // Check if directory is empty (empty dirs are greenfield with unknown lang)
1406        let is_empty = std::fs::read_dir(&self.context.working_dir)
1407            .map(|mut entries| entries.next().is_none())
1408            .unwrap_or(true);
1409
1410        if is_empty {
1411            return WorkspaceState::Greenfield {
1412                inferred_lang: None,
1413            };
1414        }
1415
1416        // Has files but no project metadata and no language inferred
1417        WorkspaceState::Ambiguous
1418    }
1419
1420    /// Probe verifier readiness for active plugins and emit `ToolReadiness` event.
1421    ///
1422    /// Reads `self.context.active_plugins` and emits available/degraded stage
1423    /// info so TUI/headless surfaces know which verification stages are ready.
1424    fn emit_plugin_readiness(&self) {
1425        let registry = perspt_core::plugin::PluginRegistry::new();
1426        let mut plugin_readiness = Vec::new();
1427
1428        for plugin_name in &self.context.active_plugins {
1429            if let Some(plugin) = registry.get(plugin_name) {
1430                let profile = plugin.verifier_profile();
1431                let available: Vec<String> = profile
1432                    .capabilities
1433                    .iter()
1434                    .filter(|c| c.available)
1435                    .map(|c| c.stage.to_string())
1436                    .collect();
1437                let degraded: Vec<String> = profile
1438                    .capabilities
1439                    .iter()
1440                    .filter(|c| !c.available && c.fallback_available)
1441                    .map(|c| format!("{} (fallback)", c.stage))
1442                    .chain(
1443                        profile
1444                            .capabilities
1445                            .iter()
1446                            .filter(|c| !c.any_available())
1447                            .map(|c| format!("{} (unavailable)", c.stage)),
1448                    )
1449                    .collect();
1450                let lsp_status = if profile.lsp.primary_available {
1451                    format!("{} (primary)", profile.lsp.primary.server_binary)
1452                } else if profile.lsp.fallback_available {
1453                    profile
1454                        .lsp
1455                        .fallback
1456                        .as_ref()
1457                        .map(|f| format!("{} (fallback)", f.server_binary))
1458                        .unwrap_or_else(|| "fallback".to_string())
1459                } else {
1460                    "unavailable".to_string()
1461                };
1462
1463                if !degraded.is_empty() {
1464                    self.emit_log(format!(
1465                        "⚠️ Plugin '{}' degraded: {}",
1466                        plugin_name,
1467                        degraded.join(", ")
1468                    ));
1469                }
1470
1471                plugin_readiness.push(perspt_core::events::PluginReadiness {
1472                    plugin_name: plugin_name.clone(),
1473                    available_stages: available,
1474                    degraded_stages: degraded,
1475                    lsp_status,
1476                });
1477            }
1478        }
1479
1480        self.emit_event_ref(perspt_core::AgentEvent::ToolReadiness {
1481            plugins: plugin_readiness,
1482            strictness: format!("{:?}", self.context.verifier_strictness),
1483        });
1484    }
1485
1486    /// Re-detect plugins after greenfield project initialization.
1487    ///
1488    /// Updates `self.context.active_plugins` and `workspace_state`, then
1489    /// emits plugin readiness so the verifier stack matches the new project.
1490    fn redetect_plugins_after_init(&mut self) {
1491        let registry = perspt_core::plugin::PluginRegistry::new();
1492        let detected: Vec<String> = registry
1493            .detect_all(&self.context.working_dir)
1494            .iter()
1495            .map(|p| p.name().to_string())
1496            .collect();
1497
1498        if !detected.is_empty() {
1499            self.emit_log(format!("🔌 Post-init plugins: {}", detected.join(", ")));
1500            self.context.active_plugins = detected.clone();
1501            self.context.workspace_state = WorkspaceState::ExistingProject { plugins: detected };
1502        } else {
1503            self.emit_log("⚠️ No plugins detected after project init".to_string());
1504        }
1505
1506        self.emit_plugin_readiness();
1507    }
1508
1509    /// Run Solo Mode: A tight loop for single-file tasks
1510    ///
1511    /// Bypasses DAG sheafification and directly generates, verifies, and corrects
1512    /// a single Python file with embedded doctests for V_log.
1513    async fn run_solo_mode(&mut self, task: String) -> Result<()> {
1514        const MAX_ATTEMPTS: usize = 3;
1515        const EPSILON: f32 = 0.1;
1516
1517        // Initial prompt - will be replaced with correction prompt on retries
1518        let mut current_prompt = self.build_solo_prompt(&task);
1519        let mut attempt = 0;
1520
1521        // Track state for correction
1522        let mut last_filename: String;
1523        let mut last_code: String;
1524
1525        loop {
1526            attempt += 1;
1527
1528            if attempt > MAX_ATTEMPTS {
1529                self.emit_log(format!(
1530                    "Solo Mode failed after {} attempts, consider Team Mode",
1531                    MAX_ATTEMPTS
1532                ));
1533                self.emit_event(perspt_core::AgentEvent::Complete {
1534                    success: false,
1535                    message: "Solo Mode exhausted retries".to_string(),
1536                });
1537                return Ok(());
1538            }
1539
1540            self.emit_log(format!("Solo Mode attempt {}/{}", attempt, MAX_ATTEMPTS));
1541
1542            // Step 1: Generate code
1543            let response = self
1544                .call_llm_with_logging(&self.actuator_model.clone(), &current_prompt, Some("solo"))
1545                .await?;
1546
1547            // Step 2: Extract code from response
1548            let (filename, code) = match self.extract_code_from_response(&response) {
1549                Some((f, c, _)) => (f, c),
1550                None => {
1551                    self.emit_log("No code block found in LLM response".to_string());
1552                    continue;
1553                }
1554            };
1555
1556            last_filename = filename.clone();
1557            last_code = code.clone();
1558
1559            // Step 3: Write file
1560            let full_path = self.context.working_dir.join(&filename);
1561
1562            let mut args = HashMap::new();
1563            args.insert("path".to_string(), filename.clone());
1564            args.insert("content".to_string(), code.clone());
1565
1566            let call = ToolCall {
1567                name: "write_file".to_string(),
1568                arguments: args,
1569            };
1570
1571            let result = self.tools.execute(&call).await;
1572            if !result.success {
1573                self.emit_log(format!("Failed to write {}: {:?}", filename, result.error));
1574                continue;
1575            }
1576
1577            self.emit_log(format!("Created: {}", filename));
1578            self.last_written_file = Some(full_path.clone());
1579
1580            // Step 4: Verify - Calculate Lyapunov Energy
1581            let energy = self.solo_verify(&full_path).await;
1582            let v_total = energy.total_simple();
1583
1584            self.emit_log(format!(
1585                "V(x) = {:.2} (V_syn={:.2}, V_log={:.2}, V_boot={:.2})",
1586                v_total, energy.v_syn, energy.v_log, energy.v_boot
1587            ));
1588
1589            // Step 5: Check convergence
1590            if v_total < EPSILON {
1591                self.emit_log(format!(
1592                    "Solo Mode complete! V(x)={:.2} < epsilon={:.2}",
1593                    v_total, EPSILON
1594                ));
1595                self.emit_event(perspt_core::AgentEvent::Complete {
1596                    success: true,
1597                    message: format!("Created {}", filename),
1598                });
1599                return Ok(());
1600            }
1601
1602            // Step 6: Build correction prompt with errors (THE KEY FIX!)
1603            self.emit_log(format!(
1604                "Unstable (V={:.2} > epsilon={:.2}), building correction prompt...",
1605                v_total, EPSILON
1606            ));
1607
1608            current_prompt =
1609                self.build_solo_correction_prompt(&task, &last_filename, &last_code, &energy);
1610        }
1611    }
1612
1613    /// Verify a Solo Mode file and calculate energy components
1614    async fn solo_verify(&mut self, path: &std::path::Path) -> EnergyComponents {
1615        let mut energy = EnergyComponents::default();
1616
1617        // V_syn: LSP Diagnostics
1618        let lsp_key = self.lsp_key_for_file(&path.to_string_lossy());
1619        if let Some(client) = lsp_key.as_deref().and_then(|k| self.lsp_clients.get(k)) {
1620            tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
1621            let path_str = path.to_string_lossy().to_string();
1622
1623            let diagnostics = client.get_diagnostics(&path_str).await;
1624            energy.v_syn = LspClient::calculate_syntactic_energy(&diagnostics);
1625
1626            if !diagnostics.is_empty() {
1627                self.emit_log(format!(
1628                    "LSP: {} diagnostics (V_syn={:.2})",
1629                    diagnostics.len(),
1630                    energy.v_syn
1631                ));
1632                self.context.last_diagnostics = diagnostics;
1633            }
1634        }
1635
1636        // V_log: Doctests
1637        energy.v_log = self.run_doctest(path).await;
1638
1639        // V_boot: Execution verification
1640        energy.v_boot = self.run_script_check(path).await;
1641
1642        energy
1643    }
1644
1645    /// Run the script and check for execution errors (V_boot)
1646    async fn run_script_check(&mut self, path: &std::path::Path) -> f32 {
1647        let output = tokio::process::Command::new("python")
1648            .arg(path)
1649            .current_dir(&self.context.working_dir)
1650            .output()
1651            .await;
1652
1653        match output {
1654            Ok(out) if out.status.success() => {
1655                self.emit_log("Script execution: OK".to_string());
1656                0.0
1657            }
1658            Ok(out) => {
1659                let stderr = String::from_utf8_lossy(&out.stderr);
1660                let stdout = String::from_utf8_lossy(&out.stdout);
1661                let error_output = if !stderr.is_empty() {
1662                    stderr.to_string()
1663                } else {
1664                    stdout.to_string()
1665                };
1666
1667                // Truncate long output
1668                let truncated = if error_output.len() > 500 {
1669                    format!("{}...(truncated)", &error_output[..500])
1670                } else {
1671                    error_output.clone()
1672                };
1673
1674                self.emit_log(format!("Script execution: FAILED\n{}", truncated));
1675                self.context.last_test_output = Some(error_output);
1676                5.0 // High energy penalty for runtime errors
1677            }
1678            Err(e) => {
1679                self.emit_log(format!("Script execution: ERROR ({})", e));
1680                5.0
1681            }
1682        }
1683    }
1684
1685    /// Build a minimal prompt for Solo Mode (with dynamic filename instruction)
1686    fn build_solo_prompt(&self, task: &str) -> String {
1687        format!(
1688            r#"You are an expert Python developer. Complete this task with a SINGLE, self-contained Python file.
1689
1690## Task
1691{task}
1692
1693## Requirements
16941. Choose a DESCRIPTIVE filename based on the task (e.g., `fibonacci.py` for a fibonacci script, `prime_checker.py` for checking primes)
16952. Write ONE Python file that accomplishes the task
16963. Include docstrings with doctest examples for all functions
16974. Make the file directly runnable with `if __name__ == "__main__":` block
16985. Use type hints for all function parameters and return values
1699
1700## Output Format
1701File: <your_descriptive_filename.py>
1702```python
1703# your complete code here
1704```
1705
1706IMPORTANT: Do NOT use generic names like `script.py` or `main.py`. Choose a name that reflects the task."#,
1707            task = task
1708        )
1709    }
1710
1711    /// Build a correction prompt for Solo Mode with error feedback
1712    fn build_solo_correction_prompt(
1713        &self,
1714        task: &str,
1715        filename: &str,
1716        current_code: &str,
1717        energy: &EnergyComponents,
1718    ) -> String {
1719        let mut errors = Vec::new();
1720
1721        // Collect LSP diagnostics
1722        for diag in &self.context.last_diagnostics {
1723            let severity = match diag.severity {
1724                Some(lsp_types::DiagnosticSeverity::ERROR) => "ERROR",
1725                Some(lsp_types::DiagnosticSeverity::WARNING) => "WARNING",
1726                Some(lsp_types::DiagnosticSeverity::INFORMATION) => "INFO",
1727                Some(lsp_types::DiagnosticSeverity::HINT) => "HINT",
1728                _ => "DIAGNOSTIC",
1729            };
1730            errors.push(format!(
1731                "- Line {}: {} [{}]",
1732                diag.range.start.line + 1,
1733                diag.message,
1734                severity
1735            ));
1736        }
1737
1738        // Collect test/execution output
1739        if let Some(ref output) = self.context.last_test_output {
1740            if !output.is_empty() {
1741                // Truncate if too long
1742                let truncated = if output.len() > 1000 {
1743                    format!("{}...(truncated)", &output[..1000])
1744                } else {
1745                    output.clone()
1746                };
1747                errors.push(format!("- Runtime/Test Error:\n{}", truncated));
1748            }
1749        }
1750
1751        let error_list = if errors.is_empty() {
1752            "No specific errors captured, but energy is still too high.".to_string()
1753        } else {
1754            errors.join("\n")
1755        };
1756
1757        format!(
1758            r#"## Code Correction Required
1759
1760The code you generated has errors. Fix ALL of them.
1761
1762### Original Task
1763{task}
1764
1765### Current Code ({filename})
1766```python
1767{current_code}
1768```
1769
1770### Errors Found
1771Energy: V_syn={v_syn:.2}, V_log={v_log:.2}, V_boot={v_boot:.2}
1772
1773{error_list}
1774
1775### Instructions
17761. Fix ALL errors listed above
17772. Maintain the original functionality
17783. Ensure the script runs without errors
17794. Ensure all doctests pass
17805. Return the COMPLETE corrected file
1781
1782### Output Format
1783File: {filename}
1784```python
1785[complete corrected code]
1786```"#,
1787            task = task,
1788            filename = filename,
1789            current_code = current_code,
1790            v_syn = energy.v_syn,
1791            v_log = energy.v_log,
1792            v_boot = energy.v_boot,
1793            error_list = error_list
1794        )
1795    }
1796
1797    /// Run Python doctest on a file and return V_log energy
1798    async fn run_doctest(&mut self, file_path: &std::path::Path) -> f32 {
1799        let output = tokio::process::Command::new("python")
1800            .args(["-m", "doctest", "-v"])
1801            .arg(file_path)
1802            .current_dir(&self.context.working_dir)
1803            .output()
1804            .await;
1805
1806        match output {
1807            Ok(out) => {
1808                let stdout = String::from_utf8_lossy(&out.stdout);
1809                let stderr = String::from_utf8_lossy(&out.stderr);
1810
1811                // Parse doctest output for failures
1812                let failed = stderr.matches("FAILED").count() + stdout.matches("FAILED").count();
1813                let passed = stdout.matches("ok").count();
1814
1815                if failed > 0 {
1816                    self.emit_log(format!("Doctest: {} passed, {} failed", passed, failed));
1817                    // Store doctest output for correction prompt
1818                    let doctest_output = format!("{}\n{}", stdout, stderr);
1819                    self.context.last_test_output = Some(doctest_output);
1820                    // Weight failures at gamma=2.0 per SRBN spec
1821                    2.0 * (failed as f32)
1822                } else if passed > 0 {
1823                    self.emit_log(format!("Doctest: {} passed", passed));
1824                    0.0
1825                } else {
1826                    // No doctests found - that's okay for Solo Mode, v_log = 0
1827                    log::debug!("No doctests found in file");
1828                    0.0
1829                }
1830            }
1831            Err(e) => {
1832                log::warn!("Failed to run doctest: {}", e);
1833                0.0 // Don't penalize if doctest runner fails
1834            }
1835        }
1836    }
1837
1838    /// Detect language from task description using heuristics
1839    fn detect_language_from_task(&self, task: &str) -> Option<&'static str> {
1840        let task_lower = task.to_lowercase();
1841
1842        if task_lower.contains("rust") || task_lower.contains("cargo") {
1843            Some("rust")
1844        } else if task_lower.contains("python")
1845            || task_lower.contains("flask")
1846            || task_lower.contains("django")
1847            || task_lower.contains("fastapi")
1848            || task_lower.contains("pytorch")
1849            || task_lower.contains("tensorflow")
1850            || task_lower.contains("pandas")
1851            || task_lower.contains("numpy")
1852            || task_lower.contains("scikit")
1853            || task_lower.contains("sklearn")
1854            || task_lower.contains("ml ")
1855            || task_lower.contains("machine learning")
1856            || task_lower.contains("deep learning")
1857            || task_lower.contains("neural")
1858            || task_lower.contains("dcf")
1859            || task_lower.contains("data science")
1860            || task_lower.contains("jupyter")
1861            || task_lower.contains("notebook")
1862            || task_lower.contains("streamlit")
1863            || task_lower.contains("gradio")
1864            || task_lower.contains("huggingface")
1865            || task_lower.contains("transformers")
1866            || task_lower.contains("llm")
1867            || task_lower.contains("langchain")
1868            || task_lower.contains("pydantic")
1869        {
1870            Some("python")
1871        } else if task_lower.contains("javascript")
1872            || task_lower.contains("typescript")
1873            || task_lower.contains("node")
1874            || task_lower.contains("react")
1875            || task_lower.contains("vue")
1876            || task_lower.contains("angular")
1877            || task_lower.contains("next.js")
1878            || task_lower.contains("nextjs")
1879        {
1880            Some("javascript")
1881        } else if task_lower.contains("app") || task_lower.contains("application") {
1882            // Default to Python for generic "app" or "application" tasks
1883            Some("python")
1884        } else {
1885            None
1886        }
1887    }
1888
1889    /// Suggest a meaningful project name from the task description
1890    async fn suggest_project_name(&self, task: &str) -> String {
1891        // 1. Try heuristic extraction first (fast, no LLM)
1892        if let Some(name) = self.extract_name_heuristic(task) {
1893            self.emit_log(format!("📁 Suggested project folder: {}", name));
1894            return name;
1895        }
1896
1897        // 2. Fallback to LLM for complex tasks
1898        let prompt = format!(
1899            r#"Extract a short project name from this task description.
1900Rules:
1901- Use snake_case (lowercase with underscores)
1902- Maximum 30 characters
1903- Must be a valid folder name (letters, numbers, underscores only)
1904- Return ONLY the name, nothing else
1905
1906Task: "{}"
1907
1908Project name:"#,
1909            task
1910        );
1911
1912        match self
1913            .call_llm_with_logging(&self.actuator_model.clone(), &prompt, None)
1914            .await
1915        {
1916            Ok(response) => {
1917                let suggested = response.trim().to_lowercase();
1918                if let Some(validated) = self.validate_project_name(&suggested) {
1919                    self.emit_log(format!("📁 Suggested project folder: {}", validated));
1920                    return validated;
1921                }
1922            }
1923            Err(e) => {
1924                log::warn!("Failed to get project name from LLM: {}", e);
1925            }
1926        }
1927
1928        // 3. Final fallback
1929        let fallback = "perspt_app".to_string();
1930        self.emit_log(format!("📁 Using default folder: {}", fallback));
1931        fallback
1932    }
1933
1934    /// Extract project name from task using stop-word removal (no LLM)
1935    fn extract_name_heuristic(&self, task: &str) -> Option<String> {
1936        let task_lower = task.to_lowercase();
1937
1938        // Stop words to remove
1939        let stop_words = [
1940            // Verbs
1941            "create",
1942            "build",
1943            "make",
1944            "implement",
1945            "develop",
1946            "write",
1947            "design",
1948            "add",
1949            "setup",
1950            "set",
1951            "up",
1952            "generate",
1953            "please",
1954            "can",
1955            "you",
1956            // Articles
1957            "a",
1958            "an",
1959            "the",
1960            "this",
1961            "that",
1962            // Prepositions
1963            "in",
1964            "on",
1965            "for",
1966            "with",
1967            "using",
1968            "to",
1969            "from",
1970            // Languages (we don't want these in folder names)
1971            "python",
1972            "rust",
1973            "javascript",
1974            "typescript",
1975            "node",
1976            "nodejs",
1977            "react",
1978            "vue",
1979            "angular",
1980            "django",
1981            "flask",
1982            "fastapi",
1983            // Generic terms
1984            "simple",
1985            "basic",
1986            "new",
1987            "my",
1988            "our",
1989            "your",
1990        ];
1991
1992        // Split into words and filter
1993        let words: Vec<&str> = task_lower
1994            .split(|c: char| !c.is_alphanumeric())
1995            .filter(|w| !w.is_empty())
1996            .filter(|w| !stop_words.contains(w))
1997            .filter(|w| w.len() > 1) // Skip single chars
1998            .take(3) // Max 3 words
1999            .collect();
2000
2001        if words.is_empty() {
2002            return None;
2003        }
2004
2005        // Join words with underscore
2006        let name = words.join("_");
2007
2008        // Validate
2009        self.validate_project_name(&name)
2010    }
2011
2012    /// Validate and sanitize a project name
2013    fn validate_project_name(&self, name: &str) -> Option<String> {
2014        // Must start with letter, contain only letters/numbers/underscores
2015        let cleaned: String = name
2016            .chars()
2017            .filter(|c| c.is_alphanumeric() || *c == '_')
2018            .take(30)
2019            .collect();
2020
2021        if cleaned.is_empty() {
2022            return None;
2023        }
2024
2025        // Ensure it starts with a letter
2026        let first = cleaned.chars().next()?;
2027        if !first.is_alphabetic() {
2028            return None;
2029        }
2030
2031        Some(cleaned)
2032    }
2033
2034    ///
2035    /// The Architect analyzes the task and produces a structured Task DAG.
2036    /// This step retries until a valid JSON plan is produced or max attempts reached.
2037    async fn step_sheafify(&mut self, task: String) -> Result<()> {
2038        log::info!("Step 1: Sheafification - Planning task decomposition");
2039        self.emit_log("🏗️ Architect is analyzing the task...".to_string());
2040
2041        const MAX_ATTEMPTS: usize = 3;
2042        let mut last_error: Option<String> = None;
2043
2044        for attempt in 1..=MAX_ATTEMPTS {
2045            log::info!(
2046                "Sheafification attempt {}/{}: requesting structured plan",
2047                attempt,
2048                MAX_ATTEMPTS
2049            );
2050
2051            // Build the structured prompt
2052            let prompt = self.build_architect_prompt(&task, last_error.as_deref())?;
2053
2054            // Call the Architect with tier-aware fallback for structured-output failures
2055            let response = self
2056                .call_llm_with_tier_fallback(
2057                    &self.get_architect_model(),
2058                    &prompt,
2059                    None,
2060                    ModelTier::Architect,
2061                    |resp| {
2062                        // Validate that the response contains parseable JSON plan
2063                        if resp.contains("tasks") && (resp.contains('{') && resp.contains('}')) {
2064                            Ok(())
2065                        } else {
2066                            Err("Response does not contain a JSON task plan".to_string())
2067                        }
2068                    },
2069                )
2070                .await
2071                .context("Failed to get Architect response")?;
2072
2073            log::debug!("Architect response length: {} chars", response.len());
2074
2075            // Try to parse the JSON plan
2076            match self.parse_task_plan(&response) {
2077                Ok(plan) => {
2078                    // Validate the plan
2079                    if let Err(e) = plan.validate() {
2080                        log::warn!("Plan validation failed (attempt {}): {}", attempt, e);
2081                        last_error = Some(format!("Validation error: {}", e));
2082
2083                        if attempt >= MAX_ATTEMPTS {
2084                            self.emit_log(format!(
2085                                "❌ Failed to get valid plan after {} attempts",
2086                                MAX_ATTEMPTS
2087                            ));
2088                            // Fall back to single-task execution
2089                            return self.create_deterministic_fallback_graph(&task);
2090                        }
2091                        continue;
2092                    }
2093
2094                    // Check complexity gating
2095                    if plan.len() > self.context.complexity_k && !self.auto_approve {
2096                        self.emit_log(format!(
2097                            "⚠️ Plan has {} tasks (exceeds K={})",
2098                            plan.len(),
2099                            self.context.complexity_k
2100                        ));
2101                        // TODO: Implement interactive approval
2102                        // For now, auto-approve in headless mode
2103                    }
2104
2105                    self.emit_log(format!(
2106                        "✅ Architect produced plan with {} task(s)",
2107                        plan.len()
2108                    ));
2109
2110                    // Emit plan generated event
2111                    self.emit_event(perspt_core::AgentEvent::PlanGenerated(plan.clone()));
2112
2113                    // Create nodes from the plan
2114                    self.create_nodes_from_plan(&plan)?;
2115                    return Ok(());
2116                }
2117                Err(e) => {
2118                    log::warn!("Plan parsing failed (attempt {}): {}", attempt, e);
2119                    last_error = Some(format!("JSON parse error: {}", e));
2120
2121                    if attempt >= MAX_ATTEMPTS {
2122                        self.emit_log(
2123                            "⚠️ Could not parse structured plan, using single task".to_string(),
2124                        );
2125                        return self.create_deterministic_fallback_graph(&task);
2126                    }
2127                }
2128            }
2129        }
2130
2131        // Should not reach here
2132        self.create_deterministic_fallback_graph(&task)
2133    }
2134
2135    /// Build the Architect prompt requesting structured JSON output
2136    ///
2137    /// PSP-5 Fix F: Delegates to `ArchitectAgent::build_task_decomposition_prompt`
2138    /// so the JSON schema contract lives in one place.
2139    fn build_architect_prompt(&self, task: &str, last_error: Option<&str>) -> Result<String> {
2140        let mut project_context = self.gather_project_context();
2141
2142        // PSP-5: For existing projects, prepend a structured project summary
2143        // so the architect respects existing structure rather than scaffolding.
2144        if matches!(
2145            self.context.workspace_state,
2146            WorkspaceState::ExistingProject { .. }
2147        ) {
2148            let retriever = ContextRetriever::new(self.context.working_dir.clone());
2149            let summary = retriever.get_project_summary();
2150            if !summary.is_empty() {
2151                project_context = format!("{}\n\n{}", summary, project_context);
2152            }
2153        }
2154
2155        Ok(
2156            crate::agent::ArchitectAgent::build_task_decomposition_prompt(
2157                task,
2158                &self.context.working_dir,
2159                &project_context,
2160                last_error,
2161            ),
2162        )
2163    }
2164
2165    /// Gather existing project context for the Architect prompt
2166    /// Uses ContextRetriever to read key configuration files
2167    fn gather_project_context(&self) -> String {
2168        let mut context_parts = Vec::new();
2169        let working_dir = &self.context.working_dir;
2170        let retriever = ContextRetriever::new(working_dir.clone())
2171            .with_max_file_bytes(8 * 1024) // 8KB per file for config files
2172            .with_max_context_bytes(32 * 1024); // 32KB total context
2173
2174        // Key config files to read (in priority order)
2175        let config_files = [
2176            "pyproject.toml",
2177            "Cargo.toml",
2178            "package.json",
2179            "requirements.txt",
2180        ];
2181
2182        // Read and include config file contents (up to max_file_bytes)
2183        let mut found_configs = Vec::new();
2184        for file in &config_files {
2185            let path = working_dir.join(file);
2186            if path.exists() {
2187                if let Ok(content) = retriever.read_file_truncated(&path) {
2188                    context_parts.push(format!("### {}\n```\n{}\n```", file, content));
2189                    found_configs.push(*file);
2190                }
2191            }
2192        }
2193
2194        // List directory structure
2195        if let Ok(entries) = std::fs::read_dir(working_dir) {
2196            let mut dirs = Vec::new();
2197            let mut files = Vec::new();
2198            for entry in entries.flatten() {
2199                let name = entry.file_name().to_string_lossy().to_string();
2200                if name.starts_with('.') {
2201                    continue; // Skip hidden files/dirs
2202                }
2203                if entry.path().is_dir() {
2204                    dirs.push(name);
2205                } else if !found_configs.contains(&name.as_str()) {
2206                    files.push(name);
2207                }
2208            }
2209
2210            if !dirs.is_empty() {
2211                context_parts.push(format!("### Directories\n{}", dirs.join(", ")));
2212            }
2213            if !files.is_empty() && files.len() <= 15 {
2214                context_parts.push(format!("### Other Files\n{}", files.join(", ")));
2215            } else if !files.is_empty() {
2216                context_parts.push(format!(
2217                    "### Other Files\n{} files (not listed)",
2218                    files.len()
2219                ));
2220            }
2221        }
2222
2223        if context_parts.is_empty() {
2224            "Empty directory (greenfield project)".to_string()
2225        } else {
2226            context_parts.join("\n\n")
2227        }
2228    }
2229
2230    /// Parse JSON response into TaskPlan
2231    ///
2232    /// PSP-5 Phase 4: Uses the provider-neutral normalization layer to extract
2233    /// JSON from LLM responses regardless of fencing, wrapper text, or provider
2234    /// formatting quirks. Falls back to raw content parsing if normalization
2235    /// finds no JSON.
2236    fn parse_task_plan(&self, content: &str) -> Result<TaskPlan> {
2237        // PSP-5 Phase 4: Use normalized extraction
2238        match perspt_core::normalize::extract_and_deserialize::<TaskPlan>(content) {
2239            Ok((plan, method)) => {
2240                log::info!("Parsed TaskPlan via normalization ({})", method);
2241                return Ok(plan);
2242            }
2243            Err(e) => {
2244                log::warn!(
2245                    "Normalization could not extract TaskPlan: {}. Attempting raw parse.",
2246                    e
2247                );
2248            }
2249        }
2250
2251        // Legacy fallback: try direct deserialization of trimmed content
2252        let trimmed = content.trim();
2253        log::debug!(
2254            "Attempting legacy JSON parse: {}...",
2255            &trimmed[..trimmed.len().min(200)]
2256        );
2257        serde_json::from_str(trimmed).context("Failed to parse TaskPlan JSON")
2258    }
2259
2260    /// Create SRBN nodes from a parsed TaskPlan
2261    fn create_nodes_from_plan(&mut self, plan: &TaskPlan) -> Result<()> {
2262        // PSP-5: Validate plan structure including ownership closure before creating nodes
2263        plan.validate()
2264            .map_err(|e| anyhow::anyhow!("Plan validation failed: {}", e))?;
2265
2266        log::info!("Creating {} nodes from plan", plan.len());
2267
2268        // Create all nodes first
2269        let mut node_map: HashMap<String, NodeIndex> = HashMap::new();
2270
2271        for task in &plan.tasks {
2272            let node = task.to_srbn_node(ModelTier::Actuator);
2273            let idx = self.add_node(node);
2274            node_map.insert(task.id.clone(), idx);
2275            log::info!("  Created node: {} - {}", task.id, task.goal);
2276        }
2277
2278        // Wire up dependencies
2279        for task in &plan.tasks {
2280            for dep_id in &task.dependencies {
2281                if let (Some(&from_idx), Some(&to_idx)) =
2282                    (node_map.get(dep_id), node_map.get(&task.id))
2283                {
2284                    self.graph.add_edge(
2285                        from_idx,
2286                        to_idx,
2287                        Dependency {
2288                            kind: "depends_on".to_string(),
2289                        },
2290                    );
2291                    log::debug!("  Wired dependency: {} -> {}", dep_id, task.id);
2292
2293                    // PSP-5 Phase 8: Persist graph edge for resume reconstruction
2294                    if let Err(e) =
2295                        self.ledger
2296                            .record_task_graph_edge(dep_id, &task.id, "depends_on")
2297                    {
2298                        log::warn!(
2299                            "Failed to persist graph edge {} -> {}: {}",
2300                            dep_id,
2301                            task.id,
2302                            e
2303                        );
2304                    }
2305                }
2306            }
2307        }
2308
2309        // PSP-5 Phase 2: Build ownership manifest from plan output_files
2310        self.build_ownership_manifest_from_plan(plan);
2311
2312        Ok(())
2313    }
2314
2315    /// PSP-5 Phase 2: Build ownership manifest from a TaskPlan
2316    ///
2317    /// Assigns each task's output_files to the owning node, detecting the
2318    /// language plugin from file extension via the plugin registry.
2319    /// Uses majority-vote across ALL output files instead of first-file-only heuristic.
2320    fn build_ownership_manifest_from_plan(&mut self, plan: &TaskPlan) {
2321        let registry = perspt_core::plugin::PluginRegistry::new();
2322
2323        for task in &plan.tasks {
2324            // Detect plugin via majority vote across ALL output files
2325            let mut plugin_votes: HashMap<String, usize> = HashMap::new();
2326            for f in &task.output_files {
2327                let detected = registry
2328                    .all()
2329                    .iter()
2330                    .find(|p| p.owns_file(f))
2331                    .map(|p| p.name().to_string())
2332                    .unwrap_or_else(|| "unknown".to_string());
2333                *plugin_votes.entry(detected).or_insert(0) += 1;
2334            }
2335
2336            let plugin_name = plugin_votes
2337                .into_iter()
2338                .max_by_key(|(_, count)| *count)
2339                .map(|(name, _)| name)
2340                .unwrap_or_else(|| "unknown".to_string());
2341
2342            // Warn on mixed-plugin tasks (non-Integration nodes)
2343            if task.node_class != perspt_core::types::NodeClass::Integration {
2344                let mixed: Vec<String> = task
2345                    .output_files
2346                    .iter()
2347                    .filter_map(|f| {
2348                        let det = registry
2349                            .all()
2350                            .iter()
2351                            .find(|p| p.owns_file(f))
2352                            .map(|p| p.name().to_string())
2353                            .unwrap_or_else(|| "unknown".to_string());
2354                        if det != plugin_name {
2355                            Some(format!("'{}' ({})", f, det))
2356                        } else {
2357                            None
2358                        }
2359                    })
2360                    .collect();
2361                if !mixed.is_empty() {
2362                    log::warn!(
2363                        "Task '{}' has mixed-plugin outputs (primary: {}): {}",
2364                        task.id,
2365                        plugin_name,
2366                        mixed.join(", ")
2367                    );
2368                }
2369            }
2370
2371            // Set owner_plugin on the node if we can find it
2372            if let Some(idx) = self.node_indices.get(&task.id) {
2373                self.graph[*idx].owner_plugin = plugin_name.clone();
2374            }
2375
2376            // Register each output file in the manifest
2377            for file in &task.output_files {
2378                // Detect per-file plugin for accurate manifest entries
2379                let file_plugin = registry
2380                    .all()
2381                    .iter()
2382                    .find(|p| p.owns_file(file))
2383                    .map(|p| p.name().to_string())
2384                    .unwrap_or_else(|| plugin_name.clone());
2385
2386                self.context.ownership_manifest.assign(
2387                    file.clone(),
2388                    task.id.clone(),
2389                    file_plugin,
2390                    task.node_class,
2391                );
2392            }
2393        }
2394
2395        log::info!(
2396            "Built ownership manifest: {} entries",
2397            self.context.ownership_manifest.len()
2398        );
2399    }
2400
2401    /// Get the Architect model name
2402    fn get_architect_model(&self) -> String {
2403        self.architect_model.clone()
2404    }
2405
2406    /// Execute a single node through the control loop
2407    async fn execute_node(&mut self, idx: NodeIndex) -> Result<()> {
2408        let node = &self.graph[idx];
2409        log::info!("Executing node: {} ({})", node.node_id, node.goal);
2410
2411        // PSP-5 Phase 6: Create provisional branch if node has graph parents
2412        let branch_id = self.maybe_create_provisional_branch(idx);
2413
2414        // Step 2: Recursive Sub-graph Execution (already in topo order)
2415        self.graph[idx].state = NodeState::Coding;
2416        self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
2417            node_id: self.graph[idx].node_id.clone(),
2418            status: perspt_core::NodeStatus::Coding,
2419        });
2420
2421        // Step 3: Speculative Generation
2422        self.step_speculate(idx).await?;
2423
2424        // Step 4: Stability Verification
2425        let energy = self.step_verify(idx).await?;
2426
2427        // Step 5: Convergence & Self-Correction
2428        if !self.step_converge(idx, energy).await? {
2429            // PSP-5 Phase 5: Classify non-convergence and choose repair action
2430            let category = self.classify_non_convergence(idx);
2431            let action = self.choose_repair_action(idx, &category);
2432
2433            // Persist the escalation report
2434            let node = &self.graph[idx];
2435            let report = EscalationReport {
2436                node_id: node.node_id.clone(),
2437                session_id: self.context.session_id.clone(),
2438                category,
2439                action: action.clone(),
2440                energy_snapshot: EnergyComponents {
2441                    v_syn: node.monitor.current_energy(),
2442                    ..Default::default()
2443                },
2444                stage_outcomes: self
2445                    .last_verification_result
2446                    .as_ref()
2447                    .map(|vr| vr.stage_outcomes.clone())
2448                    .unwrap_or_default(),
2449                evidence: self.build_escalation_evidence(idx),
2450                affected_node_ids: self.affected_dependents(idx),
2451                timestamp: epoch_seconds(),
2452            };
2453
2454            if let Err(e) = self.ledger.record_escalation_report(&report) {
2455                log::warn!("Failed to persist escalation report: {}", e);
2456            }
2457
2458            // PSP-5 Phase 9: Also persist artifact bundle on escalation path
2459            if let Some(bundle) = self.last_applied_bundle.take() {
2460                if let Err(e) = self
2461                    .ledger
2462                    .record_artifact_bundle(&self.graph[idx].node_id, &bundle)
2463                {
2464                    log::warn!(
2465                        "Failed to persist artifact bundle on escalation for {}: {}",
2466                        self.graph[idx].node_id,
2467                        e
2468                    );
2469                }
2470            }
2471
2472            self.emit_event(perspt_core::AgentEvent::EscalationClassified {
2473                node_id: report.node_id.clone(),
2474                category: report.category.to_string(),
2475                action: report.action.to_string(),
2476            });
2477
2478            // PSP-5 Phase 6: Flush this branch and all descendant branches
2479            let node_id_for_flush = self.graph[idx].node_id.clone();
2480            if let Some(ref bid) = branch_id {
2481                self.flush_provisional_branch(bid, &node_id_for_flush);
2482            }
2483            self.flush_descendant_branches(idx);
2484
2485            // Apply the chosen repair action or escalate to user
2486            let applied = self.apply_repair_action(idx, &action).await;
2487
2488            if !applied {
2489                self.graph[idx].state = NodeState::Escalated;
2490                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
2491                    node_id: self.graph[idx].node_id.clone(),
2492                    status: perspt_core::NodeStatus::Escalated,
2493                });
2494                log::warn!(
2495                    "Node {} escalated to user: {} → {}",
2496                    self.graph[idx].node_id,
2497                    category,
2498                    action
2499                );
2500            }
2501
2502            return Ok(());
2503        }
2504
2505        // Step 6: Sheaf Validation (Post-Subgraph Consistency)
2506        self.step_sheaf_validate(idx).await?;
2507
2508        // Step 7: Merkle Ledger Commit
2509        self.step_commit(idx).await?;
2510
2511        // PSP-5 Phase 6: Merge provisional branch after successful commit
2512        if let Some(ref bid) = branch_id {
2513            self.merge_provisional_branch(bid, idx);
2514        }
2515
2516        Ok(())
2517    }
2518
2519    /// Step 3: Speculative Generation
2520    async fn step_speculate(&mut self, idx: NodeIndex) -> Result<()> {
2521        log::info!("Step 3: Speculation - Generating implementation");
2522
2523        // PSP-5 Phase 3: Build context package for this node
2524        let retriever = ContextRetriever::new(self.context.working_dir.clone())
2525            .with_max_file_bytes(8 * 1024)
2526            .with_max_context_bytes(100 * 1024); // 100KB default budget
2527
2528        let node = &self.graph[idx];
2529        let mut restriction_map =
2530            retriever.build_restriction_map(node, &self.context.ownership_manifest);
2531
2532        // PSP-5 Phase 6: Inject sealed interface digests from parent nodes.
2533        // For each parent Interface node that has a recorded seal, add the
2534        // seal's structural digest to the restriction map so the context
2535        // package uses immutable sealed data instead of mutable parent files.
2536        self.inject_sealed_interfaces(idx, &mut restriction_map);
2537
2538        let node = &self.graph[idx];
2539        let context_package = retriever.assemble_context_package(node, &restriction_map);
2540        let formatted_context = retriever.format_context_package(&context_package);
2541
2542        // PSP-5 Phase 3: Enforce context budget — emit degradation event when
2543        // budget is exceeded or required owned files are missing.
2544        let node = &self.graph[idx];
2545        let missing_owned: Vec<String> = restriction_map
2546            .owned_files
2547            .iter()
2548            .filter(|f| {
2549                // Only treat as missing if not planned for creation by this node
2550                !context_package.included_files.contains_key(*f)
2551                    && !node
2552                        .output_targets
2553                        .iter()
2554                        .any(|ot| ot.to_string_lossy() == **f)
2555            })
2556            .cloned()
2557            .collect();
2558
2559        if context_package.budget_exceeded || !missing_owned.is_empty() {
2560            let reason = if context_package.budget_exceeded && !missing_owned.is_empty() {
2561                format!(
2562                    "Budget exceeded and {} owned file(s) missing",
2563                    missing_owned.len()
2564                )
2565            } else if context_package.budget_exceeded {
2566                "Context budget exceeded; some files replaced with structural digests".to_string()
2567            } else {
2568                format!(
2569                    "{} owned file(s) could not be read: {}",
2570                    missing_owned.len(),
2571                    missing_owned.join(", ")
2572                )
2573            };
2574
2575            log::warn!("Context degraded for node '{}': {}", node.node_id, reason);
2576            self.emit_log(format!("⚠️ Context degraded: {}", reason));
2577            self.emit_event(perspt_core::AgentEvent::ContextDegraded {
2578                node_id: node.node_id.clone(),
2579                budget_exceeded: context_package.budget_exceeded,
2580                missing_owned_files: missing_owned.clone(),
2581                included_file_count: context_package.included_files.len(),
2582                total_bytes: context_package.total_bytes,
2583                reason: reason.clone(),
2584            });
2585
2586            // PSP-5 Phase 3: Block execution when required owned files are missing.
2587            // Budget-exceeded-but-all-owned-files-present is a warning, not a block.
2588            if !missing_owned.is_empty() {
2589                self.emit_event(perspt_core::AgentEvent::ContextBlocked {
2590                    node_id: node.node_id.clone(),
2591                    missing_owned_files: missing_owned,
2592                    reason: reason.clone(),
2593                });
2594                self.graph[idx].state = NodeState::Escalated;
2595                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
2596                    node_id: self.graph[idx].node_id.clone(),
2597                    status: perspt_core::NodeStatus::Escalated,
2598                });
2599                return Err(anyhow::anyhow!(
2600                    "Context blocked for node '{}': {}. Node escalated.",
2601                    self.graph[idx].node_id,
2602                    reason
2603                ));
2604            }
2605        }
2606
2607        // PSP-5 Phase 3: Pre-execution structural dependency check.
2608        // A node SHALL NOT proceed when only prose exists for a required dependency.
2609        {
2610            let node = &self.graph[idx];
2611            let prose_only_deps = self.check_structural_dependencies(node, &restriction_map);
2612            if !prose_only_deps.is_empty() {
2613                for (dep_node_id, dep_reason) in &prose_only_deps {
2614                    self.emit_event(perspt_core::AgentEvent::StructuralDependencyMissing {
2615                        node_id: node.node_id.clone(),
2616                        dependency_node_id: dep_node_id.clone(),
2617                        reason: dep_reason.clone(),
2618                    });
2619                }
2620                let dep_names: Vec<&str> =
2621                    prose_only_deps.iter().map(|(id, _)| id.as_str()).collect();
2622                let block_reason = format!(
2623                    "Required structural dependencies lack machine-verifiable digests (only prose summaries): [{}]",
2624                    dep_names.join(", ")
2625                );
2626                self.emit_log(format!("🚫 {}", block_reason));
2627                self.graph[idx].state = NodeState::Escalated;
2628                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
2629                    node_id: self.graph[idx].node_id.clone(),
2630                    status: perspt_core::NodeStatus::Escalated,
2631                });
2632                return Err(anyhow::anyhow!(
2633                    "Structural dependency check failed for node '{}': {}",
2634                    self.graph[idx].node_id,
2635                    block_reason
2636                ));
2637            }
2638        }
2639
2640        // Record provenance for later commit
2641        self.last_context_provenance = Some(context_package.provenance());
2642        // Store formatted context for reuse in correction prompts
2643        self.last_formatted_context = formatted_context.clone();
2644
2645        // PSP-5: Speculator lookahead — ask the speculator tier for bounded
2646        // hints about potential risks and downstream impacts before the
2647        // actuator generates code. Stored as ephemeral context, not committed.
2648        let speculator_hints = {
2649            let node = &self.graph[idx];
2650            let child_goals: Vec<String> = self
2651                .graph
2652                .edges(idx)
2653                .filter_map(|edge| {
2654                    let child = &self.graph[edge.target()];
2655                    if child.state == NodeState::TaskQueued {
2656                        Some(format!("- {}: {}", child.node_id, child.goal))
2657                    } else {
2658                        None
2659                    }
2660                })
2661                .collect();
2662
2663            if !child_goals.is_empty() {
2664                let speculator_prompt = format!(
2665                    "You are a Speculator agent. Given this task and its downstream dependents, \
2666                     produce a brief (3-5 bullet) list of:\n\
2667                     1. Interface contracts the current task must satisfy for dependents\n\
2668                     2. Common pitfalls (e.g., import paths, missing exports)\n\
2669                     3. Edge cases dependents may need\n\n\
2670                     Current task: {} — {}\n\
2671                     Downstream tasks:\n{}\n\n\
2672                     Be concise. No code.",
2673                    node.node_id,
2674                    node.goal,
2675                    child_goals.join("\n")
2676                );
2677
2678                log::debug!(
2679                    "Speculator lookahead for node {} using model {}",
2680                    node.node_id,
2681                    self.speculator_model
2682                );
2683                self.call_llm_with_logging(
2684                    &self.speculator_model.clone(),
2685                    &speculator_prompt,
2686                    Some(&node.node_id),
2687                )
2688                .await
2689                .unwrap_or_else(|e| {
2690                    log::warn!(
2691                        "Speculator lookahead failed ({}), proceeding without hints",
2692                        e
2693                    );
2694                    String::new()
2695                })
2696            } else {
2697                String::new()
2698            }
2699        };
2700
2701        let actuator = &self.agents[1];
2702        let node = &self.graph[idx];
2703        let node_id = node.node_id.clone();
2704
2705        // Build prompt enriched with context package and speculator hints
2706        let base_prompt = actuator.build_prompt(node, &self.context);
2707        let mut prompt = if formatted_context.is_empty() {
2708            base_prompt
2709        } else {
2710            format!(
2711                "{}\n\n## Node Context (PSP-5 Restriction Map)\n\n{}",
2712                base_prompt, formatted_context
2713            )
2714        };
2715
2716        if !speculator_hints.is_empty() {
2717            prompt = format!(
2718                "{}\n\n## Speculator Lookahead Hints\n\n{}",
2719                prompt, speculator_hints
2720            );
2721        }
2722        let model = actuator.model().to_string();
2723
2724        let response = self
2725            .call_llm_with_logging(&model, &prompt, Some(&node_id))
2726            .await?;
2727
2728        let message = crate::types::AgentMessage::new(crate::types::ModelTier::Actuator, response);
2729        let content = &message.content;
2730
2731        // Check for [COMMAND] blocks first (for TaskType::Command)
2732        if let Some(command) = self.extract_command_from_response(content) {
2733            log::info!("Extracted command: {}", command);
2734            self.emit_log(format!("🔧 Command proposed: {}", command));
2735
2736            // Request approval before executing command
2737            let node_id = self.graph[idx].node_id.clone();
2738            let approval_result = self
2739                .await_approval_for_node(
2740                    perspt_core::ActionType::Command {
2741                        command: command.clone(),
2742                    },
2743                    format!("Execute shell command: {}", command),
2744                    None,
2745                    Some(&node_id),
2746                )
2747                .await;
2748
2749            if !matches!(
2750                approval_result,
2751                ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
2752            ) {
2753                self.emit_log("⏭️ Command skipped (not approved)");
2754                return Ok(());
2755            }
2756
2757            // Execute command via AgentTools
2758            let mut args = HashMap::new();
2759            args.insert("command".to_string(), command.clone());
2760
2761            let call = ToolCall {
2762                name: "run_command".to_string(),
2763                arguments: args,
2764            };
2765
2766            let result = self.tools.execute(&call).await;
2767            if result.success {
2768                log::info!("✓ Command succeeded: {}", command);
2769                self.emit_log(format!("✅ Command succeeded: {}", command));
2770                self.emit_log(result.output);
2771            } else {
2772                log::warn!("Command failed: {:?}", result.error);
2773                self.emit_log(format!("❌ Command failed: {:?}", result.error));
2774            }
2775        }
2776        // Then check for PSP-5 artifact bundles (with legacy single-file fallback inside)
2777        else if let Some(bundle) = self.parse_artifact_bundle(content) {
2778            let affected_files: Vec<String> = bundle
2779                .affected_paths()
2780                .into_iter()
2781                .map(ToString::to_string)
2782                .collect();
2783            log::info!(
2784                "Parsed artifact bundle for node {}: {} artifacts, {} commands",
2785                node_id,
2786                bundle.artifacts.len(),
2787                bundle.commands.len()
2788            );
2789            self.emit_log(format!(
2790                "📝 Bundle proposed: {} artifact(s) across {} file(s)",
2791                bundle.artifacts.len(),
2792                affected_files.len()
2793            ));
2794
2795            let approval_result = self
2796                .await_approval_for_node(
2797                    perspt_core::ActionType::BundleWrite {
2798                        node_id: node_id.clone(),
2799                        files: affected_files.clone(),
2800                    },
2801                    format!("Apply bundle touching: {}", affected_files.join(", ")),
2802                    serde_json::to_string_pretty(&bundle).ok(),
2803                    Some(&node_id),
2804                )
2805                .await;
2806
2807            if !matches!(
2808                approval_result,
2809                ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
2810            ) {
2811                self.emit_log("⏭️ Bundle application skipped (not approved)");
2812                return Ok(());
2813            }
2814
2815            let node_class = self.graph[idx].node_class;
2816            self.apply_bundle_transactionally(&bundle, &node_id, node_class)
2817                .await?;
2818            self.last_tool_failure = None;
2819
2820            // PSP-5 Phase 9: Store bundle for persistence in step_commit
2821            self.last_applied_bundle = Some(bundle.clone());
2822
2823            // PSP-5 Phase 9: Execute post-write commands from the bundle
2824            if !bundle.commands.is_empty() {
2825                self.emit_log(format!(
2826                    "🔧 Executing {} bundle command(s)...",
2827                    bundle.commands.len()
2828                ));
2829                let work_dir = self.effective_working_dir(idx);
2830                let is_python = self.graph[idx].owner_plugin == "python";
2831                for raw_command in &bundle.commands {
2832                    // Normalize Python install commands to uv equivalents
2833                    let command = if is_python {
2834                        Self::normalize_command_to_uv(raw_command)
2835                    } else {
2836                        raw_command.clone()
2837                    };
2838
2839                    // Request approval for each command (respects --yes auto-approve)
2840                    let cmd_approval = self
2841                        .await_approval_for_node(
2842                            perspt_core::ActionType::Command {
2843                                command: command.clone(),
2844                            },
2845                            format!("Execute bundle command: {}", command),
2846                            None,
2847                            Some(&node_id),
2848                        )
2849                        .await;
2850
2851                    if !matches!(
2852                        cmd_approval,
2853                        ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
2854                    ) {
2855                        self.emit_log(format!(
2856                            "⏭️ Bundle command skipped (not approved): {}",
2857                            command
2858                        ));
2859                        continue;
2860                    }
2861
2862                    let mut args = HashMap::new();
2863                    args.insert("command".to_string(), command.clone());
2864                    args.insert(
2865                        "working_dir".to_string(),
2866                        work_dir.to_string_lossy().to_string(),
2867                    );
2868
2869                    let call = ToolCall {
2870                        name: "run_command".to_string(),
2871                        arguments: args,
2872                    };
2873
2874                    let result = self.tools.execute(&call).await;
2875                    if result.success {
2876                        log::info!("✓ Bundle command succeeded: {}", command);
2877                        self.emit_log(format!("✅ {}", command));
2878                        if !result.output.is_empty() {
2879                            // Truncate verbose output for log
2880                            let truncated: String = result.output.chars().take(500).collect();
2881                            self.emit_log(truncated);
2882                        }
2883                    } else {
2884                        let err_msg = result.error.unwrap_or_else(|| result.output.clone());
2885                        log::warn!("Bundle command failed: {} — {}", command, err_msg);
2886                        self.emit_log(format!("❌ Command failed: {} — {}", command, err_msg));
2887                        // Record as tool failure so step_verify picks it up via V_syn
2888                        self.last_tool_failure =
2889                            Some(format!("Bundle command '{}' failed: {}", command, err_msg));
2890                    }
2891                }
2892
2893                // After all bundle commands, sync Python venv so new deps are available
2894                if is_python {
2895                    log::info!("Running uv sync --dev after bundle commands...");
2896                    let sync_result = tokio::process::Command::new("uv")
2897                        .args(["sync", "--dev"])
2898                        .current_dir(&work_dir)
2899                        .stdout(std::process::Stdio::piped())
2900                        .stderr(std::process::Stdio::piped())
2901                        .output()
2902                        .await;
2903                    match sync_result {
2904                        Ok(output) if output.status.success() => {
2905                            self.emit_log("🐍 uv sync --dev completed".to_string());
2906                        }
2907                        Ok(output) => {
2908                            let stderr = String::from_utf8_lossy(&output.stderr);
2909                            log::warn!("uv sync --dev failed: {}", stderr);
2910                        }
2911                        Err(e) => {
2912                            log::warn!("Failed to run uv sync --dev: {}", e);
2913                        }
2914                    }
2915                }
2916            }
2917        } else {
2918            log::debug!(
2919                "No code block or command found in response, response length: {}",
2920                content.len()
2921            );
2922            self.emit_log("ℹ️ No file changes detected in response".to_string());
2923        }
2924
2925        self.context.history.push(message);
2926        Ok(())
2927    }
2928
2929    /// Extract command from LLM response
2930    /// Looks for [COMMAND] pattern
2931    fn extract_command_from_response(&self, content: &str) -> Option<String> {
2932        for line in content.lines() {
2933            let trimmed = line.trim();
2934            if trimmed.starts_with("[COMMAND]") {
2935                return Some(trimmed.trim_start_matches("[COMMAND]").trim().to_string());
2936            }
2937            // Also support ```bash blocks with a command annotation
2938            if trimmed.starts_with("$ ") || trimmed.starts_with("➜ ") {
2939                return Some(
2940                    trimmed
2941                        .trim_start_matches("$ ")
2942                        .trim_start_matches("➜ ")
2943                        .trim()
2944                        .to_string(),
2945                );
2946            }
2947        }
2948        None
2949    }
2950
2951    /// Extract code from LLM response
2952    /// Returns (filename, code_content) if found
2953    /// Extract code from LLM response
2954    /// Returns (filename, code_content, is_diff) if found
2955    fn extract_code_from_response(&self, content: &str) -> Option<(String, String, bool)> {
2956        // Return only the first block for backward compatibility
2957        self.extract_all_code_blocks_from_response(content)
2958            .into_iter()
2959            .next()
2960    }
2961
2962    /// Extract ALL File:/Diff: code blocks from an LLM response.
2963    ///
2964    /// Unlike `extract_code_from_response` which returns only the first block,
2965    /// this collects every named code block so multi-file legacy responses are
2966    /// not silently truncated to a single artifact.
2967    fn extract_all_code_blocks_from_response(&self, content: &str) -> Vec<(String, String, bool)> {
2968        let lines: Vec<&str> = content.lines().collect();
2969        let mut results: Vec<(String, String, bool)> = Vec::new();
2970        let mut file_path: Option<String> = None;
2971        let mut is_diff_marker = false;
2972        let mut in_code_block = false;
2973        let mut code_lines: Vec<&str> = Vec::new();
2974        let mut code_lang = String::new();
2975
2976        for line in &lines {
2977            // Look for file path patterns
2978            if line.starts_with("File:") || line.starts_with("**File:") || line.starts_with("file:")
2979            {
2980                let path = line
2981                    .trim_start_matches("File:")
2982                    .trim_start_matches("**File:")
2983                    .trim_start_matches("file:")
2984                    .trim_start_matches("**")
2985                    .trim_end_matches("**")
2986                    .trim();
2987                if !path.is_empty() {
2988                    file_path = Some(path.to_string());
2989                    is_diff_marker = false;
2990                }
2991            }
2992
2993            // Look for Diff patterns
2994            if line.starts_with("Diff:") || line.starts_with("**Diff:") || line.starts_with("diff:")
2995            {
2996                let path = line
2997                    .trim_start_matches("Diff:")
2998                    .trim_start_matches("**Diff:")
2999                    .trim_start_matches("diff:")
3000                    .trim_start_matches("**")
3001                    .trim_end_matches("**")
3002                    .trim();
3003                if !path.is_empty() {
3004                    file_path = Some(path.to_string());
3005                    is_diff_marker = true;
3006                }
3007            }
3008
3009            // Parse code blocks
3010            if line.starts_with("```") && !in_code_block {
3011                in_code_block = true;
3012                code_lang = line.trim_start_matches('`').to_string();
3013                continue;
3014            }
3015
3016            if line.starts_with("```") && in_code_block {
3017                in_code_block = false;
3018                if !code_lines.is_empty() {
3019                    let code = code_lines.join("\n");
3020                    let filename = match file_path.take() {
3021                        Some(p) => p,
3022                        None => match code_lang.as_str() {
3023                            "python" | "py" => "main.py".to_string(),
3024                            "rust" | "rs" => "main.rs".to_string(),
3025                            "javascript" | "js" => "index.js".to_string(),
3026                            "typescript" | "ts" => "index.ts".to_string(),
3027                            "toml" => "Cargo.toml".to_string(),
3028                            "json" => "config.json".to_string(),
3029                            "yaml" | "yml" => "config.yaml".to_string(),
3030                            other => {
3031                                log::warn!(
3032                                    "Skipping unnamed code block with unrecognized language tag '{}'",
3033                                    other
3034                                );
3035                                code_lines.clear();
3036                                code_lang.clear();
3037                                is_diff_marker = false;
3038                                continue;
3039                            }
3040                        },
3041                    };
3042                    let is_diff = is_diff_marker || code_lang == "diff" || code.starts_with("---");
3043                    results.push((filename, code, is_diff));
3044                }
3045                code_lines.clear();
3046                code_lang.clear();
3047                is_diff_marker = false;
3048                continue;
3049            }
3050
3051            if in_code_block {
3052                code_lines.push(line);
3053            }
3054        }
3055
3056        results
3057    }
3058
3059    /// Step 4: Stability Verification
3060    ///
3061    /// Computes Lyapunov Energy V(x) from LSP diagnostics, contracts, and tests
3062    async fn step_verify(&mut self, idx: NodeIndex) -> Result<EnergyComponents> {
3063        log::info!("Step 4: Verification - Computing stability energy");
3064
3065        self.graph[idx].state = NodeState::Verifying;
3066        self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
3067            node_id: self.graph[idx].node_id.clone(),
3068            status: perspt_core::NodeStatus::Verifying,
3069        });
3070
3071        // Calculate energy components
3072        let mut energy = EnergyComponents::default();
3073
3074        // V_syn: From Tool Failures (Critical)
3075        if let Some(ref err) = self.last_tool_failure {
3076            energy.v_syn = 10.0; // High energy for tool failure
3077            log::warn!("Tool failure detected, V_syn set to 10.0: {}", err);
3078            self.emit_log(format!("⚠️ Tool failure prevents verification: {}", err));
3079            // We can return early or allow other checks. Usually tool failure means broken state.
3080
3081            // Store diagnostics mock for correction prompt
3082            self.context.last_diagnostics = vec![lsp_types::Diagnostic {
3083                range: lsp_types::Range::default(),
3084                severity: Some(lsp_types::DiagnosticSeverity::ERROR),
3085                code: None,
3086                code_description: None,
3087                source: Some("tool".to_string()),
3088                message: format!("Failed to apply changes: {}", err),
3089                related_information: None,
3090                tags: None,
3091                data: None,
3092            }];
3093        }
3094
3095        // V_syn: From LSP diagnostics
3096        if let Some(ref path) = self.last_written_file {
3097            // PSP-5 Phase 4: look up LSP client by the node's owner_plugin
3098            let node_plugin = self.graph[idx].owner_plugin.clone();
3099            let lsp_key = if node_plugin.is_empty() || node_plugin == "unknown" {
3100                "python".to_string() // legacy fallback
3101            } else {
3102                node_plugin
3103            };
3104
3105            if let Some(client) = self.lsp_clients.get(&lsp_key) {
3106                // Small delay to let LSP analyze the file
3107                tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
3108
3109                let path_str = path.to_string_lossy().to_string();
3110                let diagnostics = client.get_diagnostics(&path_str).await;
3111
3112                if !diagnostics.is_empty() {
3113                    energy.v_syn = LspClient::calculate_syntactic_energy(&diagnostics);
3114                    log::info!(
3115                        "LSP found {} diagnostics, V_syn={:.2}",
3116                        diagnostics.len(),
3117                        energy.v_syn
3118                    );
3119                    self.emit_log(format!("🔍 LSP found {} diagnostics:", diagnostics.len()));
3120                    for d in &diagnostics {
3121                        self.emit_log(format!(
3122                            "   - [{}] {}",
3123                            severity_to_str(d.severity),
3124                            d.message
3125                        ));
3126                    }
3127
3128                    // Store diagnostics for correction prompt
3129                    self.context.last_diagnostics = diagnostics;
3130                } else {
3131                    log::info!("LSP reports no errors (diagnostics vector is empty)");
3132                }
3133            } else {
3134                log::debug!("No LSP client available for plugin '{}'", lsp_key);
3135            }
3136
3137            // V_str: Check forbidden patterns in written file
3138            let node = &self.graph[idx];
3139            if !node.contract.forbidden_patterns.is_empty() {
3140                if let Ok(content) = std::fs::read_to_string(path) {
3141                    for pattern in &node.contract.forbidden_patterns {
3142                        if content.contains(pattern) {
3143                            energy.v_str += 0.5;
3144                            log::warn!("Forbidden pattern found: '{}'", pattern);
3145                            self.emit_log(format!("⚠️ Forbidden pattern: '{}'", pattern));
3146                        }
3147                    }
3148                }
3149            }
3150
3151            // PSP-5 Phase 9: Universal plugin verification for all node classes.
3152            // Replaces the old weighted-test-only block that only ran for Integration nodes.
3153            let node = &self.graph[idx];
3154            if self.context.defer_tests {
3155                self.emit_log("⏭️ Tests deferred (--defer-tests enabled)".to_string());
3156            } else {
3157                let plugin_name = node.owner_plugin.clone();
3158                let verify_dir = self.effective_working_dir(idx);
3159                let stages = verification_stages_for_node(node);
3160
3161                if !stages.is_empty() && !plugin_name.is_empty() && plugin_name != "unknown" {
3162                    self.emit_log(format!(
3163                        "🔬 Running verification ({} stages) for {} node '{}'...",
3164                        stages.len(),
3165                        node.node_class,
3166                        node.node_id
3167                    ));
3168
3169                    let mut vr = self
3170                        .run_plugin_verification(&plugin_name, &stages, verify_dir.clone())
3171                        .await;
3172
3173                    // Auto-dependency repair: if syntax/build failed, check if the
3174                    // root cause is missing crate dependencies and auto-install them.
3175                    if !vr.syntax_ok || !vr.build_ok {
3176                        if let Some(ref raw) = vr.raw_output {
3177                            let missing = Self::extract_missing_crates(raw);
3178                            if !missing.is_empty() {
3179                                self.emit_log(format!(
3180                                    "📦 Auto-installing missing dependencies: {}",
3181                                    missing.join(", ")
3182                                ));
3183                                let dep_ok =
3184                                    Self::auto_install_crate_deps(&missing, &verify_dir).await;
3185                                if dep_ok > 0 {
3186                                    self.emit_log(format!(
3187                                        "📦 Installed {} crate(s), re-running verification...",
3188                                        dep_ok
3189                                    ));
3190                                    // Re-run verification now that deps are installed
3191                                    vr = self
3192                                        .run_plugin_verification(
3193                                            &plugin_name,
3194                                            &stages,
3195                                            verify_dir.clone(),
3196                                        )
3197                                        .await;
3198                                }
3199                            }
3200                        }
3201                    }
3202
3203                    // Auto-dependency repair for Python: parse
3204                    // ModuleNotFoundError / ImportError from test output and
3205                    // install missing packages via `uv add`.
3206                    if plugin_name == "python" && (!vr.syntax_ok || !vr.tests_ok) {
3207                        // Collect raw output from all stage outcomes for
3208                        // broader error detection (syntax check may report
3209                        // import errors too).
3210                        let all_output: String = vr
3211                            .stage_outcomes
3212                            .iter()
3213                            .filter_map(|so| so.output.as_deref())
3214                            .collect::<Vec<_>>()
3215                            .join("\n");
3216                        let combined = match vr.raw_output.as_deref() {
3217                            Some(raw) => format!("{}\n{}", raw, all_output),
3218                            None => all_output,
3219                        };
3220
3221                        let missing = Self::extract_missing_python_modules(&combined);
3222                        if !missing.is_empty() {
3223                            self.emit_log(format!(
3224                                "🐍 Auto-installing missing Python packages: {}",
3225                                missing.join(", ")
3226                            ));
3227                            let dep_ok =
3228                                Self::auto_install_python_deps(&missing, &verify_dir).await;
3229                            if dep_ok > 0 {
3230                                self.emit_log(format!(
3231                                    "🐍 Installed {} package(s), re-running verification...",
3232                                    dep_ok
3233                                ));
3234                                vr = self
3235                                    .run_plugin_verification(
3236                                        &plugin_name,
3237                                        &stages,
3238                                        verify_dir.clone(),
3239                                    )
3240                                    .await;
3241                            }
3242                        }
3243                    }
3244
3245                    // Map verification result to energy components:
3246                    // - Syntax fail → V_syn (cap at 5.0, don't override tool-failure 10.0)
3247                    if !vr.syntax_ok && energy.v_syn < 5.0 {
3248                        energy.v_syn = 5.0;
3249                    }
3250                    // - Build fail → V_syn (cap at 8.0, don't override higher)
3251                    if !vr.build_ok && energy.v_syn < 8.0 {
3252                        energy.v_syn = 8.0;
3253                    }
3254                    // - Test fail → V_log (weighted calculation)
3255                    if !vr.tests_ok && vr.tests_failed > 0 {
3256                        let node = &self.graph[idx];
3257                        if !node.contract.weighted_tests.is_empty() {
3258                            // Use weighted test calculation if contract has weights
3259                            let py_runner = PythonTestRunner::new(verify_dir);
3260                            let test_results = TestResults {
3261                                passed: vr.tests_passed,
3262                                failed: vr.tests_failed,
3263                                total: vr.tests_passed + vr.tests_failed,
3264                                output: vr.raw_output.clone().unwrap_or_default(),
3265                                failures: Vec::new(),
3266                                run_succeeded: true,
3267                                skipped: 0,
3268                                duration_ms: 0,
3269                            };
3270                            energy.v_log = py_runner.calculate_v_log(&test_results, &node.contract);
3271                        } else {
3272                            // Simple: proportion of failures
3273                            let total = (vr.tests_passed + vr.tests_failed) as f32;
3274                            if total > 0.0 {
3275                                energy.v_log = (vr.tests_failed as f32 / total) * 10.0;
3276                            }
3277                        }
3278                    }
3279                    // - Lint fail → V_str penalty
3280                    if !vr.lint_ok
3281                        && self.context.verifier_strictness
3282                            == perspt_core::types::VerifierStrictness::Strict
3283                    {
3284                        energy.v_str += 0.3;
3285                    }
3286
3287                    // D1: Feed raw output into correction context
3288                    if let Some(ref raw) = vr.raw_output {
3289                        self.context.last_test_output = Some(raw.clone());
3290                    }
3291
3292                    self.emit_log(format!("📊 Verification: {}", vr.summary));
3293                }
3294            }
3295        }
3296
3297        let node = &self.graph[idx];
3298        // Record energy in persistent ledger
3299        if let Err(e) =
3300            self.ledger
3301                .record_energy(&node.node_id, &energy, energy.total(&node.contract))
3302        {
3303            log::error!("Failed to record energy: {}", e);
3304        }
3305
3306        log::info!(
3307            "Energy for {}: V_syn={:.2}, V_str={:.2}, V_log={:.2}, V_boot={:.2}, V_sheaf={:.2}, Total={:.2}",
3308            node.node_id,
3309            energy.v_syn,
3310            energy.v_str,
3311            energy.v_log,
3312            energy.v_boot,
3313            energy.v_sheaf,
3314            energy.total(&node.contract)
3315        );
3316
3317        // PSP-5 Phase 7: Emit enriched VerificationComplete event
3318        {
3319            let node = &self.graph[idx];
3320            let total = energy.total(&node.contract);
3321            let (
3322                stage_outcomes,
3323                degraded,
3324                degraded_reasons,
3325                summary,
3326                lint_ok,
3327                tests_passed,
3328                tests_failed,
3329            ) = if let Some(ref vr) = self.last_verification_result {
3330                (
3331                    vr.stage_outcomes.clone(),
3332                    vr.degraded,
3333                    vr.degraded_stage_reasons(),
3334                    vr.summary.clone(),
3335                    vr.lint_ok,
3336                    vr.tests_passed,
3337                    vr.tests_failed,
3338                )
3339            } else {
3340                let diag_count = self.context.last_diagnostics.len();
3341                (
3342                    Vec::new(),
3343                    false,
3344                    Vec::new(),
3345                    format!("V(x)={:.2} | {} diagnostics", total, diag_count),
3346                    true,
3347                    0,
3348                    0,
3349                )
3350            };
3351
3352            self.emit_event(perspt_core::AgentEvent::VerificationComplete {
3353                node_id: node.node_id.clone(),
3354                syntax_ok: energy.v_syn == 0.0,
3355                build_ok: energy.v_syn < 5.0,
3356                tests_ok: energy.v_log == 0.0,
3357                lint_ok,
3358                diagnostics_count: self.context.last_diagnostics.len(),
3359                tests_passed,
3360                tests_failed,
3361                energy: total,
3362                energy_components: energy.clone(),
3363                stage_outcomes,
3364                degraded,
3365                degraded_reasons,
3366                summary,
3367                node_class: node.node_class.to_string(),
3368            });
3369        }
3370
3371        Ok(energy)
3372    }
3373
3374    /// Step 5: Convergence & Self-Correction
3375    ///
3376    /// Returns true if converged, false if should escalate
3377    async fn step_converge(&mut self, idx: NodeIndex, energy: EnergyComponents) -> Result<bool> {
3378        log::info!("Step 5: Convergence check");
3379
3380        // First compute what we need from the node
3381        let total = {
3382            let node = &self.graph[idx];
3383            energy.total(&node.contract)
3384        };
3385
3386        // Now mutate
3387        let node = &mut self.graph[idx];
3388        node.monitor.record_energy(total);
3389        let node_id = node.node_id.clone();
3390        let goal = node.goal.clone();
3391        let epsilon = node.monitor.stability_epsilon;
3392        let attempt_count = node.monitor.attempt_count;
3393        let stable = node.monitor.stable;
3394        let should_escalate = node.monitor.should_escalate();
3395
3396        if stable {
3397            // PSP-5 Phase 4: Block false stability when verification was degraded
3398            if let Some(ref vr) = self.last_verification_result {
3399                if vr.has_degraded_stages() {
3400                    let reasons = vr.degraded_stage_reasons();
3401                    log::warn!(
3402                        "Node {} energy is below ε but verification was degraded: {:?}",
3403                        node_id,
3404                        reasons
3405                    );
3406                    self.emit_log(format!(
3407                        "⚠️ V(x)={:.2} < ε but stability unconfirmed — degraded sensors: {}",
3408                        total,
3409                        reasons.join(", ")
3410                    ));
3411                    self.emit_event(perspt_core::AgentEvent::DegradedVerification {
3412                        node_id: node_id.clone(),
3413                        degraded_stages: reasons,
3414                        stability_blocked: true,
3415                    });
3416                    // Do NOT return Ok(true) — fall through to correction loop
3417                    // so the orchestrator retries with awareness that some sensors
3418                    // were unavailable.
3419                } else {
3420                    log::info!(
3421                        "Node {} is stable (V(x)={:.2} < ε={:.2})",
3422                        node_id,
3423                        total,
3424                        epsilon
3425                    );
3426                    self.emit_log(format!("✅ Stable! V(x)={:.2} < ε={:.2}", total, epsilon));
3427                    return Ok(true);
3428                }
3429            } else {
3430                log::info!(
3431                    "Node {} is stable (V(x)={:.2} < ε={:.2})",
3432                    node_id,
3433                    total,
3434                    epsilon
3435                );
3436                self.emit_log(format!("✅ Stable! V(x)={:.2} < ε={:.2}", total, epsilon));
3437                return Ok(true);
3438            }
3439        }
3440
3441        if should_escalate {
3442            log::warn!(
3443                "Node {} failed to converge after {} attempts (V(x)={:.2})",
3444                node_id,
3445                attempt_count,
3446                total
3447            );
3448            self.emit_log(format!(
3449                "⚠️ Escalating: failed to converge after {} attempts",
3450                attempt_count
3451            ));
3452            return Ok(false);
3453        }
3454
3455        // === CORRECTION LOOP ===
3456        self.graph[idx].state = NodeState::Retry;
3457        self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
3458            node_id: self.graph[idx].node_id.clone(),
3459            status: perspt_core::NodeStatus::Retrying,
3460        });
3461        log::info!(
3462            "V(x)={:.2} > ε={:.2}, regenerating with feedback (attempt {})",
3463            total,
3464            epsilon,
3465            attempt_count
3466        );
3467        self.emit_log(format!(
3468            "🔄 V(x)={:.2} > ε={:.2}, sending errors to LLM (attempt {})",
3469            total, epsilon, attempt_count
3470        ));
3471
3472        // Build correction prompt with diagnostics
3473        let correction_prompt = self.build_correction_prompt(&node_id, &goal, &energy)?;
3474
3475        log::info!(
3476            "--- CORRECTION PROMPT ---\n{}\n-------------------------",
3477            correction_prompt
3478        );
3479        // Don't emit the full correction prompt to TUI - it's too verbose
3480        self.emit_log("📤 Sending correction prompt to LLM...".to_string());
3481
3482        // Call LLM for corrected code
3483        let corrected = self.call_llm_for_correction(&correction_prompt).await?;
3484
3485        // Extract and apply diff
3486        if let Some((filename, new_code, is_diff)) = self.extract_code_from_response(&corrected) {
3487            let full_path = self.context.working_dir.join(&filename);
3488
3489            // Write corrected file
3490            let mut args = HashMap::new();
3491            args.insert("path".to_string(), filename.clone());
3492
3493            let call = if is_diff {
3494                args.insert("diff".to_string(), new_code.clone());
3495                ToolCall {
3496                    name: "apply_diff".to_string(),
3497                    arguments: args,
3498                }
3499            } else {
3500                args.insert("content".to_string(), new_code.clone());
3501                ToolCall {
3502                    name: "write_file".to_string(),
3503                    arguments: args,
3504                }
3505            };
3506
3507            let result = self.tools.execute(&call).await;
3508            if result.success {
3509                log::info!("✓ Applied correction to: {}", filename);
3510                self.emit_log(format!("📝 Applied correction to: {}", filename));
3511                self.last_tool_failure = None;
3512
3513                // Update tracking
3514                self.last_written_file = Some(full_path.clone());
3515                self.file_version += 1;
3516
3517                // Notify LSP of file change
3518                let lsp_key = self.lsp_key_for_file(&full_path.to_string_lossy());
3519                if let Some(client) = lsp_key.and_then(|k| self.lsp_clients.get_mut(&k)) {
3520                    if let Ok(content) = std::fs::read_to_string(&full_path) {
3521                        let _ = client
3522                            .did_change(&full_path, &content, self.file_version)
3523                            .await;
3524                    }
3525                }
3526            } else {
3527                self.last_tool_failure = result.error;
3528            }
3529        }
3530
3531        // Extract and execute any dependency commands from the correction response
3532        let correction_cmds = Self::extract_commands_from_correction(&corrected);
3533        if !correction_cmds.is_empty() {
3534            self.emit_log(format!(
3535                "📦 Running {} dependency command(s) from correction...",
3536                correction_cmds.len()
3537            ));
3538            let work_dir = self.effective_working_dir(idx);
3539            for cmd in &correction_cmds {
3540                log::info!("Running correction command: {}", cmd);
3541                let parts: Vec<&str> = cmd.split_whitespace().collect();
3542                if parts.is_empty() {
3543                    continue;
3544                }
3545                let output = tokio::process::Command::new(parts[0])
3546                    .args(&parts[1..])
3547                    .current_dir(&work_dir)
3548                    .stdout(std::process::Stdio::piped())
3549                    .stderr(std::process::Stdio::piped())
3550                    .output()
3551                    .await;
3552                match output {
3553                    Ok(o) if o.status.success() => {
3554                        self.emit_log(format!("✅ {}", cmd));
3555                    }
3556                    Ok(o) => {
3557                        let stderr = String::from_utf8_lossy(&o.stderr);
3558                        log::warn!("Command failed: {} — {}", cmd, stderr);
3559                    }
3560                    Err(e) => {
3561                        log::warn!("Failed to run command: {} — {}", cmd, e);
3562                    }
3563                }
3564            }
3565        }
3566
3567        // Re-verify (recursive correction loop)
3568        let new_energy = self.step_verify(idx).await?;
3569        Box::pin(self.step_converge(idx, new_energy)).await
3570    }
3571
3572    /// Build a correction prompt with diagnostic details.
3573    ///
3574    /// PSP-5 Phase 3: Language-agnostic, uses the node's actual output targets
3575    /// and includes formatted restriction-map context so the LLM has structural
3576    /// awareness during correction.
3577    fn build_correction_prompt(
3578        &self,
3579        _node_id: &str,
3580        goal: &str,
3581        energy: &EnergyComponents,
3582    ) -> Result<String> {
3583        let diagnostics = &self.context.last_diagnostics;
3584
3585        // Read current code from the last written file
3586        let current_code = if let Some(ref path) = self.last_written_file {
3587            std::fs::read_to_string(path).unwrap_or_default()
3588        } else {
3589            String::new()
3590        };
3591
3592        let file_path = self
3593            .last_written_file
3594            .as_ref()
3595            .map(|p| {
3596                p.file_name()
3597                    .unwrap_or_default()
3598                    .to_string_lossy()
3599                    .to_string()
3600            })
3601            .unwrap_or_else(|| "unknown".to_string());
3602
3603        // Detect language from file extension for code fences and instructions
3604        let lang = self
3605            .last_written_file
3606            .as_ref()
3607            .and_then(|p| p.extension())
3608            .and_then(|e| e.to_str())
3609            .map(|ext| match ext {
3610                "py" => "python",
3611                "rs" => "rust",
3612                "ts" | "tsx" => "typescript",
3613                "js" | "jsx" => "javascript",
3614                "go" => "go",
3615                "java" => "java",
3616                "rb" => "ruby",
3617                "c" | "h" => "c",
3618                "cpp" | "cc" | "cxx" | "hpp" => "cpp",
3619                "cs" => "csharp",
3620                other => other,
3621            })
3622            .unwrap_or("text");
3623
3624        let mut prompt = format!(
3625            r#"## Code Correction Required
3626
3627The code you generated has {} error(s) detected by the language toolchain.
3628Your task is to fix ALL errors and return the complete corrected file.
3629
3630### Original Goal
3631{}
3632
3633### Current Code (with errors)
3634File: {}
3635```{}
3636{}
3637```
3638
3639### Detected Errors (V_syn = {:.2})
3640"#,
3641            diagnostics.len(),
3642            goal,
3643            file_path,
3644            lang,
3645            current_code,
3646            energy.v_syn
3647        );
3648
3649        // Add each diagnostic with specific fix direction
3650        for (i, diag) in diagnostics.iter().enumerate() {
3651            let fix_direction = self.get_fix_direction(diag);
3652            prompt.push_str(&format!(
3653                r#"
3654#### Error {}
3655- **Location**: Line {}, Column {}
3656- **Severity**: {}
3657- **Message**: {}
3658- **How to fix**: {}
3659"#,
3660                i + 1,
3661                diag.range.start.line + 1,
3662                diag.range.start.character + 1,
3663                severity_to_str(diag.severity),
3664                diag.message,
3665                fix_direction
3666            ));
3667        }
3668
3669        // PSP-5 Phase 3: Include restriction-map context so the LLM can
3670        // reference structural dependencies and sealed interfaces during
3671        // correction instead of operating blind.
3672        if !self.last_formatted_context.is_empty() {
3673            prompt.push_str(&format!(
3674                "\n### Restriction Map Context\n\n{}\n",
3675                self.last_formatted_context
3676            ));
3677        }
3678
3679        // Include raw build/test output from plugin verification if available.
3680        // This is crucial because LSP diagnostics may not report missing crate
3681        // errors that `cargo check` / `cargo build` would catch.
3682        if let Some(ref test_output) = self.context.last_test_output {
3683            if !test_output.is_empty() {
3684                // Truncate to avoid blowing up the prompt
3685                let truncated = if test_output.len() > 3000 {
3686                    &test_output[..3000]
3687                } else {
3688                    test_output.as_str()
3689                };
3690                prompt.push_str(&format!(
3691                    "\n### Build / Test Output\nThe following is the raw output from the build toolchain (e.g. `cargo check` / `cargo build`). \
3692                     Use this to identify missing dependencies, unresolved imports, or type errors:\n```\n{}\n```\n",
3693                    truncated
3694                ));
3695            }
3696        }
3697
3698        prompt.push_str(&format!(
3699            r#"
3700### Fix Requirements
37011. Fix ALL errors listed above - do not leave any unfixed
37022. Maintain the original functionality and goal
37033. Follow {} language conventions and idioms
37044. Import any missing modules or dependencies
37055. Return the COMPLETE corrected file, not just snippets
37066. If errors mention missing crates/packages (e.g. "can't find crate", "unresolved import" for an external dependency, "ModuleNotFoundError", "No module named"), list the required install commands
3707
3708### Output Format
3709Provide the complete corrected file followed by any dependency commands needed:
3710
3711File: [same filename]
3712```{}
3713[complete corrected code]
3714```
3715
3716Commands: [optional, one per line]
3717```
3718cargo add thiserror
3719cargo add clap --features derive
3720uv add httpx
3721uv add --dev pytest
3722```
3723"#,
3724            lang, lang
3725        ));
3726
3727        Ok(prompt)
3728    }
3729
3730    /// Map diagnostic message patterns to specific fix directions
3731    fn get_fix_direction(&self, diag: &lsp_types::Diagnostic) -> String {
3732        let msg = diag.message.to_lowercase();
3733
3734        if msg.contains("undefined") || msg.contains("unresolved") || msg.contains("not defined") {
3735            if msg.contains("crate") || msg.contains("module") {
3736                "The crate may not be in Cargo.toml. Add it with `cargo add <crate>` in the Commands section, or use `crate::` for intra-crate imports".into()
3737            } else {
3738                "Define the missing variable/function, or import it from the correct module".into()
3739            }
3740        } else if msg.contains("type") && (msg.contains("expected") || msg.contains("incompatible"))
3741        {
3742            "Change the value or add a type conversion to match the expected type".into()
3743        } else if msg.contains("import") || msg.contains("no module named") {
3744            "Add the correct import statement at the top of the file. For Python: use `uv add <pkg>` for external packages; use relative imports (`from . import mod`) inside package modules.".into()
3745        } else if msg.contains("argument") && (msg.contains("missing") || msg.contains("expected"))
3746        {
3747            "Provide all required arguments to the function call".into()
3748        } else if msg.contains("return") && msg.contains("type") {
3749            "Ensure the return statement returns a value of the declared return type".into()
3750        } else if msg.contains("attribute") {
3751            "Check if the object has this attribute, or fix the object type".into()
3752        } else if msg.contains("syntax") {
3753            "Fix the syntax error - check for missing colons, parentheses, or indentation".into()
3754        } else if msg.contains("indentation") {
3755            "Fix the indentation to match Python's indentation rules (4 spaces per level)".into()
3756        } else if msg.contains("parameter") {
3757            "Check the function signature and update parameter types/names".into()
3758        } else {
3759            format!("Review and fix: {}", diag.message)
3760        }
3761    }
3762
3763    /// Call LLM for code correction using a verifier-guided two-stage flow.
3764    ///
3765    /// Stage 1 (verifier tier): Analyze the failure diagnostics and produce
3766    /// structured correction guidance — root cause, which lines/functions to
3767    /// change, and constraints to preserve.
3768    ///
3769    /// Stage 2 (actuator tier): Apply the verifier's guidance to produce
3770    /// the corrected code artifact.
3771    async fn call_llm_for_correction(&self, prompt: &str) -> Result<String> {
3772        // Stage 1: Verifier analyzes the failure
3773        let verifier_prompt = format!(
3774            "You are a Verifier agent. Analyze the following correction request and produce \
3775             concise, structured guidance for the code fixer. Identify:\n\
3776             1. Root cause of each failure\n\
3777             2. Which specific functions/lines need changes\n\
3778             3. Constraints that must be preserved\n\
3779             Do NOT produce code — only analysis and guidance.\n\n{}",
3780            prompt
3781        );
3782
3783        log::debug!(
3784            "Stage 1: Sending analysis to verifier model: {}",
3785            self.verifier_model
3786        );
3787        let guidance = self
3788            .call_llm_with_logging(&self.verifier_model.clone(), &verifier_prompt, None)
3789            .await
3790            .unwrap_or_else(|e| {
3791                log::warn!(
3792                    "Verifier analysis failed ({}), falling back to actuator-only correction",
3793                    e
3794                );
3795                String::new()
3796            });
3797
3798        // Stage 2: Actuator applies the guidance
3799        let actuator_prompt = if guidance.is_empty() {
3800            prompt.to_string()
3801        } else {
3802            format!(
3803                "{}\n\n## Verifier Analysis\n{}\n\nApply the above analysis to produce corrected code.",
3804                prompt, guidance
3805            )
3806        };
3807
3808        log::debug!(
3809            "Stage 2: Sending correction to actuator model: {}",
3810            self.actuator_model
3811        );
3812        let response = self
3813            .call_llm_with_logging(&self.actuator_model.clone(), &actuator_prompt, None)
3814            .await?;
3815        log::debug!("Received correction response with {} chars", response.len());
3816
3817        Ok(response)
3818    }
3819
3820    /// Call LLM and immediately persist the request/response to database if logging is enabled
3821    async fn call_llm_with_logging(
3822        &self,
3823        model: &str,
3824        prompt: &str,
3825        node_id: Option<&str>,
3826    ) -> Result<String> {
3827        let start = Instant::now();
3828
3829        let response = self
3830            .provider
3831            .generate_response_simple(model, prompt)
3832            .await?;
3833
3834        // Immediately persist to database if logging is enabled
3835        if self.context.log_llm {
3836            let latency_ms = start.elapsed().as_millis() as i32;
3837            if let Err(e) = self
3838                .ledger
3839                .record_llm_request(model, prompt, &response, node_id, latency_ms)
3840            {
3841                log::warn!("Failed to persist LLM request: {}", e);
3842            } else {
3843                log::debug!(
3844                    "Persisted LLM request: model={}, latency={}ms",
3845                    model,
3846                    latency_ms
3847                );
3848            }
3849        }
3850
3851        Ok(response)
3852    }
3853
3854    /// PSP-5 Phase 1/4: Call LLM with tier-aware fallback.
3855    ///
3856    /// If the primary model returns a response that fails structured-output
3857    /// contract validation (`validator` returns `Err`), and a fallback model
3858    /// is configured for the given tier, retry with the fallback. Emits a
3859    /// `ModelFallback` event on switch. Returns the raw response string.
3860    async fn call_llm_with_tier_fallback<F>(
3861        &self,
3862        primary_model: &str,
3863        prompt: &str,
3864        node_id: Option<&str>,
3865        tier: ModelTier,
3866        validator: F,
3867    ) -> Result<String>
3868    where
3869        F: Fn(&str) -> std::result::Result<(), String>,
3870    {
3871        // Try primary model
3872        let response = self
3873            .call_llm_with_logging(primary_model, prompt, node_id)
3874            .await?;
3875
3876        // Validate structured output
3877        if validator(&response).is_ok() {
3878            return Ok(response);
3879        }
3880
3881        let validation_err = validator(&response).unwrap_err();
3882        log::warn!(
3883            "Primary model '{}' failed structured-output contract for {:?}: {}",
3884            primary_model,
3885            tier,
3886            validation_err
3887        );
3888
3889        // Look up fallback model for this tier
3890        let fallback = match tier {
3891            ModelTier::Architect => self.architect_fallback_model.as_deref(),
3892            ModelTier::Actuator => self.actuator_fallback_model.as_deref(),
3893            ModelTier::Verifier => self.verifier_fallback_model.as_deref(),
3894            ModelTier::Speculator => self.speculator_fallback_model.as_deref(),
3895        };
3896
3897        // If no explicit fallback configured, retry with the same primary model.
3898        // This gives the LLM a second chance at structured output without
3899        // requiring explicit fallback config for every tier.
3900        let fallback_model = fallback.unwrap_or(primary_model);
3901
3902        log::info!(
3903            "Falling back to model '{}' for {:?} tier",
3904            fallback_model,
3905            tier
3906        );
3907        self.emit_event_ref(perspt_core::AgentEvent::ModelFallback {
3908            node_id: node_id.unwrap_or("").to_string(),
3909            tier: format!("{:?}", tier),
3910            primary_model: primary_model.to_string(),
3911            fallback_model: fallback_model.to_string(),
3912            reason: validation_err,
3913        });
3914
3915        self.call_llm_with_logging(fallback_model, prompt, node_id)
3916            .await
3917    }
3918
3919    /// Emit an event from a &self context (non-mutable).
3920    fn emit_event_ref(&self, event: perspt_core::AgentEvent) {
3921        if let Some(sender) = &self.event_sender {
3922            let _ = sender.send(event);
3923        }
3924    }
3925
3926    /// Step 6: Sheaf Validation
3927    async fn step_sheaf_validate(&mut self, idx: NodeIndex) -> Result<()> {
3928        log::info!("Step 6: Sheaf Validation - Cross-node consistency check");
3929
3930        self.graph[idx].state = NodeState::SheafCheck;
3931        self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
3932            node_id: self.graph[idx].node_id.clone(),
3933            status: perspt_core::NodeStatus::SheafCheck,
3934        });
3935
3936        // Determine which validators to run for this node.
3937        let validators = self.select_validators(idx);
3938        if validators.is_empty() {
3939            log::info!("No targeted validators selected — skipping sheaf check");
3940            return Ok(());
3941        }
3942
3943        log::info!(
3944            "Running {} sheaf validators for node {}",
3945            validators.len(),
3946            self.graph[idx].node_id
3947        );
3948
3949        let mut results = Vec::new();
3950        for class in &validators {
3951            let result = self.run_sheaf_validator(idx, *class);
3952            results.push(result);
3953        }
3954
3955        // Persist all validation results.
3956        let persist_node_id = self.graph[idx].node_id.clone();
3957        for result in &results {
3958            if let Err(e) = self
3959                .ledger
3960                .record_sheaf_validation(&persist_node_id, result)
3961            {
3962                log::warn!("Failed to persist sheaf validation: {}", e);
3963            }
3964        }
3965
3966        // Accumulate V_sheaf and check for failures.
3967        let total_v_sheaf: f32 = results.iter().map(|r| r.v_sheaf_contribution).sum();
3968        let failures: Vec<&SheafValidationResult> = results.iter().filter(|r| !r.passed).collect();
3969        let failure_count = failures.len();
3970
3971        // Emit sheaf validation event.
3972        self.emit_event(perspt_core::AgentEvent::SheafValidationComplete {
3973            node_id: self.graph[idx].node_id.clone(),
3974            validators_run: results.len(),
3975            failures: failure_count,
3976            v_sheaf: total_v_sheaf,
3977        });
3978
3979        if !failures.is_empty() {
3980            let node_id = self.graph[idx].node_id.clone();
3981            let evidence = failures
3982                .iter()
3983                .map(|f| format!("{}: {}", f.validator_class, f.evidence_summary))
3984                .collect::<Vec<_>>()
3985                .join("; ");
3986
3987            self.emit_log(format!(
3988                "⚠️ Sheaf validation failed for {} (V_sheaf={:.3}): {}",
3989                node_id, total_v_sheaf, evidence
3990            ));
3991
3992            // Collect unique requeue targets from all failures.
3993            let requeue_targets: Vec<String> = failures
3994                .iter()
3995                .flat_map(|f| f.requeue_targets.iter().cloned())
3996                .collect::<std::collections::HashSet<_>>()
3997                .into_iter()
3998                .collect();
3999
4000            if !requeue_targets.is_empty() {
4001                self.emit_log(format!(
4002                    "🔄 Requeuing {} nodes due to sheaf failures",
4003                    requeue_targets.len()
4004                ));
4005                for nid in &requeue_targets {
4006                    if let Some(&nidx) = self.node_indices.get(nid.as_str()) {
4007                        self.graph[nidx].state = NodeState::TaskQueued;
4008                        self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4009                            node_id: self.graph[nidx].node_id.clone(),
4010                            status: perspt_core::NodeStatus::Queued,
4011                        });
4012                    }
4013                }
4014            }
4015
4016            anyhow::bail!("Sheaf validation failed for node {}: {}", node_id, evidence);
4017        }
4018
4019        log::info!("Sheaf validation passed (V_sheaf={:.3})", total_v_sheaf);
4020        Ok(())
4021    }
4022
4023    /// Select which validator classes are relevant for the given node
4024    /// based on its properties and graph position.
4025    fn select_validators(&self, idx: NodeIndex) -> Vec<SheafValidatorClass> {
4026        let mut validators = Vec::new();
4027
4028        // Always run dependency graph consistency — it's cheap and universal.
4029        validators.push(SheafValidatorClass::DependencyGraphConsistency);
4030
4031        let node = &self.graph[idx];
4032
4033        // Interface nodes need export/import consistency checks.
4034        if node.node_class == perspt_core::types::NodeClass::Interface {
4035            validators.push(SheafValidatorClass::ExportImportConsistency);
4036        }
4037
4038        // Integration nodes cross ownership boundaries.
4039        if node.node_class == perspt_core::types::NodeClass::Integration {
4040            validators.push(SheafValidatorClass::ExportImportConsistency);
4041            validators.push(SheafValidatorClass::SchemaContractCompatibility);
4042        }
4043
4044        // Nodes that touch multiple plugins get cross-language validation.
4045        let node_owner = &node.owner_plugin;
4046        let has_cross_plugin_deps = self
4047            .graph
4048            .neighbors_directed(idx, petgraph::Direction::Outgoing)
4049            .any(|dep_idx| self.graph[dep_idx].owner_plugin != *node_owner);
4050        if has_cross_plugin_deps {
4051            validators.push(SheafValidatorClass::CrossLanguageBoundary);
4052        }
4053
4054        // If verification result is available and has build failures, check
4055        // build graph consistency.
4056        if let Some(ref vr) = self.last_verification_result {
4057            if !vr.build_ok {
4058                validators.push(SheafValidatorClass::BuildGraphConsistency);
4059            }
4060            // Only check test ownership when tests actually ran and failed
4061            // (tests_failed > 0), not when tests were skipped due to
4062            // syntax/build short-circuit (which leaves tests_ok = false
4063            // with tests_failed = 0).
4064            if !vr.tests_ok && vr.tests_failed > 0 {
4065                validators.push(SheafValidatorClass::TestOwnershipConsistency);
4066            }
4067        }
4068
4069        validators
4070    }
4071
4072    /// Run a single sheaf validator class against the current node context.
4073    fn run_sheaf_validator(
4074        &self,
4075        idx: NodeIndex,
4076        class: SheafValidatorClass,
4077    ) -> SheafValidationResult {
4078        let node = &self.graph[idx];
4079        let node_id = &node.node_id;
4080
4081        match class {
4082            SheafValidatorClass::DependencyGraphConsistency => {
4083                // Check for cycles in the task graph.
4084                if petgraph::algo::is_cyclic_directed(&self.graph) {
4085                    SheafValidationResult::failed(
4086                        class,
4087                        "Cyclic dependency detected in task graph",
4088                        vec![node_id.clone()],
4089                        self.affected_dependents(idx),
4090                        0.5,
4091                    )
4092                } else {
4093                    SheafValidationResult::passed(class, vec![node_id.clone()])
4094                }
4095            }
4096            SheafValidatorClass::ExportImportConsistency => {
4097                // Check that outgoing neighbors' context files include this node's outputs.
4098                let manifest = &self.context.ownership_manifest;
4099                let mut mismatched = Vec::new();
4100
4101                for target in &node.output_targets {
4102                    let target_str = target.to_string_lossy();
4103                    if let Some(entry) = manifest.owner_of(&target_str) {
4104                        if entry.owner_node_id != *node_id {
4105                            mismatched.push(target_str.to_string());
4106                        }
4107                    }
4108                }
4109
4110                if mismatched.is_empty() {
4111                    SheafValidationResult::passed(class, vec![node_id.clone()])
4112                } else {
4113                    SheafValidationResult::failed(
4114                        class,
4115                        format!(
4116                            "Ownership mismatch on {} file(s): {}",
4117                            mismatched.len(),
4118                            mismatched.join(", ")
4119                        ),
4120                        mismatched,
4121                        vec![node_id.clone()],
4122                        0.3,
4123                    )
4124                }
4125            }
4126            SheafValidatorClass::SchemaContractCompatibility => {
4127                // Check that the node's behavioral contract is not empty.
4128                let contract = &node.contract;
4129                if contract.invariants.is_empty() && contract.interface_signature.is_empty() {
4130                    SheafValidationResult::failed(
4131                        class,
4132                        "Integration node has empty contract",
4133                        node.output_targets
4134                            .iter()
4135                            .map(|t| t.to_string_lossy().to_string())
4136                            .collect(),
4137                        vec![node_id.clone()],
4138                        0.2,
4139                    )
4140                } else {
4141                    SheafValidationResult::passed(class, vec![node_id.clone()])
4142                }
4143            }
4144            SheafValidatorClass::BuildGraphConsistency => {
4145                // When build fails, check if this node's files are referenced
4146                // by others that might have broken.
4147                let dependents = self.affected_dependents(idx);
4148                if dependents.is_empty() {
4149                    SheafValidationResult::passed(class, vec![node_id.clone()])
4150                } else {
4151                    SheafValidationResult::failed(
4152                        class,
4153                        format!(
4154                            "Build failed with {} dependent nodes potentially affected",
4155                            dependents.len()
4156                        ),
4157                        node.output_targets
4158                            .iter()
4159                            .map(|t| t.to_string_lossy().to_string())
4160                            .collect(),
4161                        dependents,
4162                        0.4,
4163                    )
4164                }
4165            }
4166            SheafValidatorClass::TestOwnershipConsistency => {
4167                // When tests fail, attribute failures to the owning node.
4168                let owned_files = self.context.ownership_manifest.files_owned_by(node_id);
4169                if owned_files.is_empty() {
4170                    SheafValidationResult::passed(class, vec![node_id.clone()])
4171                } else {
4172                    // If tests are failing and this node owns files, it's a
4173                    // candidate for re-examination.
4174                    SheafValidationResult::failed(
4175                        class,
4176                        format!(
4177                            "Test failures may be attributed to {} owned file(s)",
4178                            owned_files.len()
4179                        ),
4180                        owned_files.iter().map(|s| s.to_string()).collect(),
4181                        vec![node_id.clone()],
4182                        0.3,
4183                    )
4184                }
4185            }
4186            SheafValidatorClass::CrossLanguageBoundary => {
4187                // Check that cross-plugin dependencies have matching plugins.
4188                let mut boundary_issues = Vec::new();
4189                let node_plugin = &node.owner_plugin;
4190
4191                for dep_idx in self
4192                    .graph
4193                    .neighbors_directed(idx, petgraph::Direction::Outgoing)
4194                {
4195                    let dep = &self.graph[dep_idx];
4196                    if dep.owner_plugin != *node_plugin && !dep.owner_plugin.is_empty() {
4197                        // Cross-plugin edge — check both are active.
4198                        if !self.context.active_plugins.contains(&dep.owner_plugin) {
4199                            boundary_issues.push(format!("plugin {} not active", dep.owner_plugin));
4200                        }
4201                    }
4202                }
4203
4204                if boundary_issues.is_empty() {
4205                    SheafValidationResult::passed(class, vec![node_id.clone()])
4206                } else {
4207                    SheafValidationResult::failed(
4208                        class,
4209                        boundary_issues.join("; "),
4210                        vec![node_id.clone()],
4211                        self.affected_dependents(idx),
4212                        0.4,
4213                    )
4214                }
4215            }
4216            SheafValidatorClass::PolicyInvariantConsistency => {
4217                // Placeholder: policy checks would consult perspt-policy crate.
4218                SheafValidationResult::passed(class, vec![node_id.clone()])
4219            }
4220        }
4221    }
4222
4223    /// Step 7: Merkle Ledger Commit
4224    async fn step_commit(&mut self, idx: NodeIndex) -> Result<()> {
4225        log::info!("Step 7: Committing stable state to ledger");
4226
4227        self.graph[idx].state = NodeState::Committing;
4228        self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4229            node_id: self.graph[idx].node_id.clone(),
4230            status: perspt_core::NodeStatus::Committing,
4231        });
4232
4233        // PSP-5 Phase 6: Copy sandbox artifacts back to live workspace before
4234        // persisting any state, so the commit reflects the final files.
4235        if let Some(sandbox_dir) = self.sandbox_dir_for_node(idx) {
4236            match crate::tools::list_sandbox_files(&sandbox_dir) {
4237                Ok(files) => {
4238                    for rel in &files {
4239                        if let Err(e) = crate::tools::copy_from_sandbox(
4240                            &sandbox_dir,
4241                            &self.context.working_dir,
4242                            rel,
4243                        ) {
4244                            log::warn!("Failed to export sandbox file {}: {}", rel, e);
4245                        }
4246                    }
4247                    if !files.is_empty() {
4248                        self.emit_log(format!(
4249                            "📦 Exported {} file(s) from sandbox to workspace",
4250                            files.len()
4251                        ));
4252                    }
4253                }
4254                Err(e) => {
4255                    log::warn!("Failed to list sandbox files: {}", e);
4256                }
4257            }
4258        }
4259
4260        // PSP-5 Phase 3: Record context provenance if available
4261        if let Some(provenance) = self.last_context_provenance.take() {
4262            if let Err(e) = self.ledger.record_context_provenance(&provenance) {
4263                log::warn!("Failed to record context provenance: {}", e);
4264            }
4265        }
4266
4267        // PSP-5 Phase 8: Persist verification result before marking completion
4268        if let Some(ref vr) = self.last_verification_result {
4269            self.ledger
4270                .record_verification_result(&self.graph[idx].node_id, vr)
4271                .map_err(|e| {
4272                    anyhow::anyhow!(
4273                        "Commit blocked: failed to persist verification result for {}: {}",
4274                        self.graph[idx].node_id,
4275                        e
4276                    )
4277                })?;
4278        }
4279
4280        // PSP-5 Phase 9: Persist artifact bundle if one was applied for this node
4281        if let Some(bundle) = self.last_applied_bundle.take() {
4282            if let Err(e) = self
4283                .ledger
4284                .record_artifact_bundle(&self.graph[idx].node_id, &bundle)
4285            {
4286                log::warn!(
4287                    "Failed to persist artifact bundle for {}: {}",
4288                    self.graph[idx].node_id,
4289                    e
4290                );
4291            }
4292        }
4293
4294        // PSP-5 Phase 8: Persist full node snapshot via the rich commit API.
4295        // This captures graph-structural metadata, retry/error context, and
4296        // merkle material. Partial-write failure blocks completion.
4297        let node = &self.graph[idx];
4298        let children_json = if node.children.is_empty() {
4299            None
4300        } else {
4301            Some(serde_json::to_string(&node.children).unwrap_or_default())
4302        };
4303
4304        let payload = crate::ledger::NodeCommitPayload {
4305            node_id: node.node_id.clone(),
4306            state: "Completed".to_string(),
4307            v_total: node.monitor.current_energy(),
4308            merkle_hash: node.interface_seal_hash.map(|h| h.to_vec()),
4309            attempt_count: node.monitor.attempt_count as i32,
4310            node_class: Some(node.node_class.to_string()),
4311            owner_plugin: if node.owner_plugin.is_empty() {
4312                None
4313            } else {
4314                Some(node.owner_plugin.clone())
4315            },
4316            goal: Some(node.goal.clone()),
4317            parent_id: node.parent_id.clone(),
4318            children: children_json,
4319            last_error_type: self
4320                .last_tool_failure
4321                .as_ref()
4322                .map(|f| f.chars().take(200).collect()),
4323        };
4324
4325        self.ledger.commit_node_snapshot(&payload).map_err(|e| {
4326            anyhow::anyhow!(
4327                "Commit blocked: failed to persist node snapshot for {}: {}",
4328                self.graph[idx].node_id,
4329                e
4330            )
4331        })?;
4332
4333        // PSP-5 Phase 6: Emit interface seals for Interface-class nodes
4334        self.emit_interface_seals(idx);
4335
4336        self.graph[idx].state = NodeState::Completed;
4337        self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4338            node_id: self.graph[idx].node_id.clone(),
4339            status: perspt_core::NodeStatus::Completed,
4340        });
4341
4342        // PSP-5 Phase 6: Unblock dependents that were waiting on this node's seal
4343        self.unblock_dependents(idx);
4344
4345        log::info!("Node {} committed", self.graph[idx].node_id);
4346        Ok(())
4347    }
4348
4349    // =========================================================================
4350    // PSP-5 Phase 5: Non-Convergence Classification and Repair
4351    // =========================================================================
4352
4353    /// Classify why a node failed to converge.
4354    ///
4355    /// Uses the last verification result, retry policy, tool failure state,
4356    /// and graph topology to determine the failure category.
4357    fn classify_non_convergence(&self, idx: NodeIndex) -> EscalationCategory {
4358        let node = &self.graph[idx];
4359
4360        // 1. Degraded sensors take priority — we cannot trust any other signal
4361        if let Some(ref vr) = self.last_verification_result {
4362            if vr.has_degraded_stages() {
4363                return EscalationCategory::DegradedSensors;
4364            }
4365        }
4366
4367        // 2. Contract / structural mismatch
4368        if node.monitor.retry_policy.review_rejections > 0 {
4369            return EscalationCategory::ContractMismatch;
4370        }
4371
4372        // 3. Topology mismatch — node touches files outside its ownership
4373        if !node.owner_plugin.is_empty() {
4374            let manifest = &self.context.ownership_manifest;
4375            for target in &node.output_targets {
4376                if let Some(entry) = manifest.owner_of(&target.to_string_lossy()) {
4377                    if entry.owner_node_id != node.node_id {
4378                        return EscalationCategory::TopologyMismatch;
4379                    }
4380                }
4381            }
4382        }
4383
4384        // 4. Compilation errors that persist across retries suggest model inadequacy
4385        if node.monitor.retry_policy.compilation_failures
4386            >= node.monitor.retry_policy.max_compilation_retries
4387        {
4388            // If energy never decreased, the model may not be capable enough
4389            if !node.monitor.is_converging() && node.monitor.attempt_count >= 3 {
4390                return EscalationCategory::InsufficientModelCapability;
4391            }
4392        }
4393
4394        // 5. Default: implementation error (most common case)
4395        EscalationCategory::ImplementationError
4396    }
4397
4398    /// Choose a repair action based on the classified failure category.
4399    ///
4400    /// Picks the least-destructive action that is safe given current evidence.
4401    fn choose_repair_action(&self, idx: NodeIndex, category: &EscalationCategory) -> RewriteAction {
4402        let node = &self.graph[idx];
4403
4404        match category {
4405            EscalationCategory::DegradedSensors => {
4406                let degraded = self
4407                    .last_verification_result
4408                    .as_ref()
4409                    .map(|vr| vr.degraded_stage_reasons())
4410                    .unwrap_or_default();
4411                RewriteAction::DegradedValidationStop {
4412                    reason: format!(
4413                        "Cannot verify stability — degraded sensors: {}",
4414                        degraded.join(", ")
4415                    ),
4416                }
4417            }
4418            EscalationCategory::ContractMismatch => RewriteAction::ContractRepair {
4419                fields: vec!["interface_signature".to_string(), "invariants".to_string()],
4420            },
4421            EscalationCategory::InsufficientModelCapability => {
4422                RewriteAction::CapabilityPromotion {
4423                    from_tier: node.tier,
4424                    to_tier: ModelTier::Architect, // promote to strongest tier
4425                }
4426            }
4427            EscalationCategory::TopologyMismatch => {
4428                // Check if a split would help
4429                if node.output_targets.len() > 1 {
4430                    RewriteAction::NodeSplit {
4431                        proposed_children: node
4432                            .output_targets
4433                            .iter()
4434                            .enumerate()
4435                            .map(|(i, _)| format!("{}_split_{}", node.node_id, i))
4436                            .collect(),
4437                    }
4438                } else {
4439                    RewriteAction::InterfaceInsertion {
4440                        boundary: format!(
4441                            "ownership boundary for {}",
4442                            node.output_targets
4443                                .first()
4444                                .map(|p| p.display().to_string())
4445                                .unwrap_or_default()
4446                        ),
4447                    }
4448                }
4449            }
4450            EscalationCategory::ImplementationError => {
4451                // If we still have retry budget for a different error type, ground the retry
4452                if node.monitor.remaining_attempts() > 0 {
4453                    let evidence = self.build_escalation_evidence(idx);
4454                    RewriteAction::GroundedRetry {
4455                        evidence_summary: evidence,
4456                    }
4457                } else {
4458                    RewriteAction::UserEscalation {
4459                        evidence: self.build_escalation_evidence(idx),
4460                    }
4461                }
4462            }
4463        }
4464    }
4465
4466    /// Apply a chosen repair action.  Returns `true` if the action was
4467    /// handled locally; `false` if the orchestrator should escalate to user.
4468    async fn apply_repair_action(&mut self, idx: NodeIndex, action: &RewriteAction) -> bool {
4469        let node_id = self.graph[idx].node_id.clone();
4470
4471        // PSP-5 Phase 5: Churn guardrail — limit repeated rewrites on the
4472        // same node lineage. Count prior rewrites for this node (and its
4473        // parent lineage) and refuse further rewrites beyond the limit.
4474        const MAX_REWRITES_PER_LINEAGE: usize = 3;
4475        let lineage_rewrites = self.count_lineage_rewrites(&node_id);
4476        if lineage_rewrites >= MAX_REWRITES_PER_LINEAGE {
4477            log::warn!(
4478                "Rewrite churn limit reached for node {} ({} prior rewrites) — refusing rewrite",
4479                node_id,
4480                lineage_rewrites
4481            );
4482            self.emit_log(format!(
4483                "⛔ Rewrite churn limit ({}) reached for {} — escalating",
4484                MAX_REWRITES_PER_LINEAGE, node_id
4485            ));
4486            return false;
4487        }
4488
4489        let category = self.classify_non_convergence(idx);
4490
4491        match action {
4492            RewriteAction::DegradedValidationStop { reason } => {
4493                self.emit_log(format!("⛔ Degraded-validation stop: {}", reason));
4494                self.graph[idx].state = NodeState::Escalated;
4495                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4496                    node_id: self.graph[idx].node_id.clone(),
4497                    status: perspt_core::NodeStatus::Escalated,
4498                });
4499                self.persist_rewrite_record(&node_id, action, &category, &[]);
4500                false
4501            }
4502            RewriteAction::UserEscalation { evidence } => {
4503                self.emit_log(format!("⚠️ User escalation required: {}", evidence));
4504                self.persist_rewrite_record(&node_id, action, &category, &[]);
4505                false
4506            }
4507            RewriteAction::GroundedRetry { evidence_summary } => {
4508                log::info!(
4509                    "Applying grounded retry for node {}: {}",
4510                    node_id,
4511                    evidence_summary
4512                );
4513                self.emit_log(format!(
4514                    "🔄 Grounded retry for {}: {}",
4515                    node_id,
4516                    &evidence_summary[..evidence_summary.len().min(120)]
4517                ));
4518                self.graph[idx].state = NodeState::Retry;
4519                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4520                    node_id: self.graph[idx].node_id.clone(),
4521                    status: perspt_core::NodeStatus::Retrying,
4522                });
4523                self.persist_rewrite_record(&node_id, action, &category, &[]);
4524                true
4525            }
4526            RewriteAction::ContractRepair { fields } => {
4527                log::info!("Contract repair for node {}: fields {:?}", node_id, fields);
4528                self.emit_log(format!(
4529                    "🔧 Contract repair for {}: {}",
4530                    node_id,
4531                    fields.join(", ")
4532                ));
4533                self.graph[idx].state = NodeState::Retry;
4534                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4535                    node_id: self.graph[idx].node_id.clone(),
4536                    status: perspt_core::NodeStatus::Retrying,
4537                });
4538                self.persist_rewrite_record(&node_id, action, &category, &[]);
4539                true
4540            }
4541            RewriteAction::CapabilityPromotion { from_tier, to_tier } => {
4542                log::info!(
4543                    "Promoting node {} from {:?} to {:?}",
4544                    node_id,
4545                    from_tier,
4546                    to_tier
4547                );
4548                self.emit_log(format!(
4549                    "⬆️ Promoting {} from {:?} to {:?}",
4550                    node_id, from_tier, to_tier
4551                ));
4552                self.graph[idx].tier = *to_tier;
4553                self.graph[idx].state = NodeState::Retry;
4554                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4555                    node_id: self.graph[idx].node_id.clone(),
4556                    status: perspt_core::NodeStatus::Retrying,
4557                });
4558                self.persist_rewrite_record(&node_id, action, &category, &[]);
4559                true
4560            }
4561            RewriteAction::SensorRecovery { degraded_stages } => {
4562                log::info!(
4563                    "Sensor recovery for node {}: {:?}",
4564                    node_id,
4565                    degraded_stages
4566                );
4567                self.emit_log(format!("🔧 Attempting sensor recovery for {}", node_id));
4568                self.graph[idx].state = NodeState::Retry;
4569                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4570                    node_id: self.graph[idx].node_id.clone(),
4571                    status: perspt_core::NodeStatus::Retrying,
4572                });
4573                self.persist_rewrite_record(&node_id, action, &category, &[]);
4574                true
4575            }
4576            RewriteAction::NodeSplit { proposed_children } => {
4577                log::info!(
4578                    "Node split requested for {}: {:?}",
4579                    node_id,
4580                    proposed_children
4581                );
4582                if proposed_children.is_empty() {
4583                    self.emit_log(format!(
4584                        "✂️ NodeSplit for {} requested with no children — escalating",
4585                        node_id
4586                    ));
4587                    return false;
4588                }
4589                self.emit_log(format!(
4590                    "✂️ Splitting {} into {} sub-nodes",
4591                    node_id,
4592                    proposed_children.len()
4593                ));
4594                let count = proposed_children.len();
4595                let inserted = self.split_node(idx, proposed_children);
4596                if !inserted.is_empty() {
4597                    self.persist_rewrite_record(&node_id, action, &category, &inserted);
4598                    self.emit_event(perspt_core::AgentEvent::GraphRewriteApplied {
4599                        trigger_node: node_id.clone(),
4600                        action: "node_split".to_string(),
4601                        nodes_affected: count,
4602                    });
4603                    true
4604                } else {
4605                    false
4606                }
4607            }
4608            RewriteAction::InterfaceInsertion { boundary } => {
4609                log::info!("Interface insertion for {}: {}", node_id, boundary);
4610                self.emit_log(format!(
4611                    "📐 Inserting interface adapter at boundary: {}",
4612                    boundary
4613                ));
4614                let inserted = self.insert_interface_node(idx, boundary);
4615                if let Some(ref adapter_id) = inserted {
4616                    self.persist_rewrite_record(
4617                        &node_id,
4618                        action,
4619                        &category,
4620                        std::slice::from_ref(adapter_id),
4621                    );
4622                    self.emit_event(perspt_core::AgentEvent::GraphRewriteApplied {
4623                        trigger_node: node_id.clone(),
4624                        action: "interface_insertion".to_string(),
4625                        nodes_affected: 1,
4626                    });
4627                    true
4628                } else {
4629                    false
4630                }
4631            }
4632            RewriteAction::SubgraphReplan { affected_nodes } => {
4633                log::info!("Subgraph replan for {}: {:?}", node_id, affected_nodes);
4634                let count = affected_nodes.len();
4635                self.emit_log(format!(
4636                    "🗺️ Replanning subgraph around {} ({} affected nodes)",
4637                    node_id,
4638                    affected_nodes.len()
4639                ));
4640                let applied = self.replan_subgraph(idx, affected_nodes);
4641                if applied {
4642                    self.persist_rewrite_record(&node_id, action, &category, &[]);
4643                    self.emit_event(perspt_core::AgentEvent::GraphRewriteApplied {
4644                        trigger_node: node_id.clone(),
4645                        action: "subgraph_replan".to_string(),
4646                        nodes_affected: count + 1,
4647                    });
4648                }
4649                applied
4650            }
4651        }
4652    }
4653
4654    /// Persist a rewrite record with the actual inserted node IDs.
4655    fn persist_rewrite_record(
4656        &self,
4657        node_id: &str,
4658        action: &RewriteAction,
4659        category: &perspt_core::types::EscalationCategory,
4660        inserted_nodes: &[String],
4661    ) {
4662        let rewrite = RewriteRecord {
4663            node_id: node_id.to_string(),
4664            session_id: self.context.session_id.clone(),
4665            action: action.clone(),
4666            category: *category,
4667            requeued_nodes: Vec::new(),
4668            inserted_nodes: inserted_nodes.to_vec(),
4669            timestamp: epoch_seconds(),
4670        };
4671        if let Err(e) = self.ledger.record_rewrite(&rewrite) {
4672            log::warn!("Failed to persist rewrite record: {}", e);
4673        }
4674    }
4675
4676    /// Count how many rewrites have been applied to this node or its lineage
4677    /// (nodes sharing the same base ID prefix before `__split_` or `__iface`).
4678    fn count_lineage_rewrites(&self, node_id: &str) -> usize {
4679        // Extract the root lineage ID (strip __split_N and __iface suffixes)
4680        let base = node_id
4681            .split("__split_")
4682            .next()
4683            .unwrap_or(node_id)
4684            .split("__iface")
4685            .next()
4686            .unwrap_or(node_id);
4687
4688        // Count rewrite records for this lineage from the ledger
4689        match self.ledger.get_rewrite_count_for_lineage(base) {
4690            Ok(count) => count,
4691            Err(e) => {
4692                log::warn!(
4693                    "Failed to query rewrite count for lineage '{}': {}",
4694                    base,
4695                    e
4696                );
4697                0
4698            }
4699        }
4700    }
4701
4702    /// Build a human-readable evidence string for an escalation.
4703    fn build_escalation_evidence(&self, idx: NodeIndex) -> String {
4704        let node = &self.graph[idx];
4705        let mut parts = Vec::new();
4706
4707        parts.push(format!("node: {}", node.node_id));
4708        parts.push(format!("goal: {}", node.goal));
4709        parts.push(format!("energy: {:.2}", node.monitor.current_energy()));
4710        parts.push(format!("attempts: {}", node.monitor.attempt_count));
4711        parts.push(node.monitor.retry_policy.summary());
4712
4713        if let Some(ref vr) = self.last_verification_result {
4714            parts.push(format!(
4715                "verification: syn={}, build={}, tests={}, diag={}",
4716                vr.syntax_ok, vr.build_ok, vr.tests_ok, vr.diagnostics_count
4717            ));
4718            if vr.has_degraded_stages() {
4719                parts.push(format!(
4720                    "degraded: {}",
4721                    vr.degraded_stage_reasons().join("; ")
4722                ));
4723            }
4724        }
4725
4726        if let Some(ref failure) = self.last_tool_failure {
4727            parts.push(format!("last tool failure: {}", failure));
4728        }
4729
4730        parts.join(" | ")
4731    }
4732
4733    /// Collect node IDs that directly depend on the given node.
4734    fn affected_dependents(&self, idx: NodeIndex) -> Vec<String> {
4735        self.graph
4736            .neighbors_directed(idx, petgraph::Direction::Outgoing)
4737            .map(|dep_idx| self.graph[dep_idx].node_id.clone())
4738            .collect()
4739    }
4740
4741    /// Split a node into multiple child nodes, inheriting its contracts and
4742    /// edges. The original node is removed and replaced with the children.
4743    ///
4744    /// Each proposed child string is treated as a sub-goal description.
4745    /// Returns the list of inserted child node IDs (empty on failure).
4746    fn split_node(&mut self, idx: NodeIndex, proposed_children: &[String]) -> Vec<String> {
4747        if proposed_children.is_empty() {
4748            return Vec::new();
4749        }
4750        let parent = self.graph[idx].clone();
4751        let parent_id = parent.node_id.clone();
4752
4753        // Collect existing incoming and outgoing edges before mutation.
4754        let incoming: Vec<(NodeIndex, Dependency)> = self
4755            .graph
4756            .neighbors_directed(idx, petgraph::Direction::Incoming)
4757            .map(|src| {
4758                let edge = self.graph.edges_connecting(src, idx).next().unwrap();
4759                (src, edge.weight().clone())
4760            })
4761            .collect();
4762        let outgoing: Vec<(NodeIndex, Dependency)> = self
4763            .graph
4764            .neighbors_directed(idx, petgraph::Direction::Outgoing)
4765            .map(|dst| {
4766                let edge = self.graph.edges_connecting(idx, dst).next().unwrap();
4767                (dst, edge.weight().clone())
4768            })
4769            .collect();
4770
4771        // Create child nodes.
4772        let mut child_indices = Vec::with_capacity(proposed_children.len());
4773        let mut child_ids = Vec::with_capacity(proposed_children.len());
4774        for (i, sub_goal) in proposed_children.iter().enumerate() {
4775            let child_id = format!("{}__split_{}", parent_id, i);
4776            let mut child = SRBNNode::new(child_id.clone(), sub_goal.clone(), parent.tier);
4777            child.parent_id = Some(parent_id.clone());
4778            child.contract = parent.contract.clone();
4779            child.node_class = parent.node_class;
4780            child.owner_plugin = parent.owner_plugin.clone();
4781            // Distribute output targets round-robin across children so each
4782            // child handles a subset of the original scope.
4783            child.output_targets = parent
4784                .output_targets
4785                .iter()
4786                .skip(i)
4787                .step_by(proposed_children.len())
4788                .cloned()
4789                .collect();
4790            child.context_files = parent.context_files.clone();
4791            let c_idx = self.graph.add_node(child);
4792            self.node_indices.insert(child_id.clone(), c_idx);
4793            child_indices.push(c_idx);
4794            child_ids.push(child_id);
4795        }
4796
4797        // Wire incoming edges → first child, outgoing edges from last child.
4798        if let Some(&first) = child_indices.first() {
4799            for (src, dep) in &incoming {
4800                self.graph.add_edge(*src, first, dep.clone());
4801                // Persist new edge
4802                let src_id = self.graph[*src].node_id.clone();
4803                let dst_id = self.graph[first].node_id.clone();
4804                let _ = self
4805                    .ledger
4806                    .record_task_graph_edge(&src_id, &dst_id, &dep.kind);
4807            }
4808        }
4809        if let Some(&last) = child_indices.last() {
4810            for (dst, dep) in &outgoing {
4811                self.graph.add_edge(last, *dst, dep.clone());
4812                let src_id = self.graph[last].node_id.clone();
4813                let dst_id = self.graph[*dst].node_id.clone();
4814                let _ = self
4815                    .ledger
4816                    .record_task_graph_edge(&src_id, &dst_id, &dep.kind);
4817            }
4818        }
4819
4820        // Chain children sequentially and persist edges.
4821        for pair in child_indices.windows(2) {
4822            self.graph.add_edge(
4823                pair[0],
4824                pair[1],
4825                Dependency {
4826                    kind: "split_sequence".to_string(),
4827                },
4828            );
4829            let src_id = self.graph[pair[0]].node_id.clone();
4830            let dst_id = self.graph[pair[1]].node_id.clone();
4831            let _ = self
4832                .ledger
4833                .record_task_graph_edge(&src_id, &dst_id, "split_sequence");
4834        }
4835
4836        // Remove original node.
4837        self.node_indices.remove(&parent_id);
4838        self.graph.remove_node(idx);
4839
4840        log::info!(
4841            "Split node {} into {} children: {:?}",
4842            parent_id,
4843            proposed_children.len(),
4844            child_ids
4845        );
4846        child_ids
4847    }
4848
4849    /// Insert an interface/adapter node on the edge between the given node
4850    /// and its dependents.  The boundary string describes the interface
4851    /// contract for the newly created adapter node.
4852    /// Returns the adapter node ID on success, or None on failure.
4853    fn insert_interface_node(&mut self, idx: NodeIndex, boundary: &str) -> Option<String> {
4854        let source_id = self.graph[idx].node_id.clone();
4855        let adapter_id = format!("{}__iface", source_id);
4856        let source_node = &self.graph[idx];
4857
4858        let mut adapter = SRBNNode::new(
4859            adapter_id.clone(),
4860            format!("Interface adapter: {}", boundary),
4861            source_node.tier,
4862        );
4863        adapter.parent_id = Some(source_id.clone());
4864        adapter.node_class = perspt_core::types::NodeClass::Interface;
4865        adapter.owner_plugin = source_node.owner_plugin.clone();
4866
4867        let adapter_idx = self.graph.add_node(adapter);
4868        self.node_indices.insert(adapter_id.clone(), adapter_idx);
4869
4870        // Collect outgoing edges from the source node.
4871        let outgoing: Vec<(NodeIndex, Dependency)> = self
4872            .graph
4873            .neighbors_directed(idx, petgraph::Direction::Outgoing)
4874            .map(|dst| {
4875                let edge = self.graph.edges_connecting(idx, dst).next().unwrap();
4876                (dst, edge.weight().clone())
4877            })
4878            .collect();
4879
4880        // Remove old outgoing edges and re-route through adapter.
4881        let edge_ids: Vec<_> = self
4882            .graph
4883            .edges_directed(idx, petgraph::Direction::Outgoing)
4884            .map(|e| e.id())
4885            .collect();
4886        for eid in edge_ids {
4887            self.graph.remove_edge(eid);
4888        }
4889
4890        // source → adapter
4891        self.graph.add_edge(
4892            idx,
4893            adapter_idx,
4894            Dependency {
4895                kind: "interface_boundary".to_string(),
4896            },
4897        );
4898        let _ = self
4899            .ledger
4900            .record_task_graph_edge(&source_id, &adapter_id, "interface_boundary");
4901
4902        // adapter → original dependents
4903        for (dst, dep) in outgoing {
4904            self.graph.add_edge(adapter_idx, dst, dep.clone());
4905            let dst_id = self.graph[dst].node_id.clone();
4906            let _ = self
4907                .ledger
4908                .record_task_graph_edge(&adapter_id, &dst_id, &dep.kind);
4909        }
4910
4911        log::info!("Inserted interface node {} after {}", adapter_id, source_id);
4912        Some(adapter_id)
4913    }
4914
4915    /// Reset the specified affected nodes back to `TaskQueued` so they get
4916    /// re-executed.  The triggering node itself is also reset.  Returns `true`
4917    /// if at least one node was replanned.
4918    fn replan_subgraph(&mut self, trigger_idx: NodeIndex, affected_nodes: &[String]) -> bool {
4919        let mut replanned = 0;
4920
4921        // Reset the trigger node itself.
4922        self.graph[trigger_idx].state = NodeState::Retry;
4923        self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4924            node_id: self.graph[trigger_idx].node_id.clone(),
4925            status: perspt_core::NodeStatus::Retrying,
4926        });
4927        self.graph[trigger_idx].monitor.reset_for_replan();
4928        replanned += 1;
4929
4930        // Reset each referenced affected node.
4931        for nid in affected_nodes {
4932            if let Some(&nidx) = self.node_indices.get(nid.as_str()) {
4933                self.graph[nidx].state = NodeState::TaskQueued;
4934                self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4935                    node_id: self.graph[nidx].node_id.clone(),
4936                    status: perspt_core::NodeStatus::Queued,
4937                });
4938                self.graph[nidx].monitor.reset_for_replan();
4939                replanned += 1;
4940            } else {
4941                log::warn!("Subgraph replan: unknown node {}", nid);
4942            }
4943        }
4944
4945        log::info!(
4946            "Replanned {} nodes starting from {}",
4947            replanned,
4948            self.graph[trigger_idx].node_id
4949        );
4950        replanned > 0
4951    }
4952
4953    /// Get the current session ID
4954    pub fn session_id(&self) -> &str {
4955        &self.context.session_id
4956    }
4957
4958    /// Get node count
4959    pub fn node_count(&self) -> usize {
4960        self.graph.node_count()
4961    }
4962
4963    /// Start Python LSP (ty) for type checking
4964    ///
4965    /// Legacy entry point — delegates to `start_lsp_for_plugins` with just Python.
4966    pub async fn start_python_lsp(&mut self) -> Result<()> {
4967        self.start_lsp_for_plugins(&["python"]).await
4968    }
4969
4970    /// Start LSP clients for the given plugin names.
4971    ///
4972    /// For each name, looks up the plugin's `LspConfig` (with fallback)
4973    /// and starts a client keyed by the plugin name.
4974    pub async fn start_lsp_for_plugins(&mut self, plugin_names: &[&str]) -> Result<()> {
4975        let registry = perspt_core::plugin::PluginRegistry::new();
4976
4977        for &name in plugin_names {
4978            if self.lsp_clients.contains_key(name) {
4979                log::debug!("LSP client already running for {}", name);
4980                continue;
4981            }
4982
4983            let plugin = match registry.get(name) {
4984                Some(p) => p,
4985                None => {
4986                    log::warn!("No plugin found for '{}', skipping LSP startup", name);
4987                    continue;
4988                }
4989            };
4990
4991            let profile = plugin.verifier_profile();
4992            let lsp_config = match profile.lsp.effective_config() {
4993                Some(cfg) => cfg.clone(),
4994                None => {
4995                    log::warn!(
4996                        "No available LSP for {} (primary and fallback unavailable)",
4997                        name
4998                    );
4999                    continue;
5000                }
5001            };
5002
5003            log::info!(
5004                "Starting LSP for {}: {} {:?}",
5005                name,
5006                lsp_config.server_binary,
5007                lsp_config.args
5008            );
5009
5010            let mut client = LspClient::from_config(&lsp_config);
5011            match client
5012                .start_with_config(&lsp_config, &self.context.working_dir)
5013                .await
5014            {
5015                Ok(()) => {
5016                    log::info!("{} LSP started successfully", name);
5017                    self.lsp_clients.insert(name.to_string(), client);
5018                }
5019                Err(e) => {
5020                    log::warn!(
5021                        "Failed to start {} LSP: {} (continuing without it)",
5022                        name,
5023                        e
5024                    );
5025                }
5026            }
5027        }
5028
5029        Ok(())
5030    }
5031
5032    /// Resolve the LSP client key for a given file path.
5033    ///
5034    /// Checks which registered plugin owns the file and returns its name,
5035    /// falling back to the first available LSP client.
5036    fn lsp_key_for_file(&self, path: &str) -> Option<String> {
5037        let registry = perspt_core::plugin::PluginRegistry::new();
5038
5039        // First, try to find a plugin that owns this file
5040        for plugin in registry.all() {
5041            if plugin.owns_file(path) {
5042                let name = plugin.name().to_string();
5043                if self.lsp_clients.contains_key(&name) {
5044                    return Some(name);
5045                }
5046            }
5047        }
5048
5049        // Fallback: return the first available client
5050        self.lsp_clients.keys().next().cloned()
5051    }
5052
5053    // =========================================================================
5054    // PSP-000005: Multi-Artifact Bundle Parsing & Application
5055    // =========================================================================
5056
5057    /// PSP-5: Parse an artifact bundle from LLM response
5058    ///
5059    /// Tries structured JSON bundle first, falls back to legacy `File:`/`Diff:` extraction.
5060    /// Returns None if no artifacts could be extracted.
5061    pub fn parse_artifact_bundle(
5062        &self,
5063        content: &str,
5064    ) -> Option<perspt_core::types::ArtifactBundle> {
5065        // Try structured JSON bundle first
5066        if let Some(bundle) = self.try_parse_json_bundle(content) {
5067            if let Ok(()) = bundle.validate() {
5068                log::info!(
5069                    "Parsed structured artifact bundle: {} artifacts",
5070                    bundle.len()
5071                );
5072                return Some(bundle);
5073            } else {
5074                log::warn!("JSON bundle found but failed validation, falling back to legacy");
5075            }
5076        }
5077
5078        // Fall back to legacy File:/Diff: extraction — collect ALL blocks
5079        let blocks = self.extract_all_code_blocks_from_response(content);
5080        if !blocks.is_empty() {
5081            let artifacts: Vec<perspt_core::types::ArtifactOperation> = blocks
5082                .into_iter()
5083                .map(|(filename, code, is_diff)| {
5084                    if is_diff {
5085                        perspt_core::types::ArtifactOperation::Diff {
5086                            path: filename,
5087                            patch: code,
5088                        }
5089                    } else {
5090                        perspt_core::types::ArtifactOperation::Write {
5091                            path: filename,
5092                            content: code,
5093                        }
5094                    }
5095                })
5096                .collect();
5097            log::info!(
5098                "Constructed {}-artifact bundle from legacy extraction",
5099                artifacts.len()
5100            );
5101            let bundle = perspt_core::types::ArtifactBundle {
5102                artifacts,
5103                commands: vec![],
5104            };
5105            return Some(bundle);
5106        }
5107
5108        None
5109    }
5110
5111    /// Try to parse a JSON artifact bundle from content
5112    ///
5113    /// PSP-5 Phase 4: Uses the provider-neutral normalization layer.
5114    fn try_parse_json_bundle(&self, content: &str) -> Option<perspt_core::types::ArtifactBundle> {
5115        match perspt_core::normalize::extract_and_deserialize::<perspt_core::types::ArtifactBundle>(
5116            content,
5117        ) {
5118            Ok((bundle, method)) => {
5119                log::info!("Parsed ArtifactBundle via normalization ({})", method);
5120                Some(bundle)
5121            }
5122            Err(e) => {
5123                log::debug!("Normalization could not extract ArtifactBundle: {}", e);
5124                None
5125            }
5126        }
5127    }
5128
5129    /// PSP-5: Apply an artifact bundle transactionally
5130    ///
5131    /// All file operations are validated first, then applied.
5132    /// PSP-5 Phase 2: Validates ownership boundaries before applying.
5133    /// If any operation fails, the method returns an error describing which operation
5134    /// failed, and previous successful operations are logged for manual review.
5135    pub async fn apply_bundle_transactionally(
5136        &mut self,
5137        bundle: &perspt_core::types::ArtifactBundle,
5138        node_id: &str,
5139        node_class: perspt_core::types::NodeClass,
5140    ) -> Result<()> {
5141        let idx =
5142            self.node_indices.get(node_id).copied().ok_or_else(|| {
5143                anyhow::anyhow!("Unknown node '{}' for bundle application", node_id)
5144            })?;
5145        let node_workdir = self.effective_working_dir(idx);
5146
5147        // Validate structural integrity first
5148        bundle.validate().map_err(|e| anyhow::anyhow!(e))?;
5149
5150        // Filter out undeclared paths instead of failing the entire session
5151        let filtered = self.filter_bundle_to_declared_paths(bundle, node_id);
5152
5153        // If filtering removed ALL artifacts, fall back to the original bundle
5154        // with a warning — the architect/actuator path mismatch shouldn't kill
5155        // the entire session.  Ownership validation below still guards against
5156        // true cross-node conflicts.
5157        let bundle = if filtered.artifacts.is_empty() && !bundle.artifacts.is_empty() {
5158            log::warn!(
5159                "All artifacts stripped for node '{}' — falling back to original bundle",
5160                node_id
5161            );
5162            self.emit_log(format!(
5163                "⚠️ Path mismatch: all artifacts for '{}' targeted unplanned paths — applying anyway",
5164                node_id
5165            ));
5166            bundle.clone()
5167        } else {
5168            filtered
5169        };
5170
5171        // PSP-5 Phase 2: Validate ownership boundaries (soft failure)
5172        // Instead of crashing the session, log ownership conflicts and
5173        // continue — the LLM often generates shared files (e.g. config.json)
5174        // from multiple nodes.
5175        if let Err(e) = self
5176            .context
5177            .ownership_manifest
5178            .validate_bundle(&bundle, node_id, node_class)
5179        {
5180            log::warn!("Ownership validation warning for node '{}': {}", node_id, e);
5181            self.emit_log(format!("⚠️ Ownership warning: {}", e));
5182        }
5183
5184        // PSP-5 Phase 2: Determine owner_plugin for new path assignment
5185        let owner_plugin = self
5186            .node_indices
5187            .get(node_id)
5188            .and_then(|idx| {
5189                let plugin = &self.graph[*idx].owner_plugin;
5190                if plugin.is_empty() {
5191                    None
5192                } else {
5193                    Some(plugin.clone())
5194                }
5195            })
5196            .unwrap_or_else(|| "unknown".to_string());
5197
5198        let mut files_created: Vec<String> = Vec::new();
5199        let mut files_modified: Vec<String> = Vec::new();
5200
5201        for op in &bundle.artifacts {
5202            let mut args = HashMap::new();
5203            let resolved_path = node_workdir.join(op.path());
5204            args.insert(
5205                "path".to_string(),
5206                resolved_path.to_string_lossy().to_string(),
5207            );
5208
5209            let call = match op {
5210                perspt_core::types::ArtifactOperation::Write { content, .. } => {
5211                    args.insert("content".to_string(), content.clone());
5212                    ToolCall {
5213                        name: "write_file".to_string(),
5214                        arguments: args,
5215                    }
5216                }
5217                perspt_core::types::ArtifactOperation::Diff { patch, .. } => {
5218                    args.insert("diff".to_string(), patch.clone());
5219                    ToolCall {
5220                        name: "apply_diff".to_string(),
5221                        arguments: args,
5222                    }
5223                }
5224            };
5225
5226            let result = self.tools.execute(&call).await;
5227            if result.success {
5228                let full_path = resolved_path.clone();
5229
5230                if op.is_write() {
5231                    files_created.push(op.path().to_string());
5232                } else {
5233                    files_modified.push(op.path().to_string());
5234                }
5235
5236                // Track for LSP notification
5237                self.last_written_file = Some(full_path.clone());
5238                self.file_version += 1;
5239
5240                // Notify LSP of file change
5241                let registry = perspt_core::plugin::PluginRegistry::new();
5242                for (lang, client) in self.lsp_clients.iter_mut() {
5243                    // Only notify if the plugin owns this file
5244                    let should_notify = match registry.get(lang) {
5245                        Some(plugin) => plugin.owns_file(op.path()),
5246                        None => true,
5247                    };
5248                    if should_notify {
5249                        if let Ok(content) = std::fs::read_to_string(&full_path) {
5250                            let _ = client
5251                                .did_change(&full_path, &content, self.file_version)
5252                                .await;
5253                        }
5254                    }
5255                }
5256
5257                log::info!("✓ Applied: {}", op.path());
5258                self.emit_log(format!("✅ Applied: {}", op.path()));
5259            } else {
5260                log::warn!("Failed to apply {}: {:?}", op.path(), result.error);
5261                self.emit_log(format!("❌ Failed: {} - {:?}", op.path(), result.error));
5262                self.last_tool_failure = result.error.clone();
5263                return Err(anyhow::anyhow!(
5264                    "Bundle application failed at {}: {:?}",
5265                    op.path(),
5266                    result.error
5267                ));
5268            }
5269        }
5270
5271        // PSP-5 Phase 2: Auto-assign unregistered paths to this node
5272        self.context.ownership_manifest.assign_new_paths(
5273            &bundle,
5274            node_id,
5275            &owner_plugin,
5276            node_class,
5277        );
5278
5279        // Emit BundleApplied event
5280        self.emit_event(perspt_core::AgentEvent::BundleApplied {
5281            node_id: node_id.to_string(),
5282            files_created,
5283            files_modified,
5284            writes_count: bundle.writes_count(),
5285            diffs_count: bundle.diffs_count(),
5286            node_class: node_class.to_string(),
5287        });
5288
5289        self.last_tool_failure = None;
5290        Ok(())
5291    }
5292
5293    /// PSP-5: Create a deterministic fallback execution graph
5294    ///
5295    /// When the Architect fails to produce a valid JSON plan after MAX_ATTEMPTS,
5296    /// this creates a minimal 3-node graph: scaffold → implement → test.
5297    fn create_deterministic_fallback_graph(&mut self, task: &str) -> Result<()> {
5298        log::warn!("Using deterministic fallback graph (PSP-5)");
5299        self.emit_log("📦 Using deterministic fallback plan");
5300
5301        // Emit FallbackPlanner event
5302        self.emit_event(perspt_core::AgentEvent::FallbackPlanner {
5303            reason: "Architect failed to produce valid JSON plan".to_string(),
5304        });
5305
5306        // Detect language for file extensions
5307        let lang = self.detect_language_from_task(task).unwrap_or("python");
5308        let ext = match lang {
5309            "rust" => "rs",
5310            "javascript" => "js",
5311            _ => "py",
5312        };
5313
5314        // Determine file names based on language
5315        let (main_file, test_file) = match lang {
5316            "rust" => (
5317                "src/main.rs".to_string(),
5318                "tests/integration_test.rs".to_string(),
5319            ),
5320            "javascript" => ("index.js".to_string(), "test/index.test.js".to_string()),
5321            _ => ("main.py".to_string(), format!("tests/test_main.{}", ext)),
5322        };
5323
5324        // Node 1: Scaffold/structure
5325        let scaffold_task = perspt_core::types::PlannedTask {
5326            id: "scaffold".to_string(),
5327            goal: format!("Set up project structure for: {}", task),
5328            context_files: vec![],
5329            output_files: vec![main_file.clone()],
5330            dependencies: vec![],
5331            task_type: perspt_core::types::TaskType::Code,
5332            contract: Default::default(),
5333            command_contract: None,
5334            node_class: perspt_core::types::NodeClass::Interface,
5335        };
5336
5337        // Node 2: Core implementation
5338        let impl_task = perspt_core::types::PlannedTask {
5339            id: "implement".to_string(),
5340            goal: format!("Implement core logic for: {}", task),
5341            context_files: vec![main_file.clone()],
5342            output_files: vec![main_file],
5343            dependencies: vec!["scaffold".to_string()],
5344            task_type: perspt_core::types::TaskType::Code,
5345            contract: Default::default(),
5346            command_contract: None,
5347            node_class: perspt_core::types::NodeClass::Implementation,
5348        };
5349
5350        // Node 3: Tests
5351        let test_task = perspt_core::types::PlannedTask {
5352            id: "test".to_string(),
5353            goal: format!("Write tests for: {}", task),
5354            context_files: vec![],
5355            output_files: vec![test_file],
5356            dependencies: vec!["implement".to_string()],
5357            task_type: perspt_core::types::TaskType::UnitTest,
5358            contract: Default::default(),
5359            command_contract: None,
5360            node_class: perspt_core::types::NodeClass::Integration,
5361        };
5362
5363        // Build plan and emit
5364        let mut plan = perspt_core::types::TaskPlan::new();
5365        plan.tasks.push(scaffold_task);
5366        plan.tasks.push(impl_task);
5367        plan.tasks.push(test_task);
5368
5369        self.emit_event(perspt_core::AgentEvent::PlanGenerated(plan.clone()));
5370        self.create_nodes_from_plan(&plan)?;
5371
5372        Ok(())
5373    }
5374
5375    /// Validate and strip undeclared paths from a bundle.
5376    ///
5377    /// Instead of failing the entire session, this method removes artifacts
5378    /// targeting paths not listed in the node's `output_targets` and logs
5379    /// warnings.  Returns the filtered bundle.
5380    fn filter_bundle_to_declared_paths(
5381        &self,
5382        bundle: &perspt_core::types::ArtifactBundle,
5383        node_id: &str,
5384    ) -> perspt_core::types::ArtifactBundle {
5385        let allowed_paths: std::collections::HashSet<String> = self
5386            .node_indices
5387            .get(node_id)
5388            .map(|idx| {
5389                self.graph[*idx]
5390                    .output_targets
5391                    .iter()
5392                    .map(|p| p.to_string_lossy().to_string())
5393                    .collect()
5394            })
5395            .unwrap_or_default();
5396
5397        if allowed_paths.is_empty() {
5398            return bundle.clone();
5399        }
5400
5401        let (kept, dropped): (Vec<_>, Vec<_>) = bundle
5402            .artifacts
5403            .iter()
5404            .cloned()
5405            .partition(|a| allowed_paths.contains(a.path()));
5406
5407        if !dropped.is_empty() {
5408            let dropped_paths: Vec<String> = dropped.iter().map(|a| a.path().to_string()).collect();
5409            log::warn!(
5410                "Stripped {} undeclared artifact(s) from node '{}': {}",
5411                dropped.len(),
5412                node_id,
5413                dropped_paths.join(", ")
5414            );
5415            self.emit_log(format!(
5416                "⚠️ Stripped {} undeclared path(s) from bundle: {}",
5417                dropped.len(),
5418                dropped_paths.join(", ")
5419            ));
5420        }
5421
5422        perspt_core::types::ArtifactBundle {
5423            artifacts: kept,
5424            commands: bundle.commands.clone(),
5425        }
5426    }
5427
5428    /// PSP-5: Run plugin-driven verification for a node
5429    ///
5430    /// Uses the active language plugin's verifier profile to select commands
5431    /// for syntax check, build, test, and lint stages. Delegates execution
5432    /// to `TestRunnerTrait` implementations from `test_runner`.
5433    ///
5434    /// Each stage records a `StageOutcome` with `SensorStatus`, enabling
5435    /// callers to detect fallback / unavailable sensors and block false
5436    /// stability claims.
5437    pub async fn run_plugin_verification(
5438        &mut self,
5439        plugin_name: &str,
5440        allowed_stages: &[perspt_core::plugin::VerifierStage],
5441        working_dir: std::path::PathBuf,
5442    ) -> perspt_core::types::VerificationResult {
5443        use perspt_core::plugin::VerifierStage;
5444        use perspt_core::types::{SensorStatus, StageOutcome};
5445
5446        let registry = perspt_core::plugin::PluginRegistry::new();
5447        let plugin = match registry.get(plugin_name) {
5448            Some(p) => p,
5449            None => {
5450                return perspt_core::types::VerificationResult::degraded(format!(
5451                    "Plugin '{}' not found",
5452                    plugin_name
5453                ));
5454            }
5455        };
5456
5457        let profile = plugin.verifier_profile();
5458
5459        // If fully degraded, report immediately
5460        if profile.fully_degraded() {
5461            return perspt_core::types::VerificationResult::degraded(format!(
5462                "{} toolchain not available on host (all stages degraded)",
5463                plugin.name()
5464            ));
5465        }
5466
5467        // Derive per-stage sensor status from the profile before moving it.
5468        let sensor_status_for = |stage: VerifierStage,
5469                                 profile: &perspt_core::plugin::VerifierProfile|
5470         -> SensorStatus {
5471            match profile.get(stage) {
5472                Some(cap) if cap.available => SensorStatus::Available,
5473                Some(cap) if cap.fallback_available => SensorStatus::Fallback {
5474                    actual: cap
5475                        .fallback_command
5476                        .clone()
5477                        .unwrap_or_else(|| "fallback".into()),
5478                    reason: format!(
5479                        "primary '{}' not found",
5480                        cap.command.as_deref().unwrap_or("?")
5481                    ),
5482                },
5483                Some(cap) => SensorStatus::Unavailable {
5484                    reason: format!(
5485                        "no tool for {} (tried '{}')",
5486                        stage,
5487                        cap.command.as_deref().unwrap_or("?")
5488                    ),
5489                },
5490                None => SensorStatus::Unavailable {
5491                    reason: format!("{} stage not declared by plugin", stage),
5492                },
5493            }
5494        };
5495
5496        let syn_sensor = sensor_status_for(VerifierStage::SyntaxCheck, &profile);
5497        let build_sensor = sensor_status_for(VerifierStage::Build, &profile);
5498        let test_sensor = sensor_status_for(VerifierStage::Test, &profile);
5499        let lint_sensor = sensor_status_for(VerifierStage::Lint, &profile);
5500
5501        let runner = test_runner::test_runner_for_profile(profile, working_dir);
5502
5503        let mut result = perspt_core::types::VerificationResult::default();
5504
5505        // PSP-5 Phase 9: Only run stages that are in the allowed filter.
5506        // Short-circuit: if syntax fails, skip build/test/lint.
5507        //                if build fails, skip test/lint.
5508
5509        // Syntax check
5510        if allowed_stages.contains(&VerifierStage::SyntaxCheck) {
5511            match runner.run_syntax_check().await {
5512                Ok(r) => {
5513                    result.syntax_ok = r.passed > 0 && r.failed == 0;
5514                    if !result.syntax_ok && r.run_succeeded {
5515                        result.diagnostics_count = r.output.lines().count();
5516                        result.raw_output = Some(r.output.clone());
5517                        self.emit_log(format!(
5518                            "⚠️ Syntax check failed ({} diagnostics)",
5519                            result.diagnostics_count
5520                        ));
5521                    } else if result.syntax_ok {
5522                        self.emit_log("✅ Syntax check passed".to_string());
5523                    }
5524                    result.stage_outcomes.push(StageOutcome {
5525                        stage: VerifierStage::SyntaxCheck.to_string(),
5526                        passed: result.syntax_ok,
5527                        sensor_status: syn_sensor,
5528                        output: Some(r.output),
5529                    });
5530                }
5531                Err(e) => {
5532                    log::warn!("Syntax check failed to run: {}", e);
5533                    result.syntax_ok = false;
5534                    result.stage_outcomes.push(StageOutcome {
5535                        stage: VerifierStage::SyntaxCheck.to_string(),
5536                        passed: false,
5537                        sensor_status: SensorStatus::Unavailable {
5538                            reason: format!("execution error: {}", e),
5539                        },
5540                        output: None,
5541                    });
5542                }
5543            }
5544
5545            // Short-circuit: if syntax fails, skip remaining stages
5546            if !result.syntax_ok {
5547                self.emit_log("⏭️ Skipping build/test/lint — syntax check failed".to_string());
5548                result.build_ok = false;
5549                result.tests_ok = false;
5550                self.finalize_verification_result(&mut result, plugin_name);
5551                return result;
5552            }
5553        }
5554
5555        // Build check
5556        if allowed_stages.contains(&VerifierStage::Build) {
5557            match runner.run_build_check().await {
5558                Ok(r) => {
5559                    result.build_ok = r.passed > 0 && r.failed == 0;
5560                    if result.build_ok {
5561                        self.emit_log("✅ Build passed".to_string());
5562                    } else if r.run_succeeded {
5563                        self.emit_log("⚠️ Build failed".to_string());
5564                        result.raw_output = Some(r.output.clone());
5565                    }
5566                    result.stage_outcomes.push(StageOutcome {
5567                        stage: VerifierStage::Build.to_string(),
5568                        passed: result.build_ok,
5569                        sensor_status: build_sensor,
5570                        output: Some(r.output),
5571                    });
5572                }
5573                Err(e) => {
5574                    log::warn!("Build check failed to run: {}", e);
5575                    result.build_ok = false;
5576                    result.stage_outcomes.push(StageOutcome {
5577                        stage: VerifierStage::Build.to_string(),
5578                        passed: false,
5579                        sensor_status: SensorStatus::Unavailable {
5580                            reason: format!("execution error: {}", e),
5581                        },
5582                        output: None,
5583                    });
5584                }
5585            }
5586
5587            // Short-circuit: if build fails, skip test/lint
5588            if !result.build_ok {
5589                self.emit_log("⏭️ Skipping test/lint — build failed".to_string());
5590                result.tests_ok = false;
5591                self.finalize_verification_result(&mut result, plugin_name);
5592                return result;
5593            }
5594        }
5595
5596        // Tests
5597        if allowed_stages.contains(&VerifierStage::Test) {
5598            match runner.run_tests().await {
5599                Ok(r) => {
5600                    result.tests_ok = r.all_passed();
5601                    result.tests_passed = r.passed;
5602                    result.tests_failed = r.failed;
5603
5604                    if result.tests_ok {
5605                        self.emit_log(format!("✅ Tests passed ({})", plugin_name));
5606                    } else {
5607                        self.emit_log(format!("❌ Tests failed ({})", plugin_name));
5608                        result.raw_output = Some(r.output.clone());
5609                    }
5610                    result.stage_outcomes.push(StageOutcome {
5611                        stage: VerifierStage::Test.to_string(),
5612                        passed: result.tests_ok,
5613                        sensor_status: test_sensor,
5614                        output: Some(r.output),
5615                    });
5616                }
5617                Err(e) => {
5618                    log::warn!("Test command failed to run: {}", e);
5619                    result.tests_ok = false;
5620                    result.stage_outcomes.push(StageOutcome {
5621                        stage: VerifierStage::Test.to_string(),
5622                        passed: false,
5623                        sensor_status: SensorStatus::Unavailable {
5624                            reason: format!("execution error: {}", e),
5625                        },
5626                        output: None,
5627                    });
5628                }
5629            }
5630        }
5631
5632        // Lint (only when allowed AND in Strict mode)
5633        if allowed_stages.contains(&VerifierStage::Lint)
5634            && self.context.verifier_strictness == perspt_core::types::VerifierStrictness::Strict
5635        {
5636            match runner.run_lint().await {
5637                Ok(r) => {
5638                    result.lint_ok = r.passed > 0 && r.failed == 0;
5639                    if result.lint_ok {
5640                        self.emit_log("✅ Lint passed".to_string());
5641                    } else if r.run_succeeded {
5642                        self.emit_log("⚠️ Lint issues found".to_string());
5643                    }
5644                    result.stage_outcomes.push(StageOutcome {
5645                        stage: VerifierStage::Lint.to_string(),
5646                        passed: result.lint_ok,
5647                        sensor_status: lint_sensor,
5648                        output: Some(r.output),
5649                    });
5650                }
5651                Err(e) => {
5652                    log::warn!("Lint command failed to run: {}", e);
5653                    result.lint_ok = false;
5654                    result.stage_outcomes.push(StageOutcome {
5655                        stage: VerifierStage::Lint.to_string(),
5656                        passed: false,
5657                        sensor_status: SensorStatus::Unavailable {
5658                            reason: format!("execution error: {}", e),
5659                        },
5660                        output: None,
5661                    });
5662                }
5663            }
5664        } else if !allowed_stages.contains(&VerifierStage::Lint) {
5665            result.lint_ok = true; // Skip lint when not in allowed stages
5666        } else {
5667            result.lint_ok = true; // Skip lint in non-strict mode
5668        }
5669
5670        self.finalize_verification_result(&mut result, plugin_name);
5671        result
5672    }
5673
5674    // =========================================================================
5675    // Auto-dependency repair helpers
5676    // =========================================================================
5677
5678    /// Parse `cargo check` / `cargo build` stderr and extract crate names that
5679    /// are missing.  Handles patterns like:
5680    ///   - `error[E0432]: unresolved import \`thiserror\``
5681    ///   - `error[E0463]: can't find crate for \`serde\``
5682    ///   - `use of undeclared crate or module \`clap\``
5683    fn extract_missing_crates(output: &str) -> Vec<String> {
5684        use std::collections::HashSet;
5685
5686        let mut crates: HashSet<String> = HashSet::new();
5687
5688        for line in output.lines() {
5689            let lower = line.to_lowercase();
5690
5691            // Pattern: "use of undeclared crate or module `foo`"
5692            if lower.contains("undeclared crate or module") {
5693                if let Some(name) = Self::extract_backtick_ident(line) {
5694                    if !name.contains("::") {
5695                        crates.insert(name);
5696                    }
5697                }
5698            }
5699            // Pattern: "can't find crate for `foo`"
5700            else if lower.contains("can't find crate for")
5701                || lower.contains("cant find crate for")
5702            {
5703                if let Some(name) = Self::extract_backtick_ident(line) {
5704                    crates.insert(name);
5705                }
5706            }
5707            // Pattern: "unresolved import `thiserror`" at top level
5708            else if lower.contains("unresolved import") {
5709                if let Some(name) = Self::extract_backtick_ident(line) {
5710                    let root = name.split("::").next().unwrap_or(&name).to_string();
5711                    if root != "crate" && root != "self" && root != "super" {
5712                        crates.insert(root);
5713                    }
5714                }
5715            }
5716        }
5717
5718        let builtins: HashSet<&str> = ["std", "core", "alloc", "proc_macro", "test"]
5719            .iter()
5720            .copied()
5721            .collect();
5722
5723        crates
5724            .into_iter()
5725            .filter(|c| !builtins.contains(c.as_str()))
5726            .collect()
5727    }
5728
5729    /// Extract the first back-tick–quoted identifier from a line.
5730    fn extract_backtick_ident(line: &str) -> Option<String> {
5731        let start = line.find('`')? + 1;
5732        let rest = &line[start..];
5733        let end = rest.find('`')?;
5734        let ident = &rest[..end];
5735        if ident.is_empty() {
5736            None
5737        } else {
5738            Some(ident.to_string())
5739        }
5740    }
5741
5742    /// Extract dependency commands from a correction LLM response.
5743    fn extract_commands_from_correction(response: &str) -> Vec<String> {
5744        let mut commands = Vec::new();
5745        let mut in_commands_section = false;
5746        let mut in_code_block = false;
5747
5748        let allowed_prefixes = [
5749            "cargo add",
5750            "pip install",
5751            "pip3 install",
5752            "uv add",
5753            "uv pip install",
5754            "npm install",
5755            "yarn add",
5756            "pnpm add",
5757        ];
5758
5759        for line in response.lines() {
5760            let trimmed = line.trim();
5761
5762            if trimmed.starts_with("Commands:")
5763                || trimmed.starts_with("**Commands:")
5764                || trimmed.starts_with("### Commands")
5765            {
5766                in_commands_section = true;
5767                continue;
5768            }
5769
5770            if in_commands_section {
5771                if trimmed.starts_with("```") {
5772                    in_code_block = !in_code_block;
5773                    continue;
5774                }
5775
5776                if !in_code_block
5777                    && (trimmed.is_empty()
5778                        || trimmed.starts_with('#')
5779                        || trimmed.starts_with("File:")
5780                        || trimmed.starts_with("Diff:"))
5781                {
5782                    in_commands_section = false;
5783                    continue;
5784                }
5785
5786                let cmd = trimmed
5787                    .trim_start_matches("- ")
5788                    .trim_start_matches("$ ")
5789                    .trim();
5790
5791                if !cmd.is_empty() && allowed_prefixes.iter().any(|p| cmd.starts_with(p)) {
5792                    commands.push(cmd.to_string());
5793                }
5794            }
5795        }
5796
5797        commands
5798    }
5799
5800    /// Run `cargo add <crate>` for each missing crate. Returns count of successes.
5801    async fn auto_install_crate_deps(crates: &[String], working_dir: &std::path::Path) -> usize {
5802        let mut installed = 0usize;
5803        for krate in crates {
5804            log::info!("Auto-installing crate: cargo add {}", krate);
5805            let result = tokio::process::Command::new("cargo")
5806                .args(["add", krate])
5807                .current_dir(working_dir)
5808                .stdout(std::process::Stdio::piped())
5809                .stderr(std::process::Stdio::piped())
5810                .output()
5811                .await;
5812
5813            match result {
5814                Ok(output) if output.status.success() => {
5815                    log::info!("Successfully installed crate: {}", krate);
5816                    installed += 1;
5817                }
5818                Ok(output) => {
5819                    let stderr = String::from_utf8_lossy(&output.stderr);
5820                    log::warn!("Failed to install crate {}: {}", krate, stderr);
5821                }
5822                Err(e) => {
5823                    log::warn!("Failed to run cargo add {}: {}", krate, e);
5824                }
5825            }
5826        }
5827        installed
5828    }
5829
5830    // =========================================================================
5831    // Python auto-dependency repair helpers (uv-first)
5832    // =========================================================================
5833
5834    /// Parse Python test/import output and extract module names that are missing.
5835    ///
5836    /// Handles patterns like:
5837    ///   - `ModuleNotFoundError: No module named 'httpx'`
5838    ///   - `ImportError: cannot import name 'foo' from 'bar'`
5839    ///   - `E   ModuleNotFoundError: No module named 'pydantic'`
5840    fn extract_missing_python_modules(output: &str) -> Vec<String> {
5841        use std::collections::HashSet;
5842
5843        let mut modules: HashSet<String> = HashSet::new();
5844
5845        for line in output.lines() {
5846            let trimmed = line.trim().trim_start_matches("E").trim();
5847
5848            // Pattern: "ModuleNotFoundError: No module named 'foo'"
5849            // Also matches: "ModuleNotFoundError: No module named 'foo.bar'"
5850            // Can appear anywhere in the line (e.g. after FAILED test_x.py::test - ...)
5851            if trimmed.contains("ModuleNotFoundError: No module named ") {
5852                // Extract the quoted module name after "No module named "
5853                if let Some(pos) = trimmed.find("No module named ") {
5854                    let after = &trimmed[pos + "No module named ".len()..];
5855                    let name = after.trim().trim_matches('\'').trim_matches('"');
5856                    let root = name.split('.').next().unwrap_or(name);
5857                    if !root.is_empty() {
5858                        modules.insert(root.to_string());
5859                    }
5860                }
5861            }
5862            // Pattern: "ImportError: cannot import name 'X' from 'Y'"
5863            // or "ImportError: No module named 'X'"
5864            else if trimmed.contains("ImportError") && trimmed.contains("No module named") {
5865                if let Some(start) = trimmed.find('\'') {
5866                    let rest = &trimmed[start + 1..];
5867                    if let Some(end) = rest.find('\'') {
5868                        let name = &rest[..end];
5869                        let root = name.split('.').next().unwrap_or(name);
5870                        if !root.is_empty() {
5871                            modules.insert(root.to_string());
5872                        }
5873                    }
5874                }
5875            }
5876        }
5877
5878        // Filter out standard library modules that are always present
5879        let stdlib: HashSet<&str> = [
5880            "os",
5881            "sys",
5882            "json",
5883            "re",
5884            "math",
5885            "datetime",
5886            "collections",
5887            "itertools",
5888            "functools",
5889            "pathlib",
5890            "typing",
5891            "abc",
5892            "io",
5893            "unittest",
5894            "logging",
5895            "argparse",
5896            "sqlite3",
5897            "csv",
5898            "hashlib",
5899            "tempfile",
5900            "shutil",
5901            "copy",
5902            "contextlib",
5903            "dataclasses",
5904            "enum",
5905            "textwrap",
5906            "importlib",
5907            "inspect",
5908            "traceback",
5909            "subprocess",
5910            "threading",
5911            "multiprocessing",
5912            "asyncio",
5913            "socket",
5914            "http",
5915            "urllib",
5916            "xml",
5917            "html",
5918            "email",
5919            "string",
5920            "struct",
5921            "array",
5922            "queue",
5923            "heapq",
5924            "bisect",
5925            "pprint",
5926            "decimal",
5927            "fractions",
5928            "random",
5929            "secrets",
5930            "time",
5931            "calendar",
5932            "zlib",
5933            "gzip",
5934            "zipfile",
5935            "tarfile",
5936            "glob",
5937            "fnmatch",
5938            "stat",
5939            "fileinput",
5940            "codecs",
5941            "uuid",
5942            "base64",
5943            "binascii",
5944            "pickle",
5945            "shelve",
5946            "dbm",
5947            "platform",
5948            "signal",
5949            "mmap",
5950            "ctypes",
5951            "configparser",
5952            "tomllib",
5953            "warnings",
5954            "weakref",
5955            "types",
5956            "operator",
5957            "numbers",
5958            "__future__",
5959        ]
5960        .iter()
5961        .copied()
5962        .collect();
5963
5964        modules
5965            .into_iter()
5966            .filter(|m| !stdlib.contains(m.as_str()))
5967            .collect()
5968    }
5969
5970    /// Map a Python import name to its PyPI package name.
5971    ///
5972    /// Most packages use the same name for import and install, but some
5973    /// notable exceptions exist. We handle the common ones here.
5974    fn python_import_to_package(import_name: &str) -> &str {
5975        match import_name {
5976            "PIL" | "pil" => "pillow",
5977            "cv2" => "opencv-python",
5978            "yaml" => "pyyaml",
5979            "bs4" => "beautifulsoup4",
5980            "sklearn" => "scikit-learn",
5981            "attr" | "attrs" => "attrs",
5982            "dateutil" => "python-dateutil",
5983            "dotenv" => "python-dotenv",
5984            "gi" => "PyGObject",
5985            "serial" => "pyserial",
5986            "usb" => "pyusb",
5987            "wx" => "wxPython",
5988            "lxml" => "lxml",
5989            "Crypto" => "pycryptodome",
5990            "jose" => "python-jose",
5991            "jwt" => "PyJWT",
5992            "magic" => "python-magic",
5993            "docx" => "python-docx",
5994            "pptx" => "python-pptx",
5995            "git" => "gitpython",
5996            "psycopg2" => "psycopg2-binary",
5997            other => other,
5998        }
5999    }
6000
6001    /// Run `uv add <package>` for each missing Python module. Returns count of successes.
6002    async fn auto_install_python_deps(modules: &[String], working_dir: &std::path::Path) -> usize {
6003        let mut installed = 0usize;
6004        for module in modules {
6005            let package = Self::python_import_to_package(module);
6006            log::info!("Auto-installing Python package: uv add {}", package);
6007            let result = tokio::process::Command::new("uv")
6008                .args(["add", package])
6009                .current_dir(working_dir)
6010                .stdout(std::process::Stdio::piped())
6011                .stderr(std::process::Stdio::piped())
6012                .output()
6013                .await;
6014
6015            match result {
6016                Ok(output) if output.status.success() => {
6017                    log::info!("Successfully installed Python package: {}", package);
6018                    installed += 1;
6019                }
6020                Ok(output) => {
6021                    let stderr = String::from_utf8_lossy(&output.stderr);
6022                    log::warn!("Failed to install Python package {}: {}", package, stderr);
6023                }
6024                Err(e) => {
6025                    log::warn!("Failed to run uv add {}: {}", package, e);
6026                }
6027            }
6028        }
6029
6030        // Always sync after adding dependencies to ensure venv is up-to-date
6031        if installed > 0 {
6032            log::info!("Running uv sync --dev after dependency install...");
6033            let _ = tokio::process::Command::new("uv")
6034                .args(["sync", "--dev"])
6035                .current_dir(working_dir)
6036                .stdout(std::process::Stdio::piped())
6037                .stderr(std::process::Stdio::piped())
6038                .output()
6039                .await;
6040        }
6041
6042        installed
6043    }
6044
6045    /// Normalize a dependency command to its uv-first equivalent.
6046    ///
6047    /// Converts generic pip/pip3/python -m pip install commands to `uv add`,
6048    /// leaving already-correct uv commands and non-Python commands unchanged.
6049    fn normalize_command_to_uv(command: &str) -> String {
6050        let trimmed = command.trim();
6051
6052        // pip install foo → uv add foo
6053        // pip3 install foo → uv add foo
6054        // python -m pip install foo → uv add foo
6055        // python3 -m pip install foo → uv add foo
6056        let pip_install_prefixes = [
6057            "pip install ",
6058            "pip3 install ",
6059            "python -m pip install ",
6060            "python3 -m pip install ",
6061        ];
6062        for prefix in &pip_install_prefixes {
6063            if let Some(rest) = trimmed.strip_prefix(prefix) {
6064                let packages = rest.trim();
6065                if packages.is_empty() {
6066                    return command.to_string();
6067                }
6068                // Strip -r/--requirement flags (uv add doesn't support those directly)
6069                if packages.starts_with("-r ") || packages.starts_with("--requirement ") {
6070                    return format!("uv pip install {}", packages);
6071                }
6072                return format!("uv add {}", packages);
6073            }
6074        }
6075
6076        // pip install -e . → uv pip install -e .
6077        if trimmed.starts_with("pip install -") || trimmed.starts_with("pip3 install -") {
6078            return format!("uv {}", trimmed);
6079        }
6080
6081        command.to_string()
6082    }
6083
6084    /// PSP-5 Phase 9: Finalize verification result — mark degraded, emit events, build summary.
6085    fn finalize_verification_result(
6086        &mut self,
6087        result: &mut perspt_core::types::VerificationResult,
6088        plugin_name: &str,
6089    ) {
6090        if result.has_degraded_stages() {
6091            result.degraded = true;
6092            let reasons = result.degraded_stage_reasons();
6093            result.degraded_reason = Some(reasons.join("; "));
6094
6095            // Emit per-stage SensorFallback events
6096            for outcome in &result.stage_outcomes {
6097                if let perspt_core::types::SensorStatus::Fallback { actual, reason } =
6098                    &outcome.sensor_status
6099                {
6100                    self.emit_event(perspt_core::AgentEvent::SensorFallback {
6101                        node_id: plugin_name.to_string(),
6102                        stage: outcome.stage.clone(),
6103                        primary: reason.clone(),
6104                        actual: actual.clone(),
6105                        reason: reason.clone(),
6106                    });
6107                }
6108            }
6109        }
6110
6111        // Store result for convergence-time degraded check
6112        self.last_verification_result = Some(result.clone());
6113
6114        // Build summary
6115        result.summary = format!(
6116            "{}: syntax={}, build={}, tests={}, lint={}{}",
6117            plugin_name,
6118            if result.syntax_ok { "✅" } else { "❌" },
6119            if result.build_ok { "✅" } else { "❌" },
6120            if result.tests_ok { "✅" } else { "❌" },
6121            if result.lint_ok { "✅" } else { "⏭️" },
6122            if result.degraded { " (degraded)" } else { "" },
6123        );
6124    }
6125
6126    // =========================================================================
6127    // PSP-5 Phase 6: Provisional Branch Lifecycle
6128    // =========================================================================
6129
6130    /// Resolve the sandbox directory for a node that has a provisional branch.
6131    /// Returns `None` for root nodes or nodes without branches.
6132    fn sandbox_dir_for_node(&self, idx: NodeIndex) -> Option<std::path::PathBuf> {
6133        let branch_id = self.graph[idx].provisional_branch_id.as_ref()?;
6134        let sandbox_path = self
6135            .context
6136            .working_dir
6137            .join(".perspt")
6138            .join("sandboxes")
6139            .join(&self.context.session_id)
6140            .join(branch_id);
6141        if sandbox_path.exists() {
6142            Some(sandbox_path)
6143        } else {
6144            None
6145        }
6146    }
6147
6148    /// Return the effective working directory for a node: sandbox if the node
6149    /// has an active provisional branch, otherwise the live workspace.
6150    fn effective_working_dir(&self, idx: NodeIndex) -> std::path::PathBuf {
6151        self.sandbox_dir_for_node(idx)
6152            .unwrap_or_else(|| self.context.working_dir.clone())
6153    }
6154
6155    /// Create a provisional branch if the node has graph parents (i.e., it
6156    /// depends on another node's output). Returns the branch ID if created.
6157    fn maybe_create_provisional_branch(&mut self, idx: NodeIndex) -> Option<String> {
6158        // Find incoming edges (parents this node depends on)
6159        let parents: Vec<NodeIndex> = self
6160            .graph
6161            .neighbors_directed(idx, petgraph::Direction::Incoming)
6162            .collect();
6163
6164        if parents.is_empty() {
6165            return None; // Root node — no provisional branch needed
6166        }
6167
6168        let node = &self.graph[idx];
6169        let node_id = node.node_id.clone();
6170        let session_id = self.context.session_id.clone();
6171
6172        // Use the first parent as the primary dependency.
6173        // For multi-parent nodes the branch tracks the first parent;
6174        // lineage records capture all parent→child edges.
6175        let parent_idx = parents[0];
6176        let parent_node_id = self.graph[parent_idx].node_id.clone();
6177
6178        let branch_id = format!("branch_{}_{}", node_id, uuid::Uuid::new_v4());
6179        let branch = ProvisionalBranch::new(
6180            branch_id.clone(),
6181            session_id.clone(),
6182            node_id.clone(),
6183            parent_node_id.clone(),
6184        );
6185
6186        // Persist via ledger
6187        if let Err(e) = self.ledger.record_provisional_branch(&branch) {
6188            log::warn!("Failed to record provisional branch: {}", e);
6189        }
6190
6191        // Record lineage edges for every parent
6192        for pidx in &parents {
6193            let parent_id = self.graph[*pidx].node_id.clone();
6194            // Determine if this parent is an Interface node (seal dependency)
6195            let depends_on_seal = self.graph[*pidx].node_class == NodeClass::Interface;
6196            let lineage = perspt_core::types::BranchLineage {
6197                lineage_id: format!("lin_{}_{}", branch_id, parent_id),
6198                parent_branch_id: parent_id,
6199                child_branch_id: branch_id.clone(),
6200                depends_on_seal,
6201            };
6202            if let Err(e) = self.ledger.record_branch_lineage(&lineage) {
6203                log::warn!("Failed to record branch lineage: {}", e);
6204            }
6205        }
6206
6207        // Store branch ID on the node for tracking
6208        self.graph[idx].provisional_branch_id = Some(branch_id.clone());
6209
6210        // PSP-5 Phase 6: Create sandbox workspace for this branch and seed it
6211        // with any existing files the node will read or modify.
6212        match crate::tools::create_sandbox(&self.context.working_dir, &session_id, &branch_id) {
6213            Ok(sandbox_path) => {
6214                log::debug!("Sandbox created at {}", sandbox_path.display());
6215                // Copy node's owned output targets into the sandbox so
6216                // verification and builds can find them.
6217                let node = &self.graph[idx];
6218                for target in &node.output_targets {
6219                    if let Some(rel) = target.to_str() {
6220                        if let Err(e) = crate::tools::copy_to_sandbox(
6221                            &self.context.working_dir,
6222                            &sandbox_path,
6223                            rel,
6224                        ) {
6225                            log::debug!("Could not seed sandbox with {}: {}", rel, e);
6226                        }
6227                    }
6228                }
6229                // Also copy parent output targets that this node depends on
6230                for pidx in &parents {
6231                    for target in &self.graph[*pidx].output_targets {
6232                        if let Some(rel) = target.to_str() {
6233                            if let Err(e) = crate::tools::copy_to_sandbox(
6234                                &self.context.working_dir,
6235                                &sandbox_path,
6236                                rel,
6237                            ) {
6238                                log::debug!(
6239                                    "Could not seed sandbox with parent file {}: {}",
6240                                    rel,
6241                                    e
6242                                );
6243                            }
6244                        }
6245                    }
6246                }
6247            }
6248            Err(e) => {
6249                log::warn!("Failed to create sandbox for branch {}: {}", branch_id, e);
6250            }
6251        }
6252
6253        self.emit_event(perspt_core::AgentEvent::BranchCreated {
6254            branch_id: branch_id.clone(),
6255            node_id,
6256            parent_node_id,
6257        });
6258        log::info!("Created provisional branch {} for node", branch_id);
6259
6260        Some(branch_id)
6261    }
6262
6263    /// Merge a provisional branch after successful commit.
6264    fn merge_provisional_branch(&mut self, branch_id: &str, idx: NodeIndex) {
6265        let node_id = self.graph[idx].node_id.clone();
6266        if let Err(e) = self
6267            .ledger
6268            .update_branch_state(branch_id, &ProvisionalBranchState::Merged.to_string())
6269        {
6270            log::warn!("Failed to merge branch {}: {}", branch_id, e);
6271        }
6272
6273        // Clean up sandbox directory — artifacts were already exported in step_commit
6274        let sandbox_path = self
6275            .context
6276            .working_dir
6277            .join(".perspt")
6278            .join("sandboxes")
6279            .join(&self.context.session_id)
6280            .join(branch_id);
6281        if let Err(e) = crate::tools::cleanup_sandbox(&sandbox_path) {
6282            log::warn!(
6283                "Failed to cleanup sandbox for merged branch {}: {}",
6284                branch_id,
6285                e
6286            );
6287        }
6288
6289        self.emit_event(perspt_core::AgentEvent::BranchMerged {
6290            branch_id: branch_id.to_string(),
6291            node_id,
6292        });
6293        log::info!("Merged provisional branch {}", branch_id);
6294    }
6295
6296    /// Flush a provisional branch on escalation / non-convergence.
6297    fn flush_provisional_branch(&mut self, branch_id: &str, node_id: &str) {
6298        if let Err(e) = self
6299            .ledger
6300            .update_branch_state(branch_id, &ProvisionalBranchState::Flushed.to_string())
6301        {
6302            log::warn!("Failed to flush branch {}: {}", branch_id, e);
6303        }
6304
6305        // Clean up sandbox directory — speculative work is discarded
6306        let sandbox_path = self
6307            .context
6308            .working_dir
6309            .join(".perspt")
6310            .join("sandboxes")
6311            .join(&self.context.session_id)
6312            .join(branch_id);
6313        if let Err(e) = crate::tools::cleanup_sandbox(&sandbox_path) {
6314            log::warn!(
6315                "Failed to cleanup sandbox for flushed branch {}: {}",
6316                branch_id,
6317                e
6318            );
6319        }
6320
6321        log::info!(
6322            "Flushed provisional branch {} for node {}",
6323            branch_id,
6324            node_id
6325        );
6326    }
6327
6328    /// Flush all descendant provisional branches when a parent node fails.
6329    ///
6330    /// Walks the DAG outward from `idx`, finds all child nodes that have
6331    /// active provisional branches, flushes them, and persists a
6332    /// BranchFlushRecord documenting the cascade.
6333    fn flush_descendant_branches(&mut self, idx: NodeIndex) {
6334        let parent_node_id = self.graph[idx].node_id.clone();
6335        let session_id = self.context.session_id.clone();
6336
6337        // Collect all transitive dependents
6338        let descendant_indices = self.collect_descendants(idx);
6339
6340        let mut flushed_branch_ids = Vec::new();
6341        let mut requeue_node_ids = Vec::new();
6342
6343        for desc_idx in &descendant_indices {
6344            let desc_node = &self.graph[*desc_idx];
6345            if let Some(ref bid) = desc_node.provisional_branch_id {
6346                // Flush the branch
6347                let bid_clone = bid.clone();
6348                let nid_clone = desc_node.node_id.clone();
6349                self.flush_provisional_branch(&bid_clone, &nid_clone);
6350                flushed_branch_ids.push(bid_clone);
6351                requeue_node_ids.push(nid_clone);
6352            }
6353        }
6354
6355        if flushed_branch_ids.is_empty() {
6356            return;
6357        }
6358
6359        // Persist the flush decision
6360        let flush_record = perspt_core::types::BranchFlushRecord::new(
6361            &session_id,
6362            &parent_node_id,
6363            flushed_branch_ids.clone(),
6364            requeue_node_ids.clone(),
6365            format!(
6366                "Parent node {} failed verification/convergence",
6367                parent_node_id
6368            ),
6369        );
6370        if let Err(e) = self.ledger.record_branch_flush(&flush_record) {
6371            log::warn!("Failed to record branch flush: {}", e);
6372        }
6373
6374        self.emit_event(perspt_core::AgentEvent::BranchFlushed {
6375            parent_node_id: parent_node_id.clone(),
6376            flushed_branch_ids,
6377            reason: format!("Parent {} failed", parent_node_id),
6378        });
6379
6380        log::info!(
6381            "Flushed {} descendant branches for parent {}; {} nodes eligible for requeue",
6382            flush_record.flushed_branch_ids.len(),
6383            parent_node_id,
6384            requeue_node_ids.len(),
6385        );
6386    }
6387
6388    /// Collect all transitive dependent node indices reachable from `idx`
6389    /// via outgoing edges (children, grandchildren, etc.).
6390    fn collect_descendants(&self, idx: NodeIndex) -> Vec<NodeIndex> {
6391        let mut descendants = Vec::new();
6392        let mut stack = vec![idx];
6393        let mut visited = std::collections::HashSet::new();
6394        visited.insert(idx);
6395
6396        while let Some(current) = stack.pop() {
6397            for child in self
6398                .graph
6399                .neighbors_directed(current, petgraph::Direction::Outgoing)
6400            {
6401                if visited.insert(child) {
6402                    descendants.push(child);
6403                    stack.push(child);
6404                }
6405            }
6406        }
6407        descendants
6408    }
6409
6410    /// Emit interface seals from an Interface-class node's output artifacts.
6411    ///
6412    /// Called during step_commit for nodes whose `node_class` is `Interface`.
6413    /// Computes structural digests of owned output files and persists seal
6414    /// records so dependent nodes can assemble context from sealed interfaces.
6415    fn emit_interface_seals(&mut self, idx: NodeIndex) {
6416        let node = &self.graph[idx];
6417        if node.node_class != NodeClass::Interface {
6418            return;
6419        }
6420
6421        let node_id = node.node_id.clone();
6422        let session_id = self.context.session_id.clone();
6423        let output_targets: Vec<_> = node.output_targets.clone();
6424        let mut sealed_paths = Vec::new();
6425        let mut seal_hash = [0u8; 32];
6426
6427        let retriever = ContextRetriever::new(self.context.working_dir.clone());
6428
6429        for target in &output_targets {
6430            let path_str = target.to_string_lossy().to_string();
6431            match retriever.compute_structural_digest(
6432                &path_str,
6433                perspt_core::types::ArtifactKind::InterfaceSeal,
6434                &node_id,
6435            ) {
6436                Ok(digest) => {
6437                    let seal = perspt_core::types::InterfaceSealRecord::from_digest(
6438                        &session_id,
6439                        &node_id,
6440                        &digest,
6441                    );
6442                    seal_hash = seal.seal_hash;
6443                    sealed_paths.push(path_str);
6444
6445                    if let Err(e) = self.ledger.record_interface_seal(&seal) {
6446                        log::warn!("Failed to record interface seal: {}", e);
6447                    }
6448                }
6449                Err(e) => {
6450                    log::debug!("Skipping seal for {}: {}", path_str, e);
6451                }
6452            }
6453        }
6454
6455        if !sealed_paths.is_empty() {
6456            // Store seal hash on the node
6457            self.graph[idx].interface_seal_hash = Some(seal_hash);
6458
6459            self.emit_event(perspt_core::AgentEvent::InterfaceSealed {
6460                node_id: node_id.clone(),
6461                sealed_paths: sealed_paths.clone(),
6462                seal_hash: seal_hash
6463                    .iter()
6464                    .map(|b| format!("{:02x}", b))
6465                    .collect::<String>(),
6466            });
6467            log::info!(
6468                "Sealed {} interface artifact(s) for node {}",
6469                sealed_paths.len(),
6470                node_id
6471            );
6472        }
6473    }
6474
6475    /// Unblock child nodes that were waiting on this node's interface seal.
6476    fn unblock_dependents(&mut self, idx: NodeIndex) {
6477        let node_id = self.graph[idx].node_id.clone();
6478
6479        // Drain blocked dependencies that match this parent
6480        let (unblocked, remaining): (Vec<_>, Vec<_>) = self
6481            .blocked_dependencies
6482            .drain(..)
6483            .partition(|dep| dep.parent_node_id == node_id);
6484
6485        self.blocked_dependencies = remaining;
6486
6487        for dep in unblocked {
6488            self.emit_event(perspt_core::AgentEvent::DependentUnblocked {
6489                child_node_id: dep.child_node_id.clone(),
6490                parent_node_id: node_id.clone(),
6491            });
6492            log::info!(
6493                "Unblocked dependent {} (parent {} sealed)",
6494                dep.child_node_id,
6495                node_id
6496            );
6497        }
6498    }
6499
6500    /// Check whether a node should be blocked because a parent Interface node
6501    /// has not yet produced a seal.  Returns `true` if the node is blocked.
6502    fn check_seal_prerequisites(&mut self, idx: NodeIndex) -> bool {
6503        let parents: Vec<NodeIndex> = self
6504            .graph
6505            .neighbors_directed(idx, petgraph::Direction::Incoming)
6506            .collect();
6507
6508        for pidx in parents {
6509            let parent = &self.graph[pidx];
6510            if parent.node_class == NodeClass::Interface
6511                && parent.interface_seal_hash.is_none()
6512                && parent.state != NodeState::Completed
6513            {
6514                // Parent Interface node hasn't sealed yet — block this child
6515                let child_node_id = self.graph[idx].node_id.clone();
6516                let parent_node_id = parent.node_id.clone();
6517                let sealed_paths: Vec<String> = parent
6518                    .output_targets
6519                    .iter()
6520                    .map(|p| p.to_string_lossy().to_string())
6521                    .collect();
6522
6523                let dep = perspt_core::types::BlockedDependency::new(
6524                    &child_node_id,
6525                    &parent_node_id,
6526                    sealed_paths,
6527                );
6528                self.blocked_dependencies.push(dep);
6529
6530                log::info!(
6531                    "Node {} blocked: waiting on interface seal from {}",
6532                    child_node_id,
6533                    parent_node_id
6534                );
6535                return true;
6536            }
6537        }
6538        false
6539    }
6540
6541    /// PSP-5 Phase 3: Check that required structural dependencies have
6542    /// machine-verifiable digests, not just prose summaries.
6543    ///
6544    /// Returns a list of (dependency_node_id, reason) for dependencies that
6545    /// only have semantic/advisory summaries with no structural evidence.
6546    fn check_structural_dependencies(
6547        &self,
6548        node: &SRBNNode,
6549        restriction_map: &perspt_core::types::RestrictionMap,
6550    ) -> Vec<(String, String)> {
6551        use perspt_core::types::{ArtifactKind, NodeClass};
6552
6553        let mut prose_only = Vec::new();
6554
6555        // Only enforce for Implementation nodes that depend on Interface nodes
6556        if node.node_class != NodeClass::Implementation {
6557            return prose_only;
6558        }
6559
6560        // Collect parent Interface node IDs from the DAG
6561        let idx = match self.node_indices.get(&node.node_id) {
6562            Some(i) => *i,
6563            None => return prose_only,
6564        };
6565
6566        let parents: Vec<NodeIndex> = self
6567            .graph
6568            .neighbors_directed(idx, petgraph::Direction::Incoming)
6569            .collect();
6570
6571        for pidx in parents {
6572            let parent = &self.graph[pidx];
6573            if parent.node_class != NodeClass::Interface {
6574                continue;
6575            }
6576
6577            // Check if we have at least one structural digest from this parent
6578            let has_structural = restriction_map.structural_digests.iter().any(|d| {
6579                d.source_node_id == parent.node_id
6580                    && matches!(
6581                        d.artifact_kind,
6582                        ArtifactKind::Signature
6583                            | ArtifactKind::Schema
6584                            | ArtifactKind::InterfaceSeal
6585                    )
6586            });
6587
6588            if !has_structural {
6589                prose_only.push((
6590                    parent.node_id.clone(),
6591                    format!(
6592                        "Interface node '{}' has no Signature/Schema/InterfaceSeal digest in the restriction map",
6593                        parent.node_id
6594                    ),
6595                ));
6596            }
6597        }
6598
6599        prose_only
6600    }
6601
6602    /// Inject sealed interface digests from parent nodes into a restriction map.
6603    ///
6604    /// For each parent that has a recorded interface seal in the ledger, replace
6605    /// the mutable file reference in the sealed_interfaces list with a
6606    /// structural digest derived from the persisted seal.  This ensures the
6607    /// child context is assembled from immutable sealed data.
6608    fn inject_sealed_interfaces(
6609        &self,
6610        idx: NodeIndex,
6611        restriction_map: &mut perspt_core::types::RestrictionMap,
6612    ) {
6613        let parents: Vec<NodeIndex> = self
6614            .graph
6615            .neighbors_directed(idx, petgraph::Direction::Incoming)
6616            .collect();
6617
6618        for pidx in parents {
6619            let parent = &self.graph[pidx];
6620            if parent.interface_seal_hash.is_none() {
6621                continue;
6622            }
6623
6624            let parent_node_id = &parent.node_id;
6625
6626            // Query persisted seal records for this parent
6627            let seals = match self.ledger.get_interface_seals(parent_node_id) {
6628                Ok(rows) => rows,
6629                Err(e) => {
6630                    log::debug!("Could not query seals for {}: {}", parent_node_id, e);
6631                    continue;
6632                }
6633            };
6634
6635            for seal in seals {
6636                // Remove the path from sealed_interfaces (it will be replaced by digest)
6637                restriction_map
6638                    .sealed_interfaces
6639                    .retain(|p| *p != seal.sealed_path);
6640
6641                // Convert Vec<u8> seal_hash to [u8; 32]
6642                let mut hash = [0u8; 32];
6643                let len = seal.seal_hash.len().min(32);
6644                hash[..len].copy_from_slice(&seal.seal_hash[..len]);
6645
6646                // Add a structural digest instead
6647                let digest = perspt_core::types::StructuralDigest {
6648                    digest_id: format!("seal_{}_{}", seal.node_id, seal.sealed_path),
6649                    source_node_id: seal.node_id.clone(),
6650                    source_path: seal.sealed_path.clone(),
6651                    artifact_kind: perspt_core::types::ArtifactKind::InterfaceSeal,
6652                    hash,
6653                    version: seal.version as u32,
6654                };
6655                restriction_map.structural_digests.push(digest);
6656
6657                log::debug!(
6658                    "Injected sealed digest for {} from parent {}",
6659                    seal.sealed_path,
6660                    parent_node_id,
6661                );
6662            }
6663        }
6664    }
6665}
6666
6667/// Convert diagnostic severity to string
6668fn severity_to_str(severity: Option<lsp_types::DiagnosticSeverity>) -> &'static str {
6669    match severity {
6670        Some(lsp_types::DiagnosticSeverity::ERROR) => "ERROR",
6671        Some(lsp_types::DiagnosticSeverity::WARNING) => "WARNING",
6672        Some(lsp_types::DiagnosticSeverity::INFORMATION) => "INFO",
6673        Some(lsp_types::DiagnosticSeverity::HINT) => "HINT",
6674        Some(_) => "OTHER",
6675        None => "UNKNOWN",
6676    }
6677}
6678
6679/// PSP-5 Phase 9: Determine which verification stages to run based on NodeClass.
6680///
6681/// - **Interface**: SyntaxCheck only (signatures/schemas)
6682/// - **Implementation**: SyntaxCheck + Build (+ Test if weighted_tests non-empty)
6683/// - **Integration**: Full pipeline (SyntaxCheck + Build + Test + Lint)
6684fn verification_stages_for_node(node: &SRBNNode) -> Vec<perspt_core::plugin::VerifierStage> {
6685    use perspt_core::plugin::VerifierStage;
6686    match node.node_class {
6687        perspt_core::types::NodeClass::Interface => {
6688            vec![VerifierStage::SyntaxCheck]
6689        }
6690        perspt_core::types::NodeClass::Implementation => {
6691            let mut stages = vec![VerifierStage::SyntaxCheck, VerifierStage::Build];
6692            if !node.contract.weighted_tests.is_empty() {
6693                stages.push(VerifierStage::Test);
6694            }
6695            stages
6696        }
6697        perspt_core::types::NodeClass::Integration => {
6698            vec![
6699                VerifierStage::SyntaxCheck,
6700                VerifierStage::Build,
6701                VerifierStage::Test,
6702                VerifierStage::Lint,
6703            ]
6704        }
6705    }
6706}
6707
6708/// Parse a persisted state string back into a NodeState enum
6709fn parse_node_state(s: &str) -> NodeState {
6710    match s {
6711        "TaskQueued" => NodeState::TaskQueued,
6712        "Planning" => NodeState::Planning,
6713        "Coding" => NodeState::Coding,
6714        "Verifying" => NodeState::Verifying,
6715        "Retry" => NodeState::Retry,
6716        "SheafCheck" => NodeState::SheafCheck,
6717        "Committing" => NodeState::Committing,
6718        "Escalated" => NodeState::Escalated,
6719        "Completed" | "COMPLETED" | "STABLE" => NodeState::Completed,
6720        "Failed" | "FAILED" => NodeState::Failed,
6721        "Aborted" | "ABORTED" => NodeState::Aborted,
6722        _ => NodeState::TaskQueued, // Default for unknown states
6723    }
6724}
6725
6726/// Parse a persisted node class string back into a NodeClass enum
6727fn parse_node_class(s: &str) -> NodeClass {
6728    match s {
6729        "Interface" => NodeClass::Interface,
6730        "Implementation" => NodeClass::Implementation,
6731        "Integration" => NodeClass::Integration,
6732        _ => NodeClass::default(),
6733    }
6734}
6735
6736#[cfg(test)]
6737mod tests {
6738    use super::*;
6739    use std::path::PathBuf;
6740
6741    #[tokio::test]
6742    async fn test_orchestrator_creation() {
6743        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6744        assert_eq!(orch.node_count(), 0);
6745    }
6746
6747    #[tokio::test]
6748    async fn test_add_nodes() {
6749        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6750
6751        let node1 = SRBNNode::new(
6752            "node1".to_string(),
6753            "Test task 1".to_string(),
6754            ModelTier::Architect,
6755        );
6756        let node2 = SRBNNode::new(
6757            "node2".to_string(),
6758            "Test task 2".to_string(),
6759            ModelTier::Actuator,
6760        );
6761
6762        orch.add_node(node1);
6763        orch.add_node(node2);
6764        orch.add_dependency("node1", "node2", "depends_on").unwrap();
6765
6766        assert_eq!(orch.node_count(), 2);
6767    }
6768    #[tokio::test]
6769    async fn test_lsp_key_for_file_resolves_by_plugin() {
6770        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6771        // Insert a dummy LSP client key so the lookup has something to match
6772        orch.lsp_clients.insert(
6773            "rust".to_string(),
6774            crate::lsp::LspClient::new("rust-analyzer"),
6775        );
6776        orch.lsp_clients
6777            .insert("python".to_string(), crate::lsp::LspClient::new("pylsp"));
6778
6779        // Rust plugin owns .rs files
6780        assert_eq!(
6781            orch.lsp_key_for_file("src/main.rs"),
6782            Some("rust".to_string())
6783        );
6784        // Python plugin owns .py files
6785        assert_eq!(orch.lsp_key_for_file("app.py"), Some("python".to_string()));
6786        // Unknown extension falls back to first available client
6787        let key = orch.lsp_key_for_file("data.csv");
6788        assert!(key.is_some()); // Falls back to first available
6789    }
6790
6791    // =========================================================================
6792    // Phase 5: Graph rewrite & sheaf validator tests
6793    // =========================================================================
6794
6795    #[tokio::test]
6796    async fn test_split_node_creates_children() {
6797        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6798        let mut node = SRBNNode::new("parent".into(), "Do everything".into(), ModelTier::Actuator);
6799        node.output_targets = vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")];
6800        orch.add_node(node);
6801
6802        let idx = orch.node_indices["parent"];
6803        let applied = orch.split_node(idx, &["handle a.rs".into(), "handle b.rs".into()]);
6804        assert!(!applied.is_empty());
6805        // Parent should be gone
6806        assert!(!orch.node_indices.contains_key("parent"));
6807        // Two children should exist
6808        assert!(orch.node_indices.contains_key("parent__split_0"));
6809        assert!(orch.node_indices.contains_key("parent__split_1"));
6810    }
6811
6812    #[tokio::test]
6813    async fn test_split_node_empty_children_is_noop() {
6814        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6815        let node = SRBNNode::new("n".into(), "g".into(), ModelTier::Actuator);
6816        orch.add_node(node);
6817        let idx = orch.node_indices["n"];
6818        let applied = orch.split_node(idx, &[]);
6819        // Should not apply — return empty vec but not panic
6820        assert!(applied.is_empty());
6821    }
6822
6823    #[tokio::test]
6824    async fn test_insert_interface_node() {
6825        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6826        let n1 = SRBNNode::new("a".into(), "source".into(), ModelTier::Actuator);
6827        let n2 = SRBNNode::new("b".into(), "dest".into(), ModelTier::Actuator);
6828        orch.add_node(n1);
6829        orch.add_node(n2);
6830        orch.add_dependency("a", "b", "data_flow").unwrap();
6831
6832        let idx_a = orch.node_indices["a"];
6833        let applied = orch.insert_interface_node(idx_a, "API boundary");
6834        assert!(applied.is_some());
6835        assert!(orch.node_indices.contains_key("a__iface"));
6836        // Should now have 3 nodes
6837        assert_eq!(orch.node_count(), 3);
6838    }
6839
6840    #[tokio::test]
6841    async fn test_replan_subgraph_resets_nodes() {
6842        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6843        let mut n1 = SRBNNode::new("trigger".into(), "g1".into(), ModelTier::Actuator);
6844        n1.state = NodeState::Coding;
6845        let mut n2 = SRBNNode::new("dep".into(), "g2".into(), ModelTier::Actuator);
6846        n2.state = NodeState::Completed;
6847        orch.add_node(n1);
6848        orch.add_node(n2);
6849
6850        let trigger_idx = orch.node_indices["trigger"];
6851        let applied = orch.replan_subgraph(trigger_idx, &["dep".into()]);
6852        assert!(applied);
6853
6854        let dep_idx = orch.node_indices["dep"];
6855        assert_eq!(orch.graph[dep_idx].state, NodeState::TaskQueued);
6856        assert_eq!(orch.graph[trigger_idx].state, NodeState::Retry);
6857    }
6858
6859    #[tokio::test]
6860    async fn test_select_validators_always_includes_dependency_graph() {
6861        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6862        let node = SRBNNode::new("n".into(), "g".into(), ModelTier::Actuator);
6863        orch.add_node(node);
6864        let idx = orch.node_indices["n"];
6865
6866        let validators = orch.select_validators(idx);
6867        assert!(validators.contains(&SheafValidatorClass::DependencyGraphConsistency));
6868    }
6869
6870    #[tokio::test]
6871    async fn test_select_validators_interface_node() {
6872        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6873        let mut node = SRBNNode::new("iface".into(), "g".into(), ModelTier::Actuator);
6874        node.node_class = perspt_core::types::NodeClass::Interface;
6875        orch.add_node(node);
6876        let idx = orch.node_indices["iface"];
6877
6878        let validators = orch.select_validators(idx);
6879        assert!(validators.contains(&SheafValidatorClass::ExportImportConsistency));
6880    }
6881
6882    #[tokio::test]
6883    async fn test_run_sheaf_validator_dependency_graph_no_cycles() {
6884        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6885        let n1 = SRBNNode::new("a".into(), "g".into(), ModelTier::Actuator);
6886        let n2 = SRBNNode::new("b".into(), "g".into(), ModelTier::Actuator);
6887        orch.add_node(n1);
6888        orch.add_node(n2);
6889        orch.add_dependency("a", "b", "dep").unwrap();
6890
6891        let idx = orch.node_indices["a"];
6892        let result = orch.run_sheaf_validator(idx, SheafValidatorClass::DependencyGraphConsistency);
6893        assert!(result.passed);
6894        assert_eq!(result.v_sheaf_contribution, 0.0);
6895    }
6896
6897    #[tokio::test]
6898    async fn test_classify_non_convergence_default() {
6899        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6900        let node = SRBNNode::new("n".into(), "g".into(), ModelTier::Actuator);
6901        orch.add_node(node);
6902        let idx = orch.node_indices["n"];
6903
6904        // With no verification results or policy failures, should default to ImplementationError
6905        let category = orch.classify_non_convergence(idx);
6906        assert_eq!(category, EscalationCategory::ImplementationError);
6907    }
6908
6909    #[tokio::test]
6910    async fn test_affected_dependents() {
6911        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6912        let n1 = SRBNNode::new("root".into(), "g".into(), ModelTier::Actuator);
6913        let n2 = SRBNNode::new("child1".into(), "g".into(), ModelTier::Actuator);
6914        let n3 = SRBNNode::new("child2".into(), "g".into(), ModelTier::Actuator);
6915        orch.add_node(n1);
6916        orch.add_node(n2);
6917        orch.add_node(n3);
6918        orch.add_dependency("root", "child1", "dep").unwrap();
6919        orch.add_dependency("root", "child2", "dep").unwrap();
6920
6921        let idx = orch.node_indices["root"];
6922        let deps = orch.affected_dependents(idx);
6923        assert_eq!(deps.len(), 2);
6924        assert!(deps.contains(&"child1".to_string()));
6925        assert!(deps.contains(&"child2".to_string()));
6926    }
6927
6928    // =========================================================================
6929    // PSP-5 Phase 6: Provisional Branch Tests
6930    // =========================================================================
6931
6932    #[tokio::test]
6933    async fn test_maybe_create_provisional_branch_root_node() {
6934        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_phase6"));
6935        orch.context.session_id = "test_session".into();
6936        let node = SRBNNode::new("root".into(), "root goal".into(), ModelTier::Actuator);
6937        orch.add_node(node);
6938
6939        let idx = orch.node_indices["root"];
6940        // Root node has no parents — should not create a branch
6941        let branch = orch.maybe_create_provisional_branch(idx);
6942        assert!(branch.is_none());
6943        assert!(orch.graph[idx].provisional_branch_id.is_none());
6944    }
6945
6946    #[tokio::test]
6947    async fn test_maybe_create_provisional_branch_child_node() {
6948        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_phase6"));
6949        orch.context.session_id = "test_session".into();
6950        let parent = SRBNNode::new("parent".into(), "parent goal".into(), ModelTier::Actuator);
6951        let child = SRBNNode::new("child".into(), "child goal".into(), ModelTier::Actuator);
6952        orch.add_node(parent);
6953        orch.add_node(child);
6954        orch.add_dependency("parent", "child", "dep").unwrap();
6955
6956        let idx = orch.node_indices["child"];
6957        let branch = orch.maybe_create_provisional_branch(idx);
6958        assert!(branch.is_some());
6959        assert!(orch.graph[idx].provisional_branch_id.is_some());
6960    }
6961
6962    #[tokio::test]
6963    async fn test_collect_descendants() {
6964        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6965        let n1 = SRBNNode::new("a".into(), "g".into(), ModelTier::Actuator);
6966        let n2 = SRBNNode::new("b".into(), "g".into(), ModelTier::Actuator);
6967        let n3 = SRBNNode::new("c".into(), "g".into(), ModelTier::Actuator);
6968        let n4 = SRBNNode::new("d".into(), "g".into(), ModelTier::Actuator);
6969        orch.add_node(n1);
6970        orch.add_node(n2);
6971        orch.add_node(n3);
6972        orch.add_node(n4);
6973        orch.add_dependency("a", "b", "dep").unwrap();
6974        orch.add_dependency("b", "c", "dep").unwrap();
6975        orch.add_dependency("a", "d", "dep").unwrap();
6976
6977        let idx_a = orch.node_indices["a"];
6978        let descendants = orch.collect_descendants(idx_a);
6979        assert_eq!(descendants.len(), 3); // b, c, d
6980    }
6981
6982    #[tokio::test]
6983    async fn test_check_seal_prerequisites_no_interface_parent() {
6984        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6985        let parent = SRBNNode::new("parent".into(), "g".into(), ModelTier::Actuator);
6986        let child = SRBNNode::new("child".into(), "g".into(), ModelTier::Actuator);
6987        orch.add_node(parent);
6988        orch.add_node(child);
6989        orch.add_dependency("parent", "child", "dep").unwrap();
6990
6991        let idx = orch.node_indices["child"];
6992        // Parent is Implementation (default), not Interface — should not block
6993        assert!(!orch.check_seal_prerequisites(idx));
6994        assert!(orch.blocked_dependencies.is_empty());
6995    }
6996
6997    #[tokio::test]
6998    async fn test_check_seal_prerequisites_unsealed_interface() {
6999        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
7000        let mut parent = SRBNNode::new("iface".into(), "g".into(), ModelTier::Actuator);
7001        parent.node_class = perspt_core::types::NodeClass::Interface;
7002        let child = SRBNNode::new("impl".into(), "g".into(), ModelTier::Actuator);
7003        orch.add_node(parent);
7004        orch.add_node(child);
7005        orch.add_dependency("iface", "impl", "dep").unwrap();
7006
7007        let idx = orch.node_indices["impl"];
7008        // Interface parent not sealed and not completed — should block
7009        assert!(orch.check_seal_prerequisites(idx));
7010        assert_eq!(orch.blocked_dependencies.len(), 1);
7011        assert_eq!(orch.blocked_dependencies[0].parent_node_id, "iface");
7012    }
7013
7014    #[tokio::test]
7015    async fn test_check_seal_prerequisites_sealed_interface() {
7016        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
7017        let mut parent = SRBNNode::new("iface".into(), "g".into(), ModelTier::Actuator);
7018        parent.node_class = perspt_core::types::NodeClass::Interface;
7019        parent.interface_seal_hash = Some([1u8; 32]); // Already sealed
7020        let child = SRBNNode::new("impl".into(), "g".into(), ModelTier::Actuator);
7021        orch.add_node(parent);
7022        orch.add_node(child);
7023        orch.add_dependency("iface", "impl", "dep").unwrap();
7024
7025        let idx = orch.node_indices["impl"];
7026        // Interface parent is sealed — should not block
7027        assert!(!orch.check_seal_prerequisites(idx));
7028        assert!(orch.blocked_dependencies.is_empty());
7029    }
7030
7031    #[tokio::test]
7032    async fn test_unblock_dependents() {
7033        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
7034        let parent = SRBNNode::new("parent".into(), "g".into(), ModelTier::Actuator);
7035        let child = SRBNNode::new("child".into(), "g".into(), ModelTier::Actuator);
7036        orch.add_node(parent);
7037        orch.add_node(child);
7038
7039        // Manually add a blocked dependency
7040        orch.blocked_dependencies
7041            .push(perspt_core::types::BlockedDependency::new(
7042                "child",
7043                "parent",
7044                vec!["src/api.rs".into()],
7045            ));
7046        assert_eq!(orch.blocked_dependencies.len(), 1);
7047
7048        let idx = orch.node_indices["parent"];
7049        orch.unblock_dependents(idx);
7050        assert!(orch.blocked_dependencies.is_empty());
7051    }
7052
7053    #[tokio::test]
7054    async fn test_flush_descendant_branches() {
7055        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_phase6_flush"));
7056        orch.context.session_id = "test_session".into();
7057
7058        let parent = SRBNNode::new("parent".into(), "g".into(), ModelTier::Actuator);
7059        let mut child1 = SRBNNode::new("child1".into(), "g".into(), ModelTier::Actuator);
7060        child1.provisional_branch_id = Some("branch_c1".into());
7061        let mut child2 = SRBNNode::new("child2".into(), "g".into(), ModelTier::Actuator);
7062        child2.provisional_branch_id = Some("branch_c2".into());
7063        let grandchild = SRBNNode::new("grandchild".into(), "g".into(), ModelTier::Actuator);
7064        orch.add_node(parent);
7065        orch.add_node(child1);
7066        orch.add_node(child2);
7067        orch.add_node(grandchild);
7068        orch.add_dependency("parent", "child1", "dep").unwrap();
7069        orch.add_dependency("parent", "child2", "dep").unwrap();
7070        orch.add_dependency("child1", "grandchild", "dep").unwrap();
7071
7072        let idx = orch.node_indices["parent"];
7073        // This will try to flush branches but ledger may not find them —
7074        // the important thing is it doesn't panic and traverses correctly
7075        orch.flush_descendant_branches(idx);
7076    }
7077
7078    // =========================================================================
7079    // PSP-5 Completion Tests
7080    // =========================================================================
7081
7082    #[tokio::test]
7083    async fn test_effective_working_dir_no_branch() {
7084        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/test/workspace"));
7085        // No nodes, but we can test the helper directly by adding one
7086        let mut orch = orch;
7087        let node = SRBNNode::new("n1".into(), "goal".into(), ModelTier::Actuator);
7088        orch.add_node(node);
7089        let idx = orch.node_indices["n1"];
7090        // No provisional branch → returns live workspace
7091        assert_eq!(
7092            orch.effective_working_dir(idx),
7093            PathBuf::from("/test/workspace")
7094        );
7095    }
7096
7097    #[tokio::test]
7098    async fn test_sandbox_dir_for_node_none_without_branch() {
7099        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/test/workspace"));
7100        let mut orch = orch;
7101        let node = SRBNNode::new("n1".into(), "goal".into(), ModelTier::Actuator);
7102        orch.add_node(node);
7103        let idx = orch.node_indices["n1"];
7104        assert!(orch.sandbox_dir_for_node(idx).is_none());
7105    }
7106
7107    #[tokio::test]
7108    async fn test_rewrite_churn_guardrail() {
7109        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_churn"));
7110        let mut orch = orch;
7111        let node = SRBNNode::new("node_a".into(), "goal".into(), ModelTier::Actuator);
7112        orch.add_node(node);
7113        // count_lineage_rewrites should return 0 for a fresh node
7114        let count = orch.count_lineage_rewrites("node_a");
7115        assert_eq!(count, 0);
7116    }
7117
7118    #[tokio::test]
7119    async fn test_run_resumed_skips_terminal_nodes() {
7120        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_resume"));
7121
7122        let mut n1 = SRBNNode::new("done".into(), "completed".into(), ModelTier::Actuator);
7123        n1.state = NodeState::Completed;
7124        let mut n2 = SRBNNode::new("failed".into(), "failed".into(), ModelTier::Actuator);
7125        n2.state = NodeState::Failed;
7126        orch.add_node(n1);
7127        orch.add_node(n2);
7128
7129        // Both nodes are terminal, so run_resumed should do nothing and succeed
7130        let result = orch.run_resumed().await;
7131        assert!(result.is_ok());
7132    }
7133
7134    #[tokio::test]
7135    async fn test_persist_review_decision_no_panic() {
7136        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_review"));
7137        // Should not panic even without a real ledger session —
7138        // it gracefully logs errors
7139        orch.persist_review_decision("node_x", "approved", None);
7140    }
7141
7142    // =========================================================================
7143    // PSP-5 Gap Tests
7144    // =========================================================================
7145
7146    #[tokio::test]
7147    async fn test_check_structural_dependencies_blocks_prose_only() {
7148        use perspt_core::types::{NodeClass, RestrictionMap};
7149
7150        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_struct_dep"));
7151
7152        // Parent: Interface node (no structural digests)
7153        let mut parent = SRBNNode::new("iface_1".into(), "Define API".into(), ModelTier::Architect);
7154        parent.node_class = NodeClass::Interface;
7155
7156        // Child: Implementation node depending on the interface
7157        let mut child = SRBNNode::new("impl_1".into(), "Implement API".into(), ModelTier::Actuator);
7158        child.node_class = NodeClass::Implementation;
7159
7160        let parent_idx = orch.add_node(parent);
7161        let child_idx = orch.add_node(child.clone());
7162        orch.graph
7163            .add_edge(parent_idx, child_idx, Dependency { kind: "dep".into() });
7164
7165        // Empty restriction map — no structural digests at all
7166        let rmap = RestrictionMap::for_node("impl_1");
7167        let gaps = orch.check_structural_dependencies(&child, &rmap);
7168
7169        assert_eq!(gaps.len(), 1);
7170        assert_eq!(gaps[0].0, "iface_1");
7171        assert!(gaps[0].1.contains("no Signature/Schema/InterfaceSeal"));
7172    }
7173
7174    #[tokio::test]
7175    async fn test_check_structural_dependencies_passes_with_digest() {
7176        use perspt_core::types::{ArtifactKind, NodeClass, RestrictionMap, StructuralDigest};
7177
7178        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_struct_ok"));
7179
7180        let mut parent = SRBNNode::new("iface_2".into(), "Define API".into(), ModelTier::Architect);
7181        parent.node_class = NodeClass::Interface;
7182
7183        let mut child = SRBNNode::new("impl_2".into(), "Implement API".into(), ModelTier::Actuator);
7184        child.node_class = NodeClass::Implementation;
7185
7186        let parent_idx = orch.add_node(parent);
7187        let child_idx = orch.add_node(child.clone());
7188        orch.graph
7189            .add_edge(parent_idx, child_idx, Dependency { kind: "dep".into() });
7190
7191        // Restriction map with a Signature digest from the Interface node
7192        let mut rmap = RestrictionMap::for_node("impl_2");
7193        rmap.structural_digests.push(StructuralDigest::from_content(
7194            "iface_2",
7195            "api.rs",
7196            ArtifactKind::Signature,
7197            b"fn do_thing(x: i32) -> bool;",
7198        ));
7199
7200        let gaps = orch.check_structural_dependencies(&child, &rmap);
7201        assert!(gaps.is_empty(), "Expected no gaps when digest present");
7202    }
7203
7204    #[tokio::test]
7205    async fn test_check_structural_dependencies_skips_non_implementation() {
7206        use perspt_core::types::{NodeClass, RestrictionMap};
7207
7208        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_struct_skip"));
7209
7210        // An Integration node should NOT be checked
7211        let mut node = SRBNNode::new("integ_1".into(), "Wire modules".into(), ModelTier::Actuator);
7212        node.node_class = NodeClass::Integration;
7213        orch.add_node(node.clone());
7214
7215        let rmap = RestrictionMap::for_node("integ_1");
7216        let gaps = orch.check_structural_dependencies(&node, &rmap);
7217        assert!(gaps.is_empty(), "Integration nodes should skip the check");
7218    }
7219
7220    #[tokio::test]
7221    async fn test_tier_default_models_are_differentiated() {
7222        // PSP-5 Fix D: each tier should map to a different default model
7223        let arch = ModelTier::Architect.default_model();
7224        let act = ModelTier::Actuator.default_model();
7225        let spec = ModelTier::Speculator.default_model();
7226
7227        // Architect and Actuator should NOT be the same tier default
7228        assert_ne!(arch, act, "Architect and Actuator defaults should differ");
7229        // Speculator should be the lightest
7230        assert_ne!(spec, arch, "Speculator should differ from Architect");
7231    }
7232
7233    // =========================================================================
7234    // PSP-5: Tier Wiring and Plan Validation Tests
7235    // =========================================================================
7236
7237    #[tokio::test]
7238    async fn test_orchestrator_stores_all_four_tier_models() {
7239        let orch = SRBNOrchestrator::new_with_models(
7240            PathBuf::from("/tmp/test_tiers"),
7241            false,
7242            Some("arch-model".into()),
7243            Some("act-model".into()),
7244            Some("ver-model".into()),
7245            Some("spec-model".into()),
7246            None,
7247            None,
7248            None,
7249            None,
7250        );
7251        assert_eq!(orch.architect_model, "arch-model");
7252        assert_eq!(orch.actuator_model, "act-model");
7253        assert_eq!(orch.verifier_model, "ver-model");
7254        assert_eq!(orch.speculator_model, "spec-model");
7255    }
7256
7257    #[tokio::test]
7258    async fn test_orchestrator_default_tier_models() {
7259        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_tier_defaults"));
7260        assert_eq!(orch.architect_model, ModelTier::Architect.default_model());
7261        assert_eq!(orch.actuator_model, ModelTier::Actuator.default_model());
7262        assert_eq!(orch.verifier_model, ModelTier::Verifier.default_model());
7263        assert_eq!(orch.speculator_model, ModelTier::Speculator.default_model());
7264    }
7265
7266    #[tokio::test]
7267    async fn test_create_nodes_rejects_duplicate_output_files() {
7268        use perspt_core::types::PlannedTask;
7269
7270        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_dup_outputs"));
7271
7272        let plan = TaskPlan {
7273            tasks: vec![
7274                PlannedTask {
7275                    id: "task_1".into(),
7276                    goal: "Create math".into(),
7277                    output_files: vec!["src/math.py".into(), "tests/test_math.py".into()],
7278                    ..PlannedTask::new("task_1", "Create math")
7279                },
7280                PlannedTask {
7281                    id: "task_2".into(),
7282                    goal: "Create tests".into(),
7283                    output_files: vec!["tests/test_math.py".into()],
7284                    ..PlannedTask::new("task_2", "Create tests")
7285                },
7286            ],
7287        };
7288
7289        let result = orch.create_nodes_from_plan(&plan);
7290        assert!(result.is_err(), "Should reject duplicate output_files");
7291        let err = result.unwrap_err().to_string();
7292        assert!(
7293            err.contains("tests/test_math.py"),
7294            "Error should mention the duplicate file: {}",
7295            err
7296        );
7297    }
7298
7299    #[tokio::test]
7300    async fn test_create_nodes_accepts_unique_output_files() {
7301        use perspt_core::types::PlannedTask;
7302
7303        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_unique_outputs"));
7304
7305        let plan = TaskPlan {
7306            tasks: vec![
7307                PlannedTask {
7308                    id: "task_1".into(),
7309                    goal: "Create math".into(),
7310                    output_files: vec!["src/math.py".into()],
7311                    ..PlannedTask::new("task_1", "Create math")
7312                },
7313                PlannedTask {
7314                    id: "test_1".into(),
7315                    goal: "Test math".into(),
7316                    output_files: vec!["tests/test_math.py".into()],
7317                    dependencies: vec!["task_1".into()],
7318                    ..PlannedTask::new("test_1", "Test math")
7319                },
7320            ],
7321        };
7322
7323        let result = orch.create_nodes_from_plan(&plan);
7324        assert!(result.is_ok(), "Should accept unique output_files");
7325        assert_eq!(orch.graph.node_count(), 2);
7326    }
7327
7328    #[tokio::test]
7329    async fn test_ownership_manifest_built_with_majority_plugin_vote() {
7330        use perspt_core::types::PlannedTask;
7331
7332        let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_plugin_vote"));
7333
7334        let plan = TaskPlan {
7335            tasks: vec![PlannedTask {
7336                id: "task_1".into(),
7337                goal: "Create Python module".into(),
7338                output_files: vec![
7339                    "src/main.py".into(),
7340                    "src/helper.py".into(),
7341                    "src/__init__.py".into(),
7342                ],
7343                ..PlannedTask::new("task_1", "Create Python module")
7344            }],
7345        };
7346
7347        orch.create_nodes_from_plan(&plan).unwrap();
7348
7349        // All three files should be in the manifest
7350        assert_eq!(orch.context.ownership_manifest.len(), 3);
7351        // The node should have the python plugin assigned
7352        let idx = orch.node_indices["task_1"];
7353        assert_eq!(orch.graph[idx].owner_plugin, "python");
7354    }
7355
7356    #[tokio::test]
7357    async fn test_apply_bundle_strips_paths_outside_node_output_targets() {
7358        use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
7359
7360        let temp_dir = std::env::temp_dir().join(format!(
7361            "perspt_bundle_target_guard_{}",
7362            uuid::Uuid::new_v4()
7363        ));
7364        std::fs::create_dir_all(temp_dir.join("src")).unwrap();
7365
7366        let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
7367        let plan = TaskPlan {
7368            tasks: vec![
7369                PlannedTask {
7370                    id: "validate_module".into(),
7371                    goal: "Create validation module".into(),
7372                    output_files: vec!["src/validate.rs".into()],
7373                    ..PlannedTask::new("validate_module", "Create validation module")
7374                },
7375                PlannedTask {
7376                    id: "lib_module".into(),
7377                    goal: "Export validation module".into(),
7378                    output_files: vec!["src/lib.rs".into()],
7379                    dependencies: vec!["validate_module".into()],
7380                    ..PlannedTask::new("lib_module", "Export validation module")
7381                },
7382            ],
7383        };
7384
7385        orch.create_nodes_from_plan(&plan).unwrap();
7386
7387        let bundle = ArtifactBundle {
7388            artifacts: vec![
7389                ArtifactOperation::Write {
7390                    path: "src/validate.rs".into(),
7391                    content: "pub fn ok() {}".into(),
7392                },
7393                ArtifactOperation::Write {
7394                    path: "src/lib.rs".into(),
7395                    content: "pub mod validate;".into(),
7396                },
7397            ],
7398            commands: vec![],
7399        };
7400
7401        // Should succeed — the undeclared path src/lib.rs is stripped, but
7402        // src/validate.rs is applied.
7403        orch.apply_bundle_transactionally(
7404            &bundle,
7405            "validate_module",
7406            perspt_core::types::NodeClass::Implementation,
7407        )
7408        .await
7409        .expect("Should apply valid artifacts after stripping undeclared paths");
7410
7411        // The declared file should be written
7412        assert!(temp_dir.join("src/validate.rs").exists());
7413        // The undeclared file should NOT be written
7414        assert!(!temp_dir.join("src/lib.rs").exists());
7415    }
7416
7417    #[tokio::test]
7418    async fn test_apply_bundle_writes_into_branch_sandbox() {
7419        use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
7420
7421        let temp_dir = std::env::temp_dir().join(format!(
7422            "perspt_branch_sandbox_write_{}",
7423            uuid::Uuid::new_v4()
7424        ));
7425        std::fs::create_dir_all(temp_dir.join("src")).unwrap();
7426        std::fs::write(temp_dir.join("src/lib.rs"), "pub fn old() {}\n").unwrap();
7427
7428        let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
7429        orch.context.session_id = uuid::Uuid::new_v4().to_string();
7430
7431        let plan = TaskPlan {
7432            tasks: vec![
7433                PlannedTask {
7434                    id: "parent".into(),
7435                    goal: "Parent node".into(),
7436                    output_files: vec!["src/lib.rs".into()],
7437                    ..PlannedTask::new("parent", "Parent node")
7438                },
7439                PlannedTask {
7440                    id: "child".into(),
7441                    goal: "Child node".into(),
7442                    context_files: vec!["src/lib.rs".into()],
7443                    output_files: vec!["src/child.rs".into()],
7444                    dependencies: vec!["parent".into()],
7445                    ..PlannedTask::new("child", "Child node")
7446                },
7447            ],
7448        };
7449
7450        orch.create_nodes_from_plan(&plan).unwrap();
7451        let child_idx = orch.node_indices["child"];
7452        let branch_id = orch.maybe_create_provisional_branch(child_idx).unwrap();
7453        let sandbox_dir = orch.sandbox_dir_for_node(child_idx).unwrap();
7454
7455        let bundle = ArtifactBundle {
7456            artifacts: vec![ArtifactOperation::Write {
7457                path: "src/child.rs".into(),
7458                content: "pub fn child() {}\n".into(),
7459            }],
7460            commands: vec![],
7461        };
7462
7463        orch.apply_bundle_transactionally(
7464            &bundle,
7465            "child",
7466            perspt_core::types::NodeClass::Implementation,
7467        )
7468        .await
7469        .unwrap();
7470
7471        assert!(sandbox_dir.join("src/child.rs").exists());
7472        assert!(!temp_dir.join("src/child.rs").exists());
7473
7474        orch.merge_provisional_branch(&branch_id, child_idx);
7475    }
7476
7477    #[test]
7478    fn test_verification_stages_for_node_classes() {
7479        use perspt_core::plugin::VerifierStage;
7480
7481        // Interface → SyntaxCheck only
7482        let interface_node =
7483            SRBNNode::new("iface".into(), "Define trait".into(), ModelTier::Actuator);
7484        // Default is Implementation, so override:
7485        let mut interface_node = interface_node;
7486        interface_node.node_class = perspt_core::types::NodeClass::Interface;
7487        let stages = verification_stages_for_node(&interface_node);
7488        assert_eq!(stages, vec![VerifierStage::SyntaxCheck]);
7489
7490        // Implementation without tests → SyntaxCheck + Build
7491        let mut implementation_node = SRBNNode::new(
7492            "impl".into(),
7493            "Implement feature".into(),
7494            ModelTier::Actuator,
7495        );
7496        implementation_node.node_class = perspt_core::types::NodeClass::Implementation;
7497        let stages = verification_stages_for_node(&implementation_node);
7498        assert_eq!(
7499            stages,
7500            vec![VerifierStage::SyntaxCheck, VerifierStage::Build]
7501        );
7502
7503        // Implementation with weighted tests → SyntaxCheck + Build + Test
7504        implementation_node
7505            .contract
7506            .weighted_tests
7507            .push(perspt_core::types::WeightedTest {
7508                test_name: "test_feature".into(),
7509                criticality: perspt_core::types::Criticality::High,
7510            });
7511        let stages = verification_stages_for_node(&implementation_node);
7512        assert_eq!(
7513            stages,
7514            vec![
7515                VerifierStage::SyntaxCheck,
7516                VerifierStage::Build,
7517                VerifierStage::Test
7518            ]
7519        );
7520
7521        // Integration → full pipeline
7522        let mut integration_node =
7523            SRBNNode::new("test".into(), "Verify feature".into(), ModelTier::Actuator);
7524        integration_node.node_class = perspt_core::types::NodeClass::Integration;
7525        integration_node
7526            .contract
7527            .weighted_tests
7528            .push(perspt_core::types::WeightedTest {
7529                test_name: "test_feature".into(),
7530                criticality: perspt_core::types::Criticality::High,
7531            });
7532        let stages = verification_stages_for_node(&integration_node);
7533        assert_eq!(
7534            stages,
7535            vec![
7536                VerifierStage::SyntaxCheck,
7537                VerifierStage::Build,
7538                VerifierStage::Test,
7539                VerifierStage::Lint,
7540            ]
7541        );
7542    }
7543
7544    // =========================================================================
7545    // Workspace Classification Tests
7546    // =========================================================================
7547
7548    #[tokio::test]
7549    async fn test_classify_workspace_empty_dir() {
7550        let temp = tempfile::tempdir().unwrap();
7551        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7552        let state = orch.classify_workspace("build a web app");
7553        // Empty dir with language keywords → Greenfield
7554        assert!(matches!(state, WorkspaceState::Greenfield { .. }));
7555    }
7556
7557    #[tokio::test]
7558    async fn test_classify_workspace_empty_dir_no_lang() {
7559        let temp = tempfile::tempdir().unwrap();
7560        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7561        let state = orch.classify_workspace("do something");
7562        // Empty dir, no keywords → Greenfield with no lang
7563        match state {
7564            WorkspaceState::Greenfield { inferred_lang } => assert!(inferred_lang.is_none()),
7565            _ => panic!("expected Greenfield, got {:?}", state),
7566        }
7567    }
7568
7569    #[tokio::test]
7570    async fn test_classify_workspace_existing_rust_project() {
7571        let temp = tempfile::tempdir().unwrap();
7572        // Create a Cargo.toml to make it look like a Rust project
7573        std::fs::write(
7574            temp.path().join("Cargo.toml"),
7575            "[package]\nname = \"test\"\nversion = \"0.1.0\"",
7576        )
7577        .unwrap();
7578        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7579        let state = orch.classify_workspace("add a feature");
7580        match state {
7581            WorkspaceState::ExistingProject { plugins } => {
7582                assert!(plugins.contains(&"rust".to_string()));
7583            }
7584            _ => panic!("expected ExistingProject, got {:?}", state),
7585        }
7586    }
7587
7588    #[tokio::test]
7589    async fn test_classify_workspace_existing_python_project() {
7590        let temp = tempfile::tempdir().unwrap();
7591        std::fs::write(
7592            temp.path().join("pyproject.toml"),
7593            "[project]\nname = \"test\"",
7594        )
7595        .unwrap();
7596        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7597        let state = orch.classify_workspace("add a feature");
7598        match state {
7599            WorkspaceState::ExistingProject { plugins } => {
7600                assert!(plugins.contains(&"python".to_string()));
7601            }
7602            _ => panic!("expected ExistingProject, got {:?}", state),
7603        }
7604    }
7605
7606    #[tokio::test]
7607    async fn test_classify_workspace_existing_js_project() {
7608        let temp = tempfile::tempdir().unwrap();
7609        std::fs::write(temp.path().join("package.json"), "{}").unwrap();
7610        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7611        let state = orch.classify_workspace("add auth");
7612        match state {
7613            WorkspaceState::ExistingProject { plugins } => {
7614                assert!(plugins.contains(&"javascript".to_string()));
7615            }
7616            _ => panic!("expected ExistingProject, got {:?}", state),
7617        }
7618    }
7619
7620    #[tokio::test]
7621    async fn test_classify_workspace_ambiguous_with_misc_files() {
7622        let temp = tempfile::tempdir().unwrap();
7623        // Non-empty dir with misc files that don't match any plugin
7624        std::fs::write(temp.path().join("notes.txt"), "hello").unwrap();
7625        std::fs::write(temp.path().join("data.csv"), "a,b,c").unwrap();
7626        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7627        let state = orch.classify_workspace("do something");
7628        assert!(matches!(state, WorkspaceState::Ambiguous));
7629    }
7630
7631    #[tokio::test]
7632    async fn test_classify_workspace_greenfield_with_rust_task() {
7633        let temp = tempfile::tempdir().unwrap();
7634        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7635        let state = orch.classify_workspace("create a rust CLI tool");
7636        match state {
7637            WorkspaceState::Greenfield { inferred_lang } => {
7638                assert_eq!(inferred_lang, Some("rust".to_string()));
7639            }
7640            _ => panic!("expected Greenfield, got {:?}", state),
7641        }
7642    }
7643
7644    #[tokio::test]
7645    async fn test_classify_workspace_greenfield_with_python_task() {
7646        let temp = tempfile::tempdir().unwrap();
7647        let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7648        let state = orch.classify_workspace("build a python flask API");
7649        match state {
7650            WorkspaceState::Greenfield { inferred_lang } => {
7651                assert_eq!(inferred_lang, Some("python".to_string()));
7652            }
7653            _ => panic!("expected Greenfield, got {:?}", state),
7654        }
7655    }
7656
7657    // =========================================================================
7658    // Tool Prerequisite Tests
7659    // =========================================================================
7660
7661    #[tokio::test]
7662    async fn test_check_prerequisites_returns_true_when_tools_available() {
7663        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
7664        let registry = perspt_core::plugin::PluginRegistry::new();
7665        // Rust plugin — cargo/rustc should be available in dev environment
7666        if let Some(plugin) = registry.get("rust") {
7667            let result = orch.check_tool_prerequisites(plugin);
7668            // We can't assert true (CI might not have rust-analyzer)
7669            // but the method should not panic
7670            let _ = result;
7671        }
7672    }
7673
7674    #[test]
7675    fn test_required_binaries_rust_includes_cargo() {
7676        let registry = perspt_core::plugin::PluginRegistry::new();
7677        let plugin = registry.get("rust").unwrap();
7678        let bins = plugin.required_binaries();
7679        assert!(bins.iter().any(|(name, _, _)| *name == "cargo"));
7680        assert!(bins.iter().any(|(name, _, _)| *name == "rustc"));
7681    }
7682
7683    #[test]
7684    fn test_required_binaries_python_includes_uv() {
7685        let registry = perspt_core::plugin::PluginRegistry::new();
7686        let plugin = registry.get("python").unwrap();
7687        let bins = plugin.required_binaries();
7688        assert!(bins.iter().any(|(name, _, _)| *name == "uv"));
7689        assert!(bins.iter().any(|(name, _, _)| *name == "python3"));
7690    }
7691
7692    #[test]
7693    fn test_required_binaries_js_includes_node() {
7694        let registry = perspt_core::plugin::PluginRegistry::new();
7695        let plugin = registry.get("javascript").unwrap();
7696        let bins = plugin.required_binaries();
7697        assert!(bins.iter().any(|(name, _, _)| *name == "node"));
7698        assert!(bins.iter().any(|(name, _, _)| *name == "npm"));
7699    }
7700
7701    // =========================================================================
7702    // Fallback Resolution Tests
7703    // =========================================================================
7704
7705    #[tokio::test]
7706    async fn test_fallback_defaults_to_none_without_explicit_config() {
7707        let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
7708        assert!(orch.architect_fallback_model.is_none());
7709        assert!(orch.actuator_fallback_model.is_none());
7710        assert!(orch.verifier_fallback_model.is_none());
7711        assert!(orch.speculator_fallback_model.is_none());
7712    }
7713
7714    #[tokio::test]
7715    async fn test_explicit_fallback_stored_correctly() {
7716        let orch = SRBNOrchestrator::new_with_models(
7717            PathBuf::from("/tmp/test_fallback"),
7718            false,
7719            None,
7720            None,
7721            None,
7722            None,
7723            Some("gpt-4o".into()),
7724            Some("gpt-4o-mini".into()),
7725            Some("gpt-4o".into()),
7726            Some("gpt-4o-mini".into()),
7727        );
7728        assert_eq!(orch.architect_fallback_model, Some("gpt-4o".to_string()));
7729        assert_eq!(
7730            orch.actuator_fallback_model,
7731            Some("gpt-4o-mini".to_string())
7732        );
7733        assert_eq!(orch.verifier_fallback_model, Some("gpt-4o".to_string()));
7734        assert_eq!(
7735            orch.speculator_fallback_model,
7736            Some("gpt-4o-mini".to_string())
7737        );
7738    }
7739
7740    #[tokio::test]
7741    async fn test_per_tier_models_independent() {
7742        let orch = SRBNOrchestrator::new_with_models(
7743            PathBuf::from("/tmp/test_tiers_independent"),
7744            false,
7745            Some("arch".into()),
7746            Some("act".into()),
7747            Some("ver".into()),
7748            Some("spec".into()),
7749            None,
7750            None,
7751            None,
7752            None,
7753        );
7754        // Each tier stores its own model, not shared
7755        assert_ne!(orch.architect_model, orch.actuator_model);
7756        assert_ne!(orch.verifier_model, orch.speculator_model);
7757    }
7758
7759    // =========================================================================
7760    // Python auto-dependency repair tests
7761    // =========================================================================
7762
7763    #[test]
7764    fn test_extract_missing_python_modules_basic() {
7765        let output = r#"
7766FAILED tests/test_core.py::TestPipeline::test_run - ModuleNotFoundError: No module named 'httpx'
7767E   ModuleNotFoundError: No module named 'pydantic'
7768ImportError: No module named 'pyarrow'
7769"#;
7770        let mut missing = SRBNOrchestrator::extract_missing_python_modules(output);
7771        missing.sort();
7772        assert_eq!(missing, vec!["httpx", "pyarrow", "pydantic"]);
7773    }
7774
7775    #[test]
7776    fn test_extract_missing_python_modules_subpackage() {
7777        let output = "ModuleNotFoundError: No module named 'foo.bar.baz'";
7778        let missing = SRBNOrchestrator::extract_missing_python_modules(output);
7779        assert_eq!(missing, vec!["foo"]);
7780    }
7781
7782    #[test]
7783    fn test_extract_missing_python_modules_stdlib_filtered() {
7784        let output = r#"
7785ModuleNotFoundError: No module named 'numpy'
7786ModuleNotFoundError: No module named 'os'
7787ModuleNotFoundError: No module named 'json'
7788"#;
7789        let missing = SRBNOrchestrator::extract_missing_python_modules(output);
7790        assert_eq!(missing, vec!["numpy"]);
7791    }
7792
7793    #[test]
7794    fn test_extract_missing_python_modules_empty() {
7795        let output = "All tests passed!\n3 passed in 0.5s";
7796        let missing = SRBNOrchestrator::extract_missing_python_modules(output);
7797        assert!(missing.is_empty());
7798    }
7799
7800    #[test]
7801    fn test_python_import_to_package_mapping() {
7802        assert_eq!(SRBNOrchestrator::python_import_to_package("PIL"), "pillow");
7803        assert_eq!(SRBNOrchestrator::python_import_to_package("yaml"), "pyyaml");
7804        assert_eq!(
7805            SRBNOrchestrator::python_import_to_package("cv2"),
7806            "opencv-python"
7807        );
7808        assert_eq!(
7809            SRBNOrchestrator::python_import_to_package("sklearn"),
7810            "scikit-learn"
7811        );
7812        assert_eq!(
7813            SRBNOrchestrator::python_import_to_package("bs4"),
7814            "beautifulsoup4"
7815        );
7816        // Direct passthrough for unknown
7817        assert_eq!(SRBNOrchestrator::python_import_to_package("httpx"), "httpx");
7818        assert_eq!(
7819            SRBNOrchestrator::python_import_to_package("fastapi"),
7820            "fastapi"
7821        );
7822    }
7823
7824    #[test]
7825    fn test_normalize_command_to_uv_pip_install() {
7826        assert_eq!(
7827            SRBNOrchestrator::normalize_command_to_uv("pip install httpx"),
7828            "uv add httpx"
7829        );
7830        assert_eq!(
7831            SRBNOrchestrator::normalize_command_to_uv("pip3 install httpx pydantic"),
7832            "uv add httpx pydantic"
7833        );
7834        assert_eq!(
7835            SRBNOrchestrator::normalize_command_to_uv("python -m pip install requests"),
7836            "uv add requests"
7837        );
7838        assert_eq!(
7839            SRBNOrchestrator::normalize_command_to_uv("python3 -m pip install flask"),
7840            "uv add flask"
7841        );
7842    }
7843
7844    #[test]
7845    fn test_normalize_command_to_uv_requirements_file() {
7846        assert_eq!(
7847            SRBNOrchestrator::normalize_command_to_uv("pip install -r requirements.txt"),
7848            "uv pip install -r requirements.txt"
7849        );
7850    }
7851
7852    #[test]
7853    fn test_normalize_command_to_uv_passthrough() {
7854        // Already uv commands pass through unchanged
7855        assert_eq!(
7856            SRBNOrchestrator::normalize_command_to_uv("uv add httpx"),
7857            "uv add httpx"
7858        );
7859        // Non-Python commands pass through unchanged
7860        assert_eq!(
7861            SRBNOrchestrator::normalize_command_to_uv("cargo add serde"),
7862            "cargo add serde"
7863        );
7864        assert_eq!(
7865            SRBNOrchestrator::normalize_command_to_uv("npm install lodash"),
7866            "npm install lodash"
7867        );
7868    }
7869
7870    #[test]
7871    fn test_extract_commands_from_correction_includes_uv() {
7872        let response = r#"Here's the fix:
7873Commands:
7874```
7875uv add httpx
7876uv add --dev pytest
7877cargo add serde
7878pip install numpy
7879```
7880File: main.py
7881```python
7882import httpx
7883```"#;
7884        let commands = SRBNOrchestrator::extract_commands_from_correction(response);
7885        assert!(
7886            commands.contains(&"uv add httpx".to_string()),
7887            "{:?}",
7888            commands
7889        );
7890        assert!(
7891            commands.contains(&"cargo add serde".to_string()),
7892            "{:?}",
7893            commands
7894        );
7895        assert!(
7896            commands.contains(&"pip install numpy".to_string()),
7897            "{:?}",
7898            commands
7899        );
7900    }
7901
7902    #[test]
7903    fn test_extract_all_code_blocks_multiple_files() {
7904        let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
7905        let content = r#"Here are the files:
7906
7907File: src/etl_pipeline/core.py
7908```python
7909def run_pipeline():
7910    pass
7911```
7912
7913File: src/etl_pipeline/validator.py
7914```python
7915def validate(data):
7916    return True
7917```
7918
7919File: tests/test_core.py
7920```python
7921from etl_pipeline.core import run_pipeline
7922
7923def test_run():
7924    run_pipeline()
7925```
7926"#;
7927        let blocks = orch.extract_all_code_blocks_from_response(content);
7928        assert_eq!(blocks.len(), 3, "Expected 3 blocks, got {:?}", blocks);
7929        assert_eq!(blocks[0].0, "src/etl_pipeline/core.py");
7930        assert_eq!(blocks[1].0, "src/etl_pipeline/validator.py");
7931        assert_eq!(blocks[2].0, "tests/test_core.py");
7932        assert!(!blocks[0].2, "core.py should not be a diff");
7933    }
7934
7935    #[test]
7936    fn test_extract_all_code_blocks_single_file() {
7937        let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
7938        let content = r#"File: main.py
7939```python
7940print("hello")
7941```"#;
7942        let blocks = orch.extract_all_code_blocks_from_response(content);
7943        assert_eq!(blocks.len(), 1);
7944        assert_eq!(blocks[0].0, "main.py");
7945    }
7946
7947    #[test]
7948    fn test_extract_all_code_blocks_mixed_file_and_diff() {
7949        let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
7950        let content = r#"File: new_module.py
7951```python
7952def new_fn():
7953    pass
7954```
7955
7956Diff: existing.py
7957```diff
7958--- existing.py
7959+++ existing.py
7960@@ -1 +1,2 @@
7961+import new_module
7962 def old_fn():
7963```"#;
7964        let blocks = orch.extract_all_code_blocks_from_response(content);
7965        assert_eq!(blocks.len(), 2);
7966        assert_eq!(blocks[0].0, "new_module.py");
7967        assert!(!blocks[0].2, "new_module.py should be a write");
7968        assert_eq!(blocks[1].0, "existing.py");
7969        assert!(blocks[1].2, "existing.py should be a diff");
7970    }
7971
7972    #[test]
7973    fn test_parse_artifact_bundle_legacy_multi_file() {
7974        let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
7975        let content = r#"File: core.py
7976```python
7977def core():
7978    pass
7979```
7980
7981File: utils.py
7982```python
7983def util():
7984    pass
7985```"#;
7986        let bundle = orch.parse_artifact_bundle(content);
7987        assert!(bundle.is_some(), "Should parse multi-file legacy response");
7988        let bundle = bundle.unwrap();
7989        assert_eq!(bundle.artifacts.len(), 2, "Should have 2 artifacts");
7990        assert_eq!(bundle.artifacts[0].path(), "core.py");
7991        assert_eq!(bundle.artifacts[1].path(), "utils.py");
7992    }
7993}