Skip to main content

perspt_agent/orchestrator/
mod.rs

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