Skip to main content

vtcode_core/tools/handlers/
plan_mode.rs

1//! Plan mode tools for entering, exiting, and managing planning workflow
2//!
3//! These tools allow the agent to programmatically enter and exit plan mode.
4//! The agent can:
5//! - Enter plan mode to switch to read-only exploration
6//! - Exit plan mode (triggering plan review) to start implementation
7//! - Persist canonical plans under `.vtcode/plans/` by default (with optional custom path)
8//!
9//! Based on insights from Claude Code's plan mode implementation:
10//! - Plan files are written to a dedicated directory
11//! - The agent edits its own plan file during planning
12//! - Exiting plan mode reads the plan file and starts execution
13//! - User confirmation is required before transitioning to execution (HITL)
14
15use crate::config::constants::tools;
16use crate::utils::file_utils::{
17    ensure_dir_exists, read_file_with_context, write_file_with_context,
18};
19use anyhow::{Context, Result};
20use async_trait::async_trait;
21use serde::{Deserialize, Serialize};
22use serde_json::{Value, json};
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::time::SystemTime;
27
28use crate::tools::traits::Tool;
29use crate::ui::tui::PlanContent;
30
31const PLAN_TRACKER_START: &str = "<!-- vtcode:plan-tracker:start -->";
32const PLAN_TRACKER_END: &str = "<!-- vtcode:plan-tracker:end -->";
33
34const REQUIRED_PLAN_SECTIONS: [&str; 4] = [
35    "Summary",
36    "Implementation Steps",
37    "Test Cases and Validation",
38    "Assumptions and Defaults",
39];
40
41const PLACEHOLDER_TOKENS: [&str; 14] = [
42    "[step]",
43    "[paths]",
44    "[check]",
45    "[explicit assumption]",
46    "[default chosen when user did not specify]",
47    "[out-of-scope items intentionally not changed]",
48    "[file, symbol, or behavior confirmed from the repo]",
49    "[existing pattern or constraint verified before planning]",
50    "[if any], otherwise: no remaining scope decisions",
51    "[project build and lint command",
52    "[project test command",
53    "[2-4 lines: goal, user impact, what will change, what will not]",
54    "[explicit commands/manual checks]",
55    "[what must not break]",
56];
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59#[repr(u8)]
60pub enum PlanLifecyclePhase {
61    #[default]
62    Off = 0,
63    EnterPendingApproval = 1,
64    ActiveDrafting = 2,
65    InterviewPending = 3,
66    DraftReady = 4,
67    ReviewPending = 5,
68}
69
70impl PlanLifecyclePhase {
71    fn from_u8(value: u8) -> Self {
72        match value {
73            1 => Self::EnterPendingApproval,
74            2 => Self::ActiveDrafting,
75            3 => Self::InterviewPending,
76            4 => Self::DraftReady,
77            5 => Self::ReviewPending,
78            _ => Self::Off,
79        }
80    }
81}
82
83#[derive(Debug, Clone, Default, PartialEq, Eq)]
84pub struct PlanValidationReport {
85    pub missing_sections: Vec<String>,
86    pub placeholder_tokens: Vec<String>,
87    pub open_decisions: Vec<String>,
88    pub implementation_step_count: usize,
89    pub validation_item_count: usize,
90    pub assumption_count: usize,
91    pub summary_present: bool,
92}
93
94impl PlanValidationReport {
95    pub fn is_ready(&self) -> bool {
96        self.missing_sections.is_empty()
97            && self.placeholder_tokens.is_empty()
98            && self.open_decisions.is_empty()
99            && self.summary_present
100            && self.implementation_step_count > 0
101            && self.validation_item_count > 0
102            && self.assumption_count > 0
103    }
104}
105
106#[derive(Debug, Clone)]
107pub struct PersistedPlanDraft {
108    pub plan_file: PathBuf,
109    pub tracker_file: Option<PathBuf>,
110    pub validation: PlanValidationReport,
111}
112
113/// Shared state for plan mode across tools
114#[derive(Debug, Clone)]
115pub struct PlanModeState {
116    /// Whether plan mode is currently active
117    is_active: Arc<AtomicBool>,
118    /// Path to the current plan file (if any)
119    current_plan_file: Arc<tokio::sync::RwLock<Option<PathBuf>>>,
120    /// Baseline time to require plan updates after initial creation
121    plan_baseline: Arc<tokio::sync::RwLock<Option<SystemTime>>>,
122    /// Workspace root for plan directory
123    workspace_root: PathBuf,
124    /// Shared plan lifecycle phase for the current session.
125    lifecycle_phase: Arc<std::sync::atomic::AtomicU8>,
126}
127
128impl PlanModeState {
129    pub fn new(workspace_root: PathBuf) -> Self {
130        Self {
131            is_active: Arc::new(AtomicBool::new(false)),
132            current_plan_file: Arc::new(tokio::sync::RwLock::new(None)),
133            plan_baseline: Arc::new(tokio::sync::RwLock::new(None)),
134            workspace_root,
135            lifecycle_phase: Arc::new(std::sync::atomic::AtomicU8::new(
136                PlanLifecyclePhase::Off as u8,
137            )),
138        }
139    }
140
141    /// Check if plan mode is active
142    pub fn is_active(&self) -> bool {
143        self.is_active.load(Ordering::Relaxed)
144    }
145
146    /// Enable plan mode
147    pub fn enable(&self) {
148        self.is_active.store(true, Ordering::Relaxed);
149    }
150
151    /// Disable plan mode
152    pub fn disable(&self) {
153        self.is_active.store(false, Ordering::Relaxed);
154        self.set_phase(PlanLifecyclePhase::Off);
155    }
156
157    pub fn phase(&self) -> PlanLifecyclePhase {
158        PlanLifecyclePhase::from_u8(self.lifecycle_phase.load(Ordering::Relaxed))
159    }
160
161    pub fn set_phase(&self, phase: PlanLifecyclePhase) {
162        self.lifecycle_phase.store(phase as u8, Ordering::Relaxed);
163    }
164
165    /// Get the workspace root path
166    pub fn workspace_root(&self) -> Option<PathBuf> {
167        if self.workspace_root.as_os_str().is_empty() {
168            None
169        } else {
170            Some(self.workspace_root.clone())
171        }
172    }
173
174    /// Get the default plans directory path.
175    pub fn plans_dir(&self) -> PathBuf {
176        if self.workspace_root.as_os_str().is_empty() {
177            std::env::temp_dir()
178                .join("vtcode-plans")
179                .join(workspace_slug_for_tmp(&self.workspace_root))
180        } else {
181            self.workspace_root.join(".vtcode").join("plans")
182        }
183    }
184
185    /// Set the current plan file
186    pub async fn set_plan_file(&self, path: Option<PathBuf>) {
187        let mut guard = self.current_plan_file.write().await;
188        *guard = path;
189    }
190
191    /// Set the baseline time for plan readiness checks
192    pub async fn set_plan_baseline(&self, baseline: Option<SystemTime>) {
193        let mut guard = self.plan_baseline.write().await;
194        *guard = baseline;
195    }
196
197    /// Get the baseline time for plan readiness checks
198    pub async fn plan_baseline(&self) -> Option<SystemTime> {
199        *self.plan_baseline.read().await
200    }
201
202    /// Get the current plan file path
203    pub async fn get_plan_file(&self) -> Option<PathBuf> {
204        self.current_plan_file.read().await.clone()
205    }
206}
207
208// ============================================================================
209// Enter Plan Mode Tool
210// ============================================================================
211
212/// Arguments for entering plan mode
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct EnterPlanModeArgs {
215    /// Optional: Name for the plan file (defaults to timestamp-based name)
216    #[serde(default)]
217    pub plan_name: Option<String>,
218
219    /// Optional: Explicit output path for the plan file (absolute or workspace-relative)
220    #[serde(default)]
221    pub plan_path: Option<String>,
222
223    /// Optional: Initial description of what you're planning
224    #[serde(default)]
225    pub description: Option<String>,
226
227    /// Internal: when true, request confirmation instead of entering immediately.
228    #[serde(default)]
229    pub require_confirmation: bool,
230
231    /// Internal: confirmation has already been granted.
232    #[serde(default)]
233    pub approved: bool,
234}
235
236/// Tool for entering plan mode
237pub struct EnterPlanModeTool {
238    state: PlanModeState,
239}
240
241impl EnterPlanModeTool {
242    pub fn new(state: PlanModeState) -> Self {
243        Self { state }
244    }
245
246    fn generate_plan_name(&self, provided: Option<&str>) -> String {
247        match provided {
248            Some(name) => {
249                // Sanitize the name for filesystem
250                name.to_lowercase()
251                    .chars()
252                    .map(|c| {
253                        if c.is_alphanumeric() || c == '-' {
254                            c
255                        } else {
256                            '-'
257                        }
258                    })
259                    .collect()
260            }
261            None => {
262                // Generate human-readable slug with timestamp prefix
263                // Format: {timestamp_millis}-{adjective}-{noun} (e.g., "1768330644696-gentle-harbor")
264                // This follows the OpenCode pattern for memorable plan file names
265                vtcode_commons::slug::create_timestamped()
266            }
267        }
268    }
269}
270
271fn workspace_slug_for_tmp(workspace_root: &Path) -> String {
272    let fallback = "workspace".to_string();
273    let candidate = workspace_root
274        .file_name()
275        .and_then(|name| name.to_str())
276        .map(|name| name.to_string())
277        .unwrap_or(fallback);
278    let sanitized = candidate
279        .chars()
280        .map(|ch| {
281            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
282                ch
283            } else {
284                '-'
285            }
286        })
287        .collect::<String>();
288    if sanitized.trim_matches('-').is_empty() {
289        "workspace".to_string()
290    } else {
291        sanitized
292    }
293}
294
295fn title_from_plan_name(plan_name: &str) -> String {
296    plan_name
297        .split('-')
298        .filter(|segment| !segment.is_empty())
299        .map(|segment| {
300            let mut chars = segment.chars();
301            match chars.next() {
302                Some(first) => {
303                    format!(
304                        "{}{}",
305                        first.to_ascii_uppercase(),
306                        chars.as_str().to_ascii_lowercase()
307                    )
308                }
309                None => String::new(),
310            }
311        })
312        .collect::<Vec<_>>()
313        .join(" ")
314}
315
316pub fn tracker_file_for_plan_file(plan_file: &Path) -> Option<PathBuf> {
317    let stem = plan_file.file_stem()?.to_str()?;
318    Some(plan_file.with_file_name(format!("{stem}.tasks.md")))
319}
320
321pub fn plan_file_for_tracker_file(tracker_file: &Path) -> Option<PathBuf> {
322    let file_name = tracker_file.file_name()?.to_str()?;
323    let stem = file_name.strip_suffix(".tasks.md")?;
324    Some(tracker_file.with_file_name(format!("{stem}.md")))
325}
326
327fn strip_embedded_tracker(plan_content: &str) -> String {
328    let Some(start) = plan_content.find(PLAN_TRACKER_START) else {
329        return plan_content.trim().to_string();
330    };
331    let end = plan_content[start..]
332        .find(PLAN_TRACKER_END)
333        .map(|offset| start + offset + PLAN_TRACKER_END.len())
334        .unwrap_or(plan_content.len());
335    let mut merged = String::new();
336    merged.push_str(plan_content[..start].trim_end());
337    if !merged.is_empty() && !plan_content[end..].trim().is_empty() {
338        merged.push_str("\n\n");
339    }
340    merged.push_str(plan_content[end..].trim_start());
341    merged.trim().to_string()
342}
343
344fn extract_embedded_tracker(plan_content: &str) -> Option<String> {
345    let start = plan_content.find(PLAN_TRACKER_START)?;
346    let end = plan_content.find(PLAN_TRACKER_END)?;
347    if end <= start {
348        return None;
349    }
350    let content = plan_content[start + PLAN_TRACKER_START.len()..end].trim();
351    if content.is_empty() {
352        None
353    } else {
354        Some(content.to_string())
355    }
356}
357
358fn render_plan_with_tracker(plan_markdown: &str, tracker_markdown: Option<&str>) -> String {
359    let base_plan = strip_embedded_tracker(plan_markdown);
360    let Some(tracker_markdown) = tracker_markdown
361        .map(str::trim)
362        .filter(|value| !value.is_empty())
363    else {
364        return format!("{}\n", base_plan.trim_end());
365    };
366    format!(
367        "{}\n\n{}\n{}\n{}\n",
368        base_plan.trim_end(),
369        PLAN_TRACKER_START,
370        tracker_markdown,
371        PLAN_TRACKER_END
372    )
373}
374
375pub fn merge_plan_content(
376    plan_content: Option<String>,
377    tracker_content: Option<String>,
378) -> Option<String> {
379    match (plan_content, tracker_content) {
380        (Some(plan), tracker) => {
381            let plan_trimmed = strip_embedded_tracker(&plan);
382            if plan_trimmed.is_empty() {
383                return tracker
384                    .map(|content| content.trim().to_string())
385                    .filter(|content| !content.is_empty());
386            }
387            let embedded_tracker = extract_embedded_tracker(&plan);
388            let tracker_trimmed = tracker
389                .as_deref()
390                .map(str::trim)
391                .filter(|content| !content.is_empty())
392                .map(ToOwned::to_owned)
393                .or(embedded_tracker);
394            if let Some(tracker_trimmed) = tracker_trimmed {
395                Some(format!(
396                    "{}\n\n{}\n",
397                    plan_trimmed.trim_end(),
398                    tracker_trimmed.trim()
399                ))
400            } else {
401                Some(plan_trimmed.to_string())
402            }
403        }
404        (None, Some(tracker)) => {
405            let trimmed = tracker.trim();
406            if trimmed.is_empty() {
407                None
408            } else {
409                Some(trimmed.to_string())
410            }
411        }
412        (None, None) => None,
413    }
414}
415
416fn section_body(content: &str, header: &str) -> Option<String> {
417    let mut capture = false;
418    let mut lines = Vec::new();
419    for line in content.lines() {
420        let trimmed = line.trim();
421        if let Some(found) = trimmed.strip_prefix("## ") {
422            if capture {
423                break;
424            }
425            capture = found.trim().eq_ignore_ascii_case(header);
426            continue;
427        }
428        if capture {
429            lines.push(line.to_string());
430        }
431    }
432    let body = lines.join("\n").trim().to_string();
433    (!body.is_empty()).then_some(body)
434}
435
436fn meaningful_section_lines(body: &str) -> Vec<&str> {
437    body.lines()
438        .map(str::trim)
439        .filter(|line| {
440            !line.is_empty()
441                && !line.starts_with('>')
442                && !line.starts_with("<!--")
443                && *line != PLAN_TRACKER_START
444                && *line != PLAN_TRACKER_END
445        })
446        .collect()
447}
448
449fn is_numbered_line(line: &str) -> bool {
450    let mut seen_digit = false;
451    for ch in line.chars() {
452        if ch.is_ascii_digit() {
453            seen_digit = true;
454            continue;
455        }
456        return seen_digit && (ch == '.' || ch == ')');
457    }
458    false
459}
460
461fn find_placeholder_tokens(content: &str) -> Vec<String> {
462    let lower = content.to_ascii_lowercase();
463    PLACEHOLDER_TOKENS
464        .iter()
465        .filter(|token| lower.contains(**token))
466        .map(|token| token.to_string())
467        .collect()
468}
469
470fn find_open_decisions(content: &str) -> Vec<String> {
471    content
472        .lines()
473        .map(str::trim)
474        .filter(|line| !line.is_empty())
475        .filter(|line| {
476            let lower = line.to_ascii_lowercase();
477            lower.contains("next open decision")
478                && ![
479                    "none",
480                    "no remaining",
481                    "no further",
482                    "resolved",
483                    "closed",
484                    "n/a",
485                    "not applicable",
486                ]
487                .iter()
488                .any(|needle| lower.contains(needle))
489        })
490        .map(ToString::to_string)
491        .collect()
492}
493
494pub fn validate_plan_content(content: &str) -> PlanValidationReport {
495    let stripped = strip_embedded_tracker(content);
496    let mut report = PlanValidationReport {
497        placeholder_tokens: find_placeholder_tokens(&stripped),
498        open_decisions: find_open_decisions(&stripped),
499        ..PlanValidationReport::default()
500    };
501
502    let summary_body = section_body(&stripped, "Summary");
503    let implementation_body = section_body(&stripped, "Implementation Steps");
504    let validation_body = section_body(&stripped, "Test Cases and Validation");
505    let assumptions_body = section_body(&stripped, "Assumptions and Defaults");
506
507    for section in REQUIRED_PLAN_SECTIONS {
508        if section_body(&stripped, section).is_none() {
509            report.missing_sections.push(section.to_string());
510        }
511    }
512
513    if let Some(body) = summary_body.as_deref() {
514        report.summary_present = !meaningful_section_lines(body).is_empty();
515    }
516    if !report.summary_present && !report.missing_sections.iter().any(|s| s == "Summary") {
517        report.missing_sections.push("Summary".to_string());
518    }
519
520    if let Some(body) = implementation_body.as_deref() {
521        report.implementation_step_count = meaningful_section_lines(body)
522            .into_iter()
523            .filter(|line| is_numbered_line(line))
524            .count();
525    }
526    if report.implementation_step_count == 0
527        && !report
528            .missing_sections
529            .iter()
530            .any(|s| s == "Implementation Steps")
531    {
532        report
533            .missing_sections
534            .push("Implementation Steps".to_string());
535    }
536
537    if let Some(body) = validation_body.as_deref() {
538        report.validation_item_count = meaningful_section_lines(body)
539            .into_iter()
540            .filter(|line| is_numbered_line(line) || line.starts_with("- "))
541            .count();
542    }
543    if report.validation_item_count == 0
544        && !report
545            .missing_sections
546            .iter()
547            .any(|s| s == "Test Cases and Validation")
548    {
549        report
550            .missing_sections
551            .push("Test Cases and Validation".to_string());
552    }
553
554    if let Some(body) = assumptions_body.as_deref() {
555        report.assumption_count = meaningful_section_lines(body)
556            .into_iter()
557            .filter(|line| is_numbered_line(line) || line.starts_with("- "))
558            .count();
559    }
560    if report.assumption_count == 0
561        && !report
562            .missing_sections
563            .iter()
564            .any(|s| s == "Assumptions and Defaults")
565    {
566        report
567            .missing_sections
568            .push("Assumptions and Defaults".to_string());
569    }
570
571    report
572}
573
574fn parse_bracket_list(raw: &str) -> Vec<String> {
575    let trimmed = raw.trim().trim_start_matches('[').trim_end_matches(']');
576    trimmed
577        .split(',')
578        .map(str::trim)
579        .filter(|value| !value.is_empty())
580        .map(ToOwned::to_owned)
581        .collect()
582}
583
584fn tracker_has_progress_or_notes(tracker: &str) -> bool {
585    let lower = tracker.to_ascii_lowercase();
586    if lower.contains("## notes") {
587        return true;
588    }
589    ["[x]", "[~]", "[!]", "[/]"]
590        .iter()
591        .any(|marker| lower.contains(marker))
592}
593
594pub fn generate_tracker_markdown_from_plan(plan_markdown: &str) -> Option<String> {
595    let implementation = section_body(plan_markdown, "Implementation Steps")?;
596    let title = plan_markdown
597        .lines()
598        .find_map(|line| line.trim().strip_prefix("# ").map(str::trim))
599        .filter(|line| !line.is_empty())
600        .unwrap_or("Implementation Plan");
601
602    let mut items = Vec::new();
603    for line in implementation
604        .lines()
605        .map(str::trim)
606        .filter(|line| !line.is_empty())
607    {
608        if !is_numbered_line(line) {
609            continue;
610        }
611        let description = line
612            .split_once(['.', ')'])
613            .map(|(_, rest)| rest.trim())
614            .unwrap_or(line);
615        let segments = description.split("->").map(str::trim).collect::<Vec<_>>();
616        let main = segments.first().copied().unwrap_or_default();
617        if main.is_empty() {
618            continue;
619        }
620
621        let mut entry = format!("- [ ] {}\n", main);
622        for segment in segments.iter().skip(1) {
623            if let Some(files) = segment.strip_prefix("files:") {
624                let values = parse_bracket_list(files);
625                if !values.is_empty() {
626                    entry.push_str(&format!("  files: {}\n", values.join(", ")));
627                }
628                continue;
629            }
630            if let Some(outcome) = segment.strip_prefix("outcome:") {
631                let outcome = outcome.trim().trim_start_matches('[').trim_end_matches(']');
632                if !outcome.is_empty() {
633                    entry.push_str(&format!("  outcome: {}\n", outcome));
634                }
635                continue;
636            }
637            if let Some(verify) = segment.strip_prefix("verify:") {
638                let values = parse_bracket_list(verify);
639                if values.is_empty() {
640                    let trimmed = verify.trim();
641                    if !trimmed.is_empty() {
642                        entry.push_str(&format!("  verify: {}\n", trimmed));
643                    }
644                } else {
645                    for value in values {
646                        entry.push_str(&format!("  verify: {}\n", value));
647                    }
648                }
649            }
650        }
651        items.push(entry);
652    }
653
654    if items.is_empty() {
655        return None;
656    }
657
658    Some(format!(
659        "# {}\n\n## Plan of Work\n\n{}",
660        title,
661        items.concat().trim_end()
662    ))
663}
664
665async fn persist_global_tracker_if_missing(
666    workspace_root: &Path,
667    tracker_markdown: &str,
668) -> Result<()> {
669    if workspace_root.as_os_str().is_empty() {
670        return Ok(());
671    }
672    let task_file = workspace_root
673        .join(".vtcode")
674        .join("tasks")
675        .join("current_task.md");
676    if task_file.exists() {
677        return Ok(());
678    }
679    if let Some(parent) = task_file.parent() {
680        ensure_dir_exists(parent).await.with_context(|| {
681            format!(
682                "Failed to create task tracker directory: {}",
683                parent.display()
684            )
685        })?;
686    }
687    write_file_with_context(&task_file, tracker_markdown, "task checklist")
688        .await
689        .with_context(|| format!("Failed to write task checklist: {}", task_file.display()))?;
690    Ok(())
691}
692
693pub async fn sync_tracker_into_plan_file(plan_file: &Path, tracker_markdown: &str) -> Result<()> {
694    let plan_content = read_file_with_context(plan_file, "plan file")
695        .await
696        .with_context(|| format!("Failed to read plan file: {}", plan_file.display()))?;
697    let updated = render_plan_with_tracker(&plan_content, Some(tracker_markdown));
698    write_file_with_context(plan_file, &updated, "plan file")
699        .await
700        .with_context(|| format!("Failed to write plan file: {}", plan_file.display()))?;
701    Ok(())
702}
703
704pub async fn persist_plan_draft(
705    state: &PlanModeState,
706    plan_markdown: &str,
707) -> Result<PersistedPlanDraft> {
708    let plan_file = state
709        .get_plan_file()
710        .await
711        .context("No active plan file. Call enter_plan_mode first.")?;
712    let existing_plan = read_file_with_context(&plan_file, "plan file").await.ok();
713    let tracker_file = tracker_file_for_plan_file(&plan_file);
714    let (existing_tracker, tracker_from_sidecar) = if let Some(path) = tracker_file.as_ref() {
715        if path.exists() {
716            (
717                read_file_with_context(path, "plan tracker file").await.ok(),
718                true,
719            )
720        } else {
721            (
722                existing_plan
723                    .as_deref()
724                    .and_then(extract_embedded_tracker)
725                    .filter(|content| !content.trim().is_empty()),
726                false,
727            )
728        }
729    } else {
730        (
731            existing_plan
732                .as_deref()
733                .and_then(extract_embedded_tracker)
734                .filter(|content| !content.trim().is_empty()),
735            false,
736        )
737    };
738
739    let should_refresh_embedded = !tracker_from_sidecar
740        && existing_tracker
741            .as_deref()
742            .is_some_and(|tracker| !tracker_has_progress_or_notes(tracker));
743    let validation = validate_plan_content(plan_markdown);
744    let allow_tracker_generation =
745        validation.implementation_step_count > 0 && validation.placeholder_tokens.is_empty();
746    let generated_tracker = if allow_tracker_generation {
747        generate_tracker_markdown_from_plan(plan_markdown)
748    } else {
749        None
750    };
751    let tracker_to_persist = if should_refresh_embedded {
752        generated_tracker.or(existing_tracker.clone())
753    } else {
754        existing_tracker.clone().or(generated_tracker)
755    };
756    let canonical_plan = render_plan_with_tracker(plan_markdown, tracker_to_persist.as_deref());
757    write_file_with_context(&plan_file, &canonical_plan, "plan file")
758        .await
759        .with_context(|| format!("Failed to write plan file: {}", plan_file.display()))?;
760
761    if let (Some(path), Some(tracker_markdown)) =
762        (tracker_file.as_ref(), tracker_to_persist.as_deref())
763    {
764        if let Some(parent) = path.parent() {
765            ensure_dir_exists(parent).await.with_context(|| {
766                format!(
767                    "Failed to create plan tracker directory: {}",
768                    parent.display()
769                )
770            })?;
771        }
772        write_file_with_context(path, tracker_markdown, "plan tracker file")
773            .await
774            .with_context(|| format!("Failed to write plan tracker file: {}", path.display()))?;
775        let workspace_root = state.workspace_root().unwrap_or_default();
776        persist_global_tracker_if_missing(&workspace_root, tracker_markdown).await?;
777    }
778
779    Ok(PersistedPlanDraft {
780        plan_file,
781        tracker_file,
782        validation,
783    })
784}
785
786fn resolve_plan_file_target(
787    workspace_root: &Path,
788    requested_path: Option<&str>,
789    existing_plan_file: Option<&Path>,
790    default_plan_file: PathBuf,
791    fallback_plan_name: &str,
792) -> (PathBuf, String) {
793    if let Some(raw_path) = requested_path {
794        let resolved = resolve_plan_path(workspace_root, raw_path);
795        let seed = plan_title_seed(&resolved, fallback_plan_name);
796        return (resolved, seed);
797    }
798
799    if let Some(existing_plan_file) = existing_plan_file {
800        let resolved = existing_plan_file.to_path_buf();
801        let seed = plan_title_seed(&resolved, fallback_plan_name);
802        return (resolved, seed);
803    }
804
805    (default_plan_file, fallback_plan_name.to_string())
806}
807
808fn resolve_plan_path(workspace_root: &Path, raw_path: &str) -> PathBuf {
809    let trimmed = raw_path.trim();
810    if Path::new(trimmed).is_absolute() {
811        PathBuf::from(trimmed)
812    } else {
813        workspace_root.join(trimmed)
814    }
815}
816
817fn plan_title_seed(path: &Path, fallback_plan_name: &str) -> String {
818    path.file_stem()
819        .and_then(|stem| stem.to_str())
820        .map(|stem| stem.to_string())
821        .unwrap_or_else(|| fallback_plan_name.to_string())
822}
823
824async fn initialize_plan_file(
825    plan_file: &Path,
826    plan_title: &str,
827    description: Option<&str>,
828    validation_hints: &ValidationCommandHints,
829) -> Result<()> {
830    let initial_content =
831        render_initial_plan_file_content(plan_title, description, plan_file, validation_hints);
832    write_file_with_context(plan_file, &initial_content, "plan file")
833        .await
834        .with_context(|| format!("Failed to create plan file: {}", plan_file.display()))
835}
836
837async fn plan_file_baseline(plan_file: &Path) -> SystemTime {
838    tokio::fs::metadata(plan_file)
839        .await
840        .and_then(|meta| meta.modified())
841        .unwrap_or_else(|_| SystemTime::now())
842}
843
844fn render_initial_plan_file_content(
845    plan_title: &str,
846    description: Option<&str>,
847    plan_file: &Path,
848    validation_hints: &ValidationCommandHints,
849) -> String {
850    let mut content = format!("# {}\n\n", plan_title);
851    content.push_str("Status: drafting\n");
852    content.push_str(&format!(
853        "Created: {}\n",
854        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
855    ));
856    content.push_str(&format!("Plan file: `{}`\n", plan_file.display()));
857    if let Some(description) = description.map(str::trim).filter(|value| !value.is_empty()) {
858        content.push_str(&format!("Description: {}\n", description));
859    }
860    content.push('\n');
861    content.push_str("> Plan Mode is active. Research first, then materialize one concrete `<proposed_plan>` draft here.\n");
862    content.push_str(&format!(
863        "> Suggested validation defaults: build/lint {}; tests {}.\n",
864        validation_hints.build_and_lint, validation_hints.tests
865    ));
866    content
867}
868
869#[derive(Debug, Clone)]
870struct ValidationCommandHints {
871    build_and_lint: String,
872    tests: String,
873}
874
875fn package_manager_for_workspace(workspace_root: &Path) -> &'static str {
876    if workspace_root.join("pnpm-lock.yaml").exists() {
877        "pnpm"
878    } else if workspace_root.join("yarn.lock").exists() {
879        "yarn"
880    } else if workspace_root.join("bun.lockb").exists() || workspace_root.join("bun.lock").exists()
881    {
882        "bun"
883    } else {
884        "npm"
885    }
886}
887
888fn node_script_command(pm: &str, script: &str) -> String {
889    match pm {
890        "yarn" => format!("yarn {script}"),
891        "bun" => format!("bun run {script}"),
892        _ => format!("{pm} run {script}"),
893    }
894}
895
896fn package_json_has_script(workspace_root: &Path, script: &str) -> bool {
897    let path = workspace_root.join("package.json");
898    let Ok(content) = std::fs::read_to_string(path) else {
899        return false;
900    };
901    let Ok(json) = serde_json::from_str::<Value>(&content) else {
902        return false;
903    };
904    json.get("scripts")
905        .and_then(Value::as_object)
906        .is_some_and(|scripts| scripts.contains_key(script))
907}
908
909fn detect_validation_command_hints(workspace_root: &Path) -> ValidationCommandHints {
910    if workspace_root.join("Cargo.toml").exists() {
911        return ValidationCommandHints {
912            build_and_lint:
913                "`cargo check`; `cargo clippy --workspace --all-targets -- -D warnings`".to_string(),
914            tests: "`cargo test` (or `cargo nextest run` if nextest is configured)".to_string(),
915        };
916    }
917
918    if workspace_root.join("package.json").exists() {
919        let pm = package_manager_for_workspace(workspace_root);
920        let has_build = package_json_has_script(workspace_root, "build");
921        let has_lint = package_json_has_script(workspace_root, "lint");
922        let has_test = package_json_has_script(workspace_root, "test");
923
924        let build_and_lint = match (has_build, has_lint) {
925            (true, true) => format!(
926                "`{}`; `{}`",
927                node_script_command(pm, "build"),
928                node_script_command(pm, "lint")
929            ),
930            (true, false) => format!(
931                "`{}`; plus configured lint command for the workspace",
932                node_script_command(pm, "build")
933            ),
934            (false, true) => format!(
935                "`{}`; plus configured build/typecheck command for the workspace",
936                node_script_command(pm, "lint")
937            ),
938            (false, false) => {
939                format!("Use configured {pm} build/lint (or typecheck) scripts for this workspace")
940            }
941        };
942        let tests = if has_test {
943            format!("`{}`", node_script_command(pm, "test"))
944        } else {
945            format!("Use configured {pm} test command for this workspace")
946        };
947
948        return ValidationCommandHints {
949            build_and_lint,
950            tests,
951        };
952    }
953
954    if workspace_root.join("pyproject.toml").exists()
955        || workspace_root.join("requirements.txt").exists()
956        || workspace_root.join("setup.py").exists()
957    {
958        return ValidationCommandHints {
959            build_and_lint:
960                "`python -m compileall .`; run configured linter (for example `ruff check .`)"
961                    .to_string(),
962            tests: "`pytest`".to_string(),
963        };
964    }
965
966    if workspace_root.join("go.mod").exists() {
967        return ValidationCommandHints {
968            build_and_lint: "`go build ./...`; `go vet ./...`".to_string(),
969            tests: "`go test ./...`".to_string(),
970        };
971    }
972
973    if workspace_root.join("Makefile").exists() {
974        return ValidationCommandHints {
975            build_and_lint: "`make lint` (or `make build` if no lint target exists)".to_string(),
976            tests: "`make test`".to_string(),
977        };
978    }
979
980    ValidationCommandHints {
981        build_and_lint: "[project build and lint command(s)]".to_string(),
982        tests: "[project test command(s)]".to_string(),
983    }
984}
985
986#[async_trait]
987impl Tool for EnterPlanModeTool {
988    async fn execute(&self, args: Value) -> Result<Value> {
989        let args: EnterPlanModeArgs = serde_json::from_value(args).unwrap_or(EnterPlanModeArgs {
990            plan_name: None,
991            description: None,
992            plan_path: None,
993            require_confirmation: false,
994            approved: false,
995        });
996
997        let workspace_root = self
998            .state
999            .workspace_root()
1000            .unwrap_or_else(|| PathBuf::from("."));
1001        let validation_hints = detect_validation_command_hints(&workspace_root);
1002
1003        // Check if already in plan mode
1004        if self.state.is_active() {
1005            let fallback_plan_name = self.generate_plan_name(args.plan_name.as_deref());
1006            let existing_plan_file = self.state.get_plan_file().await;
1007            let existing_plan_file_exists = existing_plan_file
1008                .as_ref()
1009                .is_some_and(|path| path.exists());
1010
1011            if existing_plan_file_exists {
1012                return Ok(json!({
1013                    "status": "already_active",
1014                    "message": "Plan Mode is already active. Continue with your planning workflow.",
1015                    "plan_file": existing_plan_file.map(|p| p.display().to_string())
1016                }));
1017            }
1018
1019            let (plan_file, plan_title_seed) = resolve_plan_file_target(
1020                &workspace_root,
1021                args.plan_path.as_deref(),
1022                existing_plan_file.as_deref(),
1023                self.state
1024                    .plans_dir()
1025                    .join(format!("{}.md", fallback_plan_name)),
1026                &fallback_plan_name,
1027            );
1028            let plan_title = title_from_plan_name(&plan_title_seed);
1029
1030            if let Some(parent) = plan_file.parent() {
1031                ensure_dir_exists(parent).await.with_context(|| {
1032                    format!("Failed to create plan directory: {}", parent.display())
1033                })?;
1034            }
1035
1036            let mut created_plan_file = false;
1037            if !plan_file.exists() {
1038                created_plan_file = true;
1039                initialize_plan_file(
1040                    &plan_file,
1041                    &plan_title,
1042                    args.description.as_deref(),
1043                    &validation_hints,
1044                )
1045                .await?;
1046            }
1047
1048            self.state.set_plan_file(Some(plan_file.clone())).await;
1049            let baseline = plan_file_baseline(&plan_file).await;
1050            self.state.set_plan_baseline(Some(baseline)).await;
1051            self.state.set_phase(PlanLifecyclePhase::ActiveDrafting);
1052
1053            let message = if created_plan_file {
1054                "Plan Mode is already active. Initialized plan file for planning workflow."
1055            } else {
1056                "Plan Mode is already active. Using existing plan file for planning workflow."
1057            };
1058
1059            return Ok(json!({
1060                "status": "already_active",
1061                "message": message,
1062                "plan_file": plan_file.display().to_string()
1063            }));
1064        }
1065
1066        // Resolve target plan path. Defaults to .vtcode/plans/, but allows explicit custom location.
1067        let plan_name = self.generate_plan_name(args.plan_name.as_deref());
1068        let (plan_file, plan_title_seed) = resolve_plan_file_target(
1069            &workspace_root,
1070            args.plan_path.as_deref(),
1071            None,
1072            self.state.plans_dir().join(format!("{}.md", plan_name)),
1073            &plan_name,
1074        );
1075        let plan_title = title_from_plan_name(&plan_title_seed);
1076        if args.require_confirmation && !args.approved {
1077            self.state
1078                .set_phase(PlanLifecyclePhase::EnterPendingApproval);
1079            return Ok(json!({
1080                "status": "pending_confirmation",
1081                "requires_confirmation": true,
1082                "message": "Plan Mode entry requires user confirmation.",
1083                "plan_file": plan_file.display().to_string(),
1084                "plan_title": plan_title.clone(),
1085                "description": args.description,
1086            }));
1087        }
1088
1089        // Enable plan mode only after explicit approval.
1090        self.state.enable();
1091        self.state.set_phase(PlanLifecyclePhase::ActiveDrafting);
1092
1093        if let Some(parent) = plan_file.parent() {
1094            ensure_dir_exists(parent).await.with_context(|| {
1095                format!("Failed to create plan directory: {}", parent.display())
1096            })?;
1097        }
1098
1099        initialize_plan_file(
1100            &plan_file,
1101            &plan_title,
1102            args.description.as_deref(),
1103            &validation_hints,
1104        )
1105        .await?;
1106
1107        // Track the current plan file
1108        self.state.set_plan_file(Some(plan_file.clone())).await;
1109        let baseline = plan_file_baseline(&plan_file).await;
1110        self.state.set_plan_baseline(Some(baseline)).await;
1111
1112        Ok(json!({
1113            "status": "success",
1114            "message": "Entered Plan Mode. You are now in read-only mode for exploration and planning.",
1115            "plan_file": plan_file.display().to_string(),
1116            "instructions": [
1117                "1. Explore files and capture repository facts before drafting the plan",
1118                "2. Ask or close only material blocking decisions",
1119                "3. Emit one concrete <proposed_plan> draft and persist it to the plan file",
1120                "4. Use exit_plan_mode when ready for the user to review and approve"
1121            ],
1122            "workflow_phases": [
1123                "Phase A: Explore facts",
1124                "Phase B: Close open decisions",
1125                "Phase C: Draft one proposed plan"
1126            ]
1127        }))
1128    }
1129
1130    fn name(&self) -> &str {
1131        tools::ENTER_PLAN_MODE
1132    }
1133
1134    fn description(&self) -> &str {
1135        "Enter Plan Mode to switch to read-only exploration. In Plan Mode, you can only read files, search code, and write canonical plan artifacts. Use this when you need to understand requirements before making changes."
1136    }
1137
1138    fn parameter_schema(&self) -> Option<Value> {
1139        Some(json!({
1140            "type": "object",
1141            "properties": {
1142                "plan_name": {
1143                    "type": "string",
1144                    "description": "Optional name for the plan file (e.g., 'add-auth-middleware'). Defaults to timestamp-based name."
1145                },
1146                "plan_path": {
1147                    "type": "string",
1148                    "description": "Optional explicit plan file path. Use this to persist plans in a custom workspace path instead of the default .vtcode/plans location."
1149                },
1150                "description": {
1151                    "type": "string",
1152                    "description": "Optional initial description of what you're planning to implement."
1153                }
1154            },
1155            "required": []
1156        }))
1157    }
1158
1159    fn is_mutating(&self) -> bool {
1160        false // This is a mode switch, not a file mutation
1161    }
1162
1163    fn is_parallel_safe(&self) -> bool {
1164        false // Mode switches should be sequential
1165    }
1166}
1167
1168// ============================================================================
1169// Exit Plan Mode Tool
1170// ============================================================================
1171
1172/// Arguments for exiting plan mode
1173#[derive(Debug, Clone, Serialize, Deserialize)]
1174pub struct ExitPlanModeArgs {
1175    /// Optional: Reason for exiting (e.g., "planning complete", "need more info")
1176    #[serde(default)]
1177    pub reason: Option<String>,
1178}
1179
1180/// Tool for exiting plan mode
1181pub struct ExitPlanModeTool {
1182    state: PlanModeState,
1183}
1184
1185impl ExitPlanModeTool {
1186    pub fn new(state: PlanModeState) -> Self {
1187        Self { state }
1188    }
1189}
1190
1191#[async_trait]
1192impl Tool for ExitPlanModeTool {
1193    async fn execute(&self, args: Value) -> Result<Value> {
1194        let args: ExitPlanModeArgs =
1195            serde_json::from_value(args).unwrap_or(ExitPlanModeArgs { reason: None });
1196        let auto_trigger = args
1197            .reason
1198            .as_deref()
1199            .is_some_and(|reason| reason == "auto_trigger_on_plan_ready");
1200
1201        // Check if not in plan mode
1202        if !self.state.is_active() {
1203            return Ok(json!({
1204                "status": "not_active",
1205                "message": "Plan Mode is not currently active."
1206            }));
1207        }
1208
1209        // Get the current plan file
1210        let plan_file = self.state.get_plan_file().await;
1211        let plan_baseline = self.state.plan_baseline().await;
1212
1213        // Read the plan content if file exists
1214        let (raw_plan_content, plan_title) = if let Some(ref path) = plan_file {
1215            let title = path
1216                .file_stem()
1217                .and_then(|s| s.to_str())
1218                .unwrap_or("Implementation Plan")
1219                .to_string();
1220            match read_file_with_context(path, "plan file").await {
1221                Ok(content) => (Some(content), title),
1222                Err(_) => (None, title),
1223            }
1224        } else {
1225            (None, "Implementation Plan".to_string())
1226        };
1227
1228        // Merge optional plan task tracker sidecar content (if present) so the
1229        // confirmation modal and readiness checks see the full plan state.
1230        let tracker_file = plan_file
1231            .as_ref()
1232            .and_then(|path| tracker_file_for_plan_file(path));
1233        let tracker_content = if let Some(ref path) = tracker_file {
1234            if path.exists() {
1235                read_file_with_context(path, "plan tracker file").await.ok()
1236            } else {
1237                None
1238            }
1239        } else {
1240            None
1241        };
1242        let plan_content = merge_plan_content(raw_plan_content, tracker_content);
1243
1244        // Parse structured plan content for the confirmation dialog
1245        let structured_plan = plan_content.as_ref().map(|content| {
1246            PlanContent::from_markdown(
1247                plan_title.clone(),
1248                content,
1249                plan_file.as_ref().map(|p| p.display().to_string()),
1250            )
1251        });
1252
1253        let plan_validation = plan_content
1254            .as_deref()
1255            .map(validate_plan_content)
1256            .unwrap_or_default();
1257        let plan_ready = plan_validation.is_ready();
1258        let plan_recently_updated =
1259            if let (Some(path), Some(baseline)) = (plan_file.as_ref(), plan_baseline) {
1260                match tokio::fs::metadata(path)
1261                    .await
1262                    .and_then(|meta| meta.modified())
1263                {
1264                    Ok(modified) => modified > baseline,
1265                    Err(_) => false,
1266                }
1267            } else {
1268                true
1269            };
1270
1271        if !plan_ready || !plan_recently_updated {
1272            let mut blockers = Vec::new();
1273            if !plan_validation.missing_sections.is_empty() {
1274                blockers.push(format!(
1275                    "Missing or incomplete sections: {}",
1276                    plan_validation.missing_sections.join(", ")
1277                ));
1278            }
1279            if !plan_validation.placeholder_tokens.is_empty() {
1280                blockers.push(format!(
1281                    "Template placeholders still present: {}",
1282                    plan_validation.placeholder_tokens.join(", ")
1283                ));
1284            }
1285            if !plan_validation.open_decisions.is_empty() {
1286                blockers.push(format!(
1287                    "Open decisions remain: {}",
1288                    plan_validation.open_decisions.join(" | ")
1289                ));
1290            }
1291            if !plan_recently_updated {
1292                blockers
1293                    .push("Plan file has not been updated since entering Plan Mode.".to_string());
1294            }
1295            if auto_trigger {
1296                self.state.set_phase(PlanLifecyclePhase::ReviewPending);
1297                return Ok(json!({
1298                    "status": "pending_confirmation",
1299                    "message": "Plan draft is incomplete. Waiting for user confirmation before execution.",
1300                    "reason": args.reason,
1301                    "plan_file": plan_file.map(|p| p.display().to_string()),
1302                    "plan_tracker_file": tracker_file.map(|p| p.display().to_string()),
1303                    "plan_content": plan_content,
1304                    "validation": {
1305                        "missing_sections": plan_validation.missing_sections,
1306                        "placeholder_tokens": plan_validation.placeholder_tokens,
1307                        "open_decisions": plan_validation.open_decisions,
1308                        "implementation_step_count": plan_validation.implementation_step_count,
1309                        "validation_item_count": plan_validation.validation_item_count,
1310                        "assumption_count": plan_validation.assumption_count,
1311                    },
1312                    "blockers": blockers,
1313                    "requires_confirmation": true,
1314                    "draft_incomplete": true
1315                }));
1316            }
1317            return Ok(json!({
1318                "status": "not_ready",
1319                "message": "Plan not ready for confirmation. Persist a concrete plan with complete sections, no template placeholders, and no open decisions, then retry.",
1320                "reason": args.reason,
1321                "plan_file": plan_file.map(|p| p.display().to_string()),
1322                "plan_tracker_file": tracker_file.map(|p| p.display().to_string()),
1323                "plan_content": plan_content,
1324                "validation": {
1325                    "missing_sections": plan_validation.missing_sections,
1326                    "placeholder_tokens": plan_validation.placeholder_tokens,
1327                    "open_decisions": plan_validation.open_decisions,
1328                    "implementation_step_count": plan_validation.implementation_step_count,
1329                    "validation_item_count": plan_validation.validation_item_count,
1330                    "assumption_count": plan_validation.assumption_count,
1331                },
1332                "blockers": blockers,
1333                "requires_confirmation": false
1334            }));
1335        }
1336
1337        self.state.set_phase(PlanLifecyclePhase::ReviewPending);
1338
1339        // Build plan summary for JSON response
1340        let plan_summary = structured_plan.as_ref().map(|p| {
1341            json!({
1342                "title": p.title,
1343                "summary": p.summary,
1344                "total_steps": p.total_steps,
1345                "completed_steps": p.completed_steps,
1346                "progress_percent": p.progress_percent(),
1347                "phases": p.phases.iter().map(|phase| {
1348                    json!({
1349                        "name": phase.name,
1350                        "completed": phase.completed,
1351                        "steps": phase.steps.iter().map(|step| {
1352                            json!({
1353                                "number": step.number,
1354                                "description": step.description,
1355                                "completed": step.completed
1356                            })
1357                        }).collect::<Vec<_>>()
1358                    })
1359                }).collect::<Vec<_>>(),
1360                "open_questions": p.open_questions
1361            })
1362        });
1363
1364        // NOTE: The actual plan mode state transition is now handled by the caller
1365        // after the user confirms via the plan confirmation dialog.
1366        // We keep plan mode active until confirmation is received.
1367        // The caller should:
1368        // 1. Display the shared plan confirmation overlay
1369        // 2. Wait for user approval (PlanApproved action)
1370        // 3. Only then disable plan mode and enable edit tools
1371
1372        Ok(json!({
1373            "status": "pending_confirmation",
1374            "message": "Plan ready for review. Waiting for user confirmation before execution.",
1375            "reason": args.reason,
1376            "plan_file": plan_file.map(|p| p.display().to_string()),
1377            "plan_tracker_file": tracker_file.map(|p| p.display().to_string()),
1378            "plan_content": plan_content,
1379            "plan_summary": plan_summary,
1380            "next_steps": [
1381                "User will see the Implementation Blueprint panel",
1382                "User can choose: Execute or Stay in Plan Mode",
1383                "If approved, Plan Mode will be disabled and mutating tools will be enabled",
1384                "Execute the plan step by step after approval"
1385            ],
1386            "requires_confirmation": true,
1387            "draft_incomplete": false
1388        }))
1389    }
1390
1391    fn name(&self) -> &str {
1392        tools::EXIT_PLAN_MODE
1393    }
1394
1395    fn description(&self) -> &str {
1396        "Exit Plan Mode after finishing your plan. This signals that you're done planning and ready for user review. The plan file content will be shown to the user for approval. Only use this when the task requires planning implementation steps, not for research tasks."
1397    }
1398
1399    fn parameter_schema(&self) -> Option<Value> {
1400        Some(json!({
1401            "type": "object",
1402            "properties": {
1403                "reason": {
1404                    "type": "string",
1405                    "description": "Optional reason for exiting plan mode (e.g., 'planning complete', 'need clarification from user')"
1406                }
1407            },
1408            "required": []
1409        }))
1410    }
1411
1412    fn is_mutating(&self) -> bool {
1413        false
1414    }
1415
1416    fn is_parallel_safe(&self) -> bool {
1417        false
1418    }
1419}
1420
1421// ============================================================================
1422// Tests
1423// ============================================================================
1424
1425#[cfg(test)]
1426mod tests {
1427    use super::*;
1428    use tempfile::TempDir;
1429
1430    #[tokio::test]
1431    async fn test_enter_plan_mode() {
1432        let temp_dir = TempDir::new().unwrap();
1433        let state = PlanModeState::new(temp_dir.path().to_path_buf());
1434        let tool = EnterPlanModeTool::new(state.clone());
1435
1436        // Initially not in plan mode
1437        assert!(!state.is_active());
1438
1439        // Enter plan mode
1440        let result = tool
1441            .execute(json!({
1442                "plan_name": "test-plan",
1443                "description": "Test planning"
1444            }))
1445            .await
1446            .unwrap();
1447
1448        // Should be in plan mode now
1449        assert!(state.is_active());
1450        assert_eq!(result["status"], "success");
1451
1452        // Plan file should exist
1453        let plan_file = state.get_plan_file().await.unwrap();
1454        assert!(plan_file.exists());
1455        assert_eq!(
1456            plan_file,
1457            temp_dir
1458                .path()
1459                .join(".vtcode")
1460                .join("plans")
1461                .join("test-plan.md")
1462        );
1463
1464        let content = std::fs::read_to_string(&plan_file).unwrap();
1465        assert!(content.contains("# Test Plan"));
1466        assert!(content.contains("Status: drafting"));
1467        assert!(content.contains(&format!("Plan file: `{}`", plan_file.display())));
1468        assert!(content.contains("Description: Test planning"));
1469        assert!(!content.contains("Repository facts checked"));
1470        assert!(!content.contains("[Step]"));
1471        assert!(!content.contains("## Implementation Steps"));
1472    }
1473
1474    #[tokio::test]
1475    async fn test_enter_plan_mode_returns_pending_confirmation_when_requested() {
1476        let temp_dir = TempDir::new().unwrap();
1477        let state = PlanModeState::new(temp_dir.path().to_path_buf());
1478        let tool = EnterPlanModeTool::new(state.clone());
1479
1480        let result = tool
1481            .execute(json!({
1482                "plan_name": "confirm-me",
1483                "require_confirmation": true
1484            }))
1485            .await
1486            .unwrap();
1487
1488        assert_eq!(result["status"], "pending_confirmation");
1489        assert_eq!(result["requires_confirmation"], true);
1490        assert!(!state.is_active());
1491        assert_eq!(state.phase(), PlanLifecyclePhase::EnterPendingApproval);
1492        assert!(state.get_plan_file().await.is_none());
1493    }
1494
1495    #[test]
1496    fn test_detect_validation_hints_for_rust_workspace() {
1497        let temp_dir = TempDir::new().unwrap();
1498        std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]\nname='x'\n").unwrap();
1499
1500        let hints = detect_validation_command_hints(temp_dir.path());
1501        assert!(hints.build_and_lint.contains("cargo check"));
1502        assert!(hints.build_and_lint.contains("cargo clippy"));
1503        assert!(hints.tests.contains("cargo test"));
1504    }
1505
1506    #[test]
1507    fn test_detect_validation_hints_for_node_workspace() {
1508        let temp_dir = TempDir::new().unwrap();
1509        std::fs::write(
1510            temp_dir.path().join("package.json"),
1511            r#"{"name":"x","scripts":{"build":"tsc","lint":"eslint .","test":"vitest run"}}"#,
1512        )
1513        .unwrap();
1514        std::fs::write(temp_dir.path().join("pnpm-lock.yaml"), "lockfileVersion: 9").unwrap();
1515
1516        let hints = detect_validation_command_hints(temp_dir.path());
1517        assert!(hints.build_and_lint.contains("pnpm run build"));
1518        assert!(hints.build_and_lint.contains("pnpm run lint"));
1519        assert_eq!(hints.tests, "`pnpm run test`");
1520    }
1521
1522    #[tokio::test]
1523    async fn test_exit_plan_mode() {
1524        let temp_dir = TempDir::new().unwrap();
1525        let state = PlanModeState::new(temp_dir.path().to_path_buf());
1526
1527        // Set up plan mode
1528        state.enable();
1529        let plans_dir = state.plans_dir();
1530        std::fs::create_dir_all(&plans_dir).unwrap();
1531        let plan_file = plans_dir.join("test.md");
1532        std::fs::write(
1533            &plan_file,
1534            "# Test Plan\n\n## Summary\nTest summary\n\n## Implementation Steps\n1. Prepare the change -> files: [src/main.rs] -> verify: [cargo test]\n2. Ship the update -> files: [src/lib.rs] -> verify: [cargo check]\n\n## Test Cases and Validation\n1. Run `cargo test`\n2. Run `cargo check`\n\n## Assumptions and Defaults\n1. The current task scope stays unchanged during review.\n",
1535        )
1536        .unwrap();
1537        state.set_plan_file(Some(plan_file)).await;
1538
1539        let tool = ExitPlanModeTool::new(state.clone());
1540
1541        // Exit plan mode
1542        let result = tool
1543            .execute(json!({
1544                "reason": "planning complete"
1545            }))
1546            .await
1547            .unwrap();
1548
1549        // Plan mode should still be active - waiting for user confirmation (HITL)
1550        assert!(state.is_active());
1551        assert_eq!(result["status"], "pending_confirmation");
1552        assert!(result["requires_confirmation"].as_bool().unwrap());
1553        assert!(
1554            result["plan_content"]
1555                .as_str()
1556                .unwrap()
1557                .contains("Test Plan")
1558        );
1559        // Verify structured plan summary is included
1560        assert!(result["plan_summary"].is_object());
1561        let summary = &result["plan_summary"];
1562        assert!(summary["total_steps"].as_u64().unwrap_or_default() >= 2);
1563        assert_eq!(summary["completed_steps"], 0);
1564        assert_eq!(state.phase(), PlanLifecyclePhase::ReviewPending);
1565    }
1566
1567    #[tokio::test]
1568    async fn test_exit_plan_mode_merges_plan_tracker_sidecar_content() {
1569        let temp_dir = TempDir::new().unwrap();
1570        let state = PlanModeState::new(temp_dir.path().to_path_buf());
1571
1572        state.enable();
1573        let plans_dir = state.plans_dir();
1574        std::fs::create_dir_all(&plans_dir).unwrap();
1575        let plan_file = plans_dir.join("merge-test.md");
1576        std::fs::write(
1577            &plan_file,
1578            "# Test Plan\n\n## Summary\nMerge tracker sidecar into the canonical review artifact.\n\n## Implementation Steps\n1. Keep the base plan content -> files: [src/base.rs] -> verify: [cargo test]\n\n## Test Cases and Validation\n1. Run `cargo test`\n\n## Assumptions and Defaults\n1. Tracker sidecar content should remain visible during review.\n",
1579        )
1580        .unwrap();
1581        let tracker_file = plans_dir.join("merge-test.tasks.md");
1582        std::fs::write(
1583            &tracker_file,
1584            "# Updated Plan\n\n## Plan of Work\n- [~] Tracker step\n",
1585        )
1586        .unwrap();
1587        state.set_plan_file(Some(plan_file)).await;
1588
1589        let tool = ExitPlanModeTool::new(state.clone());
1590        let result = tool
1591            .execute(json!({ "reason": "merge test" }))
1592            .await
1593            .unwrap();
1594
1595        assert_eq!(result["status"], "pending_confirmation");
1596        assert_eq!(
1597            result["plan_tracker_file"],
1598            tracker_file.display().to_string()
1599        );
1600        let plan_content = result["plan_content"].as_str().unwrap_or_default();
1601        assert!(plan_content.contains("Keep the base plan content"));
1602        assert!(plan_content.contains("Tracker step"));
1603    }
1604
1605    #[tokio::test]
1606    async fn test_exit_plan_mode_not_ready_without_actionable_steps() {
1607        let temp_dir = TempDir::new().unwrap();
1608        let state = PlanModeState::new(temp_dir.path().to_path_buf());
1609
1610        state.enable();
1611        let plans_dir = state.plans_dir();
1612        std::fs::create_dir_all(&plans_dir).unwrap();
1613        let plan_file = plans_dir.join("test.md");
1614        std::fs::write(
1615            &plan_file,
1616            "# Test Plan\n\n## Plan of Work\n(Describe the sequence of edits and additions. For each edit, name the file and location.)\n",
1617        )
1618        .unwrap();
1619        state.set_plan_file(Some(plan_file)).await;
1620
1621        let tool = ExitPlanModeTool::new(state.clone());
1622        let result = tool.execute(json!({})).await.unwrap();
1623
1624        assert_eq!(result["status"], "not_ready");
1625        assert_eq!(result["requires_confirmation"], false);
1626        assert!(
1627            result["validation"]["missing_sections"]
1628                .as_array()
1629                .unwrap()
1630                .iter()
1631                .any(|value| value.as_str() == Some("Summary"))
1632        );
1633    }
1634
1635    #[tokio::test]
1636    async fn test_exit_plan_mode_auto_trigger_incomplete() {
1637        let temp_dir = TempDir::new().unwrap();
1638        let state = PlanModeState::new(temp_dir.path().to_path_buf());
1639
1640        state.enable();
1641        let plans_dir = state.plans_dir();
1642        std::fs::create_dir_all(&plans_dir).unwrap();
1643        let plan_file = plans_dir.join("draft.md");
1644        std::fs::write(&plan_file, "# Test Plan\n\n## Plan of Work\n- Draft step\n").unwrap();
1645        state.set_plan_file(Some(plan_file)).await;
1646
1647        let tool = ExitPlanModeTool::new(state.clone());
1648        let result = tool
1649            .execute(json!({ "reason": "auto_trigger_on_plan_ready" }))
1650            .await
1651            .unwrap();
1652
1653        assert_eq!(result["status"], "pending_confirmation");
1654        assert_eq!(result["requires_confirmation"], true);
1655        assert_eq!(result["draft_incomplete"], true);
1656        assert_eq!(state.phase(), PlanLifecyclePhase::ReviewPending);
1657    }
1658
1659    #[tokio::test]
1660    async fn test_exit_plan_mode_not_ready_when_plan_not_updated_since_baseline() {
1661        let temp_dir = TempDir::new().unwrap();
1662        let state = PlanModeState::new(temp_dir.path().to_path_buf());
1663        let tool = EnterPlanModeTool::new(state.clone());
1664
1665        let result = tool
1666            .execute(json!({ "plan_name": "baseline-test" }))
1667            .await
1668            .unwrap();
1669        assert_eq!(result["status"], "success");
1670
1671        let plan_file = state.get_plan_file().await.unwrap();
1672        std::fs::write(&plan_file, "# Test Plan\n\n## Plan of Work\n- Step one\n").unwrap();
1673
1674        // Reset baseline to simulate no updates after template creation.
1675        let baseline = std::fs::metadata(&plan_file)
1676            .and_then(|meta| meta.modified())
1677            .unwrap();
1678        state.set_plan_baseline(Some(baseline)).await;
1679
1680        let exit_tool = ExitPlanModeTool::new(state.clone());
1681        let exit_result = exit_tool.execute(json!({})).await.unwrap();
1682
1683        assert_eq!(exit_result["status"], "not_ready");
1684        assert_eq!(exit_result["requires_confirmation"], false);
1685    }
1686
1687    #[tokio::test]
1688    async fn test_already_in_plan_mode() {
1689        let temp_dir = TempDir::new().unwrap();
1690        let state = PlanModeState::new(temp_dir.path().to_path_buf());
1691        state.enable();
1692        let plans_dir = state.plans_dir();
1693        std::fs::create_dir_all(&plans_dir).unwrap();
1694        let plan_file = plans_dir.join("test.md");
1695        std::fs::write(&plan_file, "# Test Plan\n").unwrap();
1696        state.set_plan_file(Some(plan_file)).await;
1697
1698        let tool = EnterPlanModeTool::new(state);
1699        let result = tool.execute(json!({})).await.unwrap();
1700
1701        assert_eq!(result["status"], "already_active");
1702    }
1703
1704    #[tokio::test]
1705    async fn test_already_active_initializes_missing_plan_file() {
1706        let temp_dir = TempDir::new().unwrap();
1707        let state = PlanModeState::new(temp_dir.path().to_path_buf());
1708        state.enable();
1709
1710        let tool = EnterPlanModeTool::new(state.clone());
1711        let result = tool
1712            .execute(json!({
1713                "plan_name": "missing-plan"
1714            }))
1715            .await
1716            .unwrap();
1717
1718        assert_eq!(result["status"], "already_active");
1719        let plan_file = state
1720            .get_plan_file()
1721            .await
1722            .expect("plan file should be set");
1723        assert!(plan_file.exists());
1724        assert!(
1725            plan_file
1726                .file_name()
1727                .and_then(|name| name.to_str())
1728                .unwrap_or_default()
1729                .contains("missing-plan")
1730        );
1731    }
1732
1733    #[test]
1734    fn validate_plan_content_rejects_placeholder_template() {
1735        let report = validate_plan_content(
1736            r#"# Test Plan
1737
1738Repository facts checked:
1739- [file, symbol, or behavior confirmed from the repo]
1740
1741Next open decision: [if any], otherwise: No remaining scope decisions.
1742
1743## Summary
1744[2-4 lines: goal, user impact, what will change, what will not]
1745
1746## Implementation Steps
17471. [Step] -> files: [paths] -> verify: [check]
1748
1749## Test Cases and Validation
17501. Build and lint: [project build and lint command(s)]
1751
1752## Assumptions and Defaults
17531. [Explicit assumption]
1754"#,
1755        );
1756
1757        assert!(!report.is_ready());
1758        assert!(!report.placeholder_tokens.is_empty());
1759        assert!(
1760            report
1761                .placeholder_tokens
1762                .iter()
1763                .any(|token| token.contains("file, symbol"))
1764        );
1765    }
1766
1767    #[test]
1768    fn validate_plan_content_accepts_concrete_plan() {
1769        let report = validate_plan_content(
1770            r#"# Fix Plan Mode
1771
1772## Summary
1773Persist the reviewed plan draft and route execution through explicit approval.
1774
1775## Implementation Steps
17761. Add plan lifecycle state -> files: [vtcode-core/src/tools/handlers/plan_mode.rs] -> verify: [cargo test -p vtcode-core test_enter_plan_mode -- --nocapture]
17772. Gate plan entry with overlay approval -> files: [src/agent/runloop/unified/tool_pipeline/execution_plan_mode.rs] -> verify: [cargo test -p vtcode test_run_tool_call_prevalidated_allows_task_tracker_in_plan_mode -- --nocapture]
1778
1779## Test Cases and Validation
17801. Build and lint: cargo check
17812. Tests: cargo test -p vtcode-core test_enter_plan_mode -- --nocapture
1782
1783## Assumptions and Defaults
17841. Keep tracker sidecars for compatibility.
17852. Reuse the existing overlay infrastructure.
1786"#,
1787        );
1788
1789        assert!(report.is_ready());
1790    }
1791
1792    #[tokio::test]
1793    async fn persist_plan_draft_generates_tracker_and_global_task_file() {
1794        let temp_dir = TempDir::new().unwrap();
1795        let state = PlanModeState::new(temp_dir.path().to_path_buf());
1796        let tool = EnterPlanModeTool::new(state.clone());
1797        tool.execute(json!({"plan_name":"draft-sync","approved":true}))
1798            .await
1799            .unwrap();
1800
1801        let persisted = persist_plan_draft(
1802            &state,
1803            r#"# Draft Sync
1804
1805## Summary
1806Persist a concrete draft and seed tracker state.
1807
1808## Implementation Steps
18091. Persist the plan -> files: [vtcode-core/src/tools/handlers/plan_mode.rs] -> verify: [cargo test]
18102. Sync the tracker -> files: [vtcode-core/src/tools/handlers/task_tracker.rs] -> verify: [cargo test]
1811
1812## Test Cases and Validation
18131. Build and lint: cargo check
18142. Tests: cargo test
1815
1816## Assumptions and Defaults
18171. Keep task tracker mirrors.
1818"#,
1819        )
1820        .await
1821        .unwrap();
1822
1823        let tracker_file = persisted.tracker_file.expect("tracker file should exist");
1824        let plan_content = std::fs::read_to_string(&persisted.plan_file).unwrap();
1825        let tracker_content = std::fs::read_to_string(&tracker_file).unwrap();
1826        let global_task = std::fs::read_to_string(
1827            temp_dir
1828                .path()
1829                .join(".vtcode")
1830                .join("tasks")
1831                .join("current_task.md"),
1832        )
1833        .unwrap();
1834
1835        assert!(persisted.validation.is_ready());
1836        assert!(plan_content.contains(PLAN_TRACKER_START));
1837        assert!(plan_content.contains("Persist the plan"));
1838        assert!(tracker_content.contains("- [ ] Persist the plan"));
1839        assert!(global_task.contains("- [ ] Persist the plan"));
1840    }
1841}