Skip to main content

vtcode_core/tools/handlers/
planning_workflow.rs

1//! Planning workflow tools for entering, exiting, and managing planning workflow
2//!
3//! These tools allow the agent to programmatically enter and exit planning workflow.
4//! The agent can:
5//! - Enter planning workflow to switch to read-only exploration
6//! - Exit planning workflow (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 planning workflow implementation:
10//! - Plan files are written to a dedicated directory
11//! - The agent edits its own plan file during planning
12//! - Exiting planning workflow 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 planning workflow across tools
114#[derive(Debug, Clone)]
115pub struct PlanningWorkflowState {
116    /// Whether planning workflow 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 PlanningWorkflowState {
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 planning workflow is active
142    pub fn is_active(&self) -> bool {
143        self.is_active.load(Ordering::Relaxed)
144    }
145
146    /// Enable planning workflow
147    pub fn enable(&self) {
148        self.is_active.store(true, Ordering::Relaxed);
149    }
150
151    /// Disable planning workflow
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 Planning workflow Tool
210// ============================================================================
211
212/// Arguments for entering planning workflow
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct StartPlanningArgs {
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 planning workflow
237pub struct StartPlanningTool {
238    state: PlanningWorkflowState,
239}
240
241impl StartPlanningTool {
242    pub fn new(state: PlanningWorkflowState) -> 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: &PlanningWorkflowState,
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 start_planning 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("> Planning workflow 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 StartPlanningTool {
988    async fn execute(&self, args: Value) -> Result<Value> {
989        let args: StartPlanningArgs = serde_json::from_value(args).unwrap_or(StartPlanningArgs {
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 planning workflow
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": "Planning workflow 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                "Planning workflow is already active. Initialized plan file for planning workflow."
1055            } else {
1056                "Planning workflow 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": "Planning workflow 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 planning workflow 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 Planning workflow. Mutating actions are disabled 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 finish_planning 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::START_PLANNING
1132    }
1133
1134    fn description(&self) -> &str {
1135        "Enter Planning workflow for read-safe exploration. In Planning workflow, 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 changes the planning permission state, not files.
1161    }
1162
1163    fn is_parallel_safe(&self) -> bool {
1164        false // Planning permission changes should be sequential.
1165    }
1166}
1167
1168// ============================================================================
1169// Exit Planning workflow Tool
1170// ============================================================================
1171
1172/// Arguments for exiting planning workflow
1173#[derive(Debug, Clone, Serialize, Deserialize)]
1174pub struct FinishPlanningArgs {
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 planning workflow
1181pub struct FinishPlanningTool {
1182    state: PlanningWorkflowState,
1183}
1184
1185impl FinishPlanningTool {
1186    pub fn new(state: PlanningWorkflowState) -> Self {
1187        Self { state }
1188    }
1189}
1190
1191#[async_trait]
1192impl Tool for FinishPlanningTool {
1193    async fn execute(&self, args: Value) -> Result<Value> {
1194        let args: FinishPlanningArgs =
1195            serde_json::from_value(args).unwrap_or(FinishPlanningArgs { 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 planning workflow
1202        if !self.state.is_active() {
1203            return Ok(json!({
1204                "status": "not_active",
1205                "message": "Planning workflow 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.push(
1293                    "Plan file has not been updated since entering Planning workflow.".to_string(),
1294                );
1295            }
1296            if auto_trigger {
1297                self.state.set_phase(PlanLifecyclePhase::ReviewPending);
1298                return Ok(json!({
1299                    "status": "pending_confirmation",
1300                    "message": "Plan draft is incomplete. Waiting for user confirmation before execution.",
1301                    "reason": args.reason,
1302                    "plan_file": plan_file.map(|p| p.display().to_string()),
1303                    "plan_tracker_file": tracker_file.map(|p| p.display().to_string()),
1304                    "plan_content": plan_content,
1305                    "validation": {
1306                        "missing_sections": plan_validation.missing_sections,
1307                        "placeholder_tokens": plan_validation.placeholder_tokens,
1308                        "open_decisions": plan_validation.open_decisions,
1309                        "implementation_step_count": plan_validation.implementation_step_count,
1310                        "validation_item_count": plan_validation.validation_item_count,
1311                        "assumption_count": plan_validation.assumption_count,
1312                    },
1313                    "blockers": blockers,
1314                    "requires_confirmation": true,
1315                    "draft_incomplete": true
1316                }));
1317            }
1318            return Ok(json!({
1319                "status": "not_ready",
1320                "message": "Plan not ready for confirmation. Persist a concrete plan with complete sections, no template placeholders, and no open decisions, then retry.",
1321                "reason": args.reason,
1322                "plan_file": plan_file.map(|p| p.display().to_string()),
1323                "plan_tracker_file": tracker_file.map(|p| p.display().to_string()),
1324                "plan_content": plan_content,
1325                "validation": {
1326                    "missing_sections": plan_validation.missing_sections,
1327                    "placeholder_tokens": plan_validation.placeholder_tokens,
1328                    "open_decisions": plan_validation.open_decisions,
1329                    "implementation_step_count": plan_validation.implementation_step_count,
1330                    "validation_item_count": plan_validation.validation_item_count,
1331                    "assumption_count": plan_validation.assumption_count,
1332                },
1333                "blockers": blockers,
1334                "requires_confirmation": false
1335            }));
1336        }
1337
1338        self.state.set_phase(PlanLifecyclePhase::ReviewPending);
1339
1340        // Build plan summary for JSON response
1341        let plan_summary = structured_plan.as_ref().map(|p| {
1342            json!({
1343                "title": p.title,
1344                "summary": p.summary,
1345                "total_steps": p.total_steps,
1346                "completed_steps": p.completed_steps,
1347                "progress_percent": p.progress_percent(),
1348                "phases": p.phases.iter().map(|phase| {
1349                    json!({
1350                        "name": phase.name,
1351                        "completed": phase.completed,
1352                        "steps": phase.steps.iter().map(|step| {
1353                            json!({
1354                                "number": step.number,
1355                                "description": step.description,
1356                                "completed": step.completed
1357                            })
1358                        }).collect::<Vec<_>>()
1359                    })
1360                }).collect::<Vec<_>>(),
1361                "open_questions": p.open_questions
1362            })
1363        });
1364
1365        // NOTE: The actual planning workflow state transition is now handled by the caller
1366        // after the user confirms via the plan confirmation dialog.
1367        // We keep planning workflow active until confirmation is received.
1368        // The caller should:
1369        // 1. Display the shared plan confirmation overlay
1370        // 2. Wait for user approval (PlanApproved action)
1371        // 3. Only then disable planning workflow and enable edit tools
1372
1373        Ok(json!({
1374            "status": "pending_confirmation",
1375            "message": "Plan ready for review. Waiting for user confirmation before execution.",
1376            "reason": args.reason,
1377            "plan_file": plan_file.map(|p| p.display().to_string()),
1378            "plan_tracker_file": tracker_file.map(|p| p.display().to_string()),
1379            "plan_content": plan_content,
1380            "plan_summary": plan_summary,
1381            "next_steps": [
1382                "User will see the Implementation Blueprint panel",
1383                "User can choose: Execute or Stay in Planning workflow",
1384                "If approved, Planning workflow will be disabled and mutating tools will be enabled",
1385                "Execute the plan step by step after approval"
1386            ],
1387            "requires_confirmation": true,
1388            "draft_incomplete": false
1389        }))
1390    }
1391
1392    fn name(&self) -> &str {
1393        tools::FINISH_PLANNING
1394    }
1395
1396    fn description(&self) -> &str {
1397        "Exit Planning workflow 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."
1398    }
1399
1400    fn parameter_schema(&self) -> Option<Value> {
1401        Some(json!({
1402            "type": "object",
1403            "properties": {
1404                "reason": {
1405                    "type": "string",
1406                    "description": "Optional reason for exiting planning workflow (e.g., 'planning complete', 'need clarification from user')"
1407                }
1408            },
1409            "required": []
1410        }))
1411    }
1412
1413    fn is_mutating(&self) -> bool {
1414        false
1415    }
1416
1417    fn is_parallel_safe(&self) -> bool {
1418        false
1419    }
1420}
1421
1422// ============================================================================
1423// Tests
1424// ============================================================================
1425
1426#[cfg(test)]
1427mod tests {
1428    use super::*;
1429    use tempfile::TempDir;
1430
1431    #[tokio::test]
1432    async fn test_start_planning() {
1433        let temp_dir = TempDir::new().unwrap();
1434        let state = PlanningWorkflowState::new(temp_dir.path().to_path_buf());
1435        let tool = StartPlanningTool::new(state.clone());
1436
1437        // Initially not in planning workflow
1438        assert!(!state.is_active());
1439
1440        // Enter planning workflow
1441        let result = tool
1442            .execute(json!({
1443                "plan_name": "test-plan",
1444                "description": "Test planning"
1445            }))
1446            .await
1447            .unwrap();
1448
1449        // Should be in planning workflow now
1450        assert!(state.is_active());
1451        assert_eq!(result["status"], "success");
1452
1453        // Plan file should exist
1454        let plan_file = state.get_plan_file().await.unwrap();
1455        assert!(plan_file.exists());
1456        assert_eq!(
1457            plan_file,
1458            temp_dir
1459                .path()
1460                .join(".vtcode")
1461                .join("plans")
1462                .join("test-plan.md")
1463        );
1464
1465        let content = std::fs::read_to_string(&plan_file).unwrap();
1466        assert!(content.contains("# Test Plan"));
1467        assert!(content.contains("Status: drafting"));
1468        assert!(content.contains(&format!("Plan file: `{}`", plan_file.display())));
1469        assert!(content.contains("Description: Test planning"));
1470        assert!(!content.contains("Repository facts checked"));
1471        assert!(!content.contains("[Step]"));
1472        assert!(!content.contains("## Implementation Steps"));
1473    }
1474
1475    #[tokio::test]
1476    async fn test_start_planning_returns_pending_confirmation_when_requested() {
1477        let temp_dir = TempDir::new().unwrap();
1478        let state = PlanningWorkflowState::new(temp_dir.path().to_path_buf());
1479        let tool = StartPlanningTool::new(state.clone());
1480
1481        let result = tool
1482            .execute(json!({
1483                "plan_name": "confirm-me",
1484                "require_confirmation": true
1485            }))
1486            .await
1487            .unwrap();
1488
1489        assert_eq!(result["status"], "pending_confirmation");
1490        assert_eq!(result["requires_confirmation"], true);
1491        assert!(!state.is_active());
1492        assert_eq!(state.phase(), PlanLifecyclePhase::EnterPendingApproval);
1493        assert!(state.get_plan_file().await.is_none());
1494    }
1495
1496    #[test]
1497    fn test_detect_validation_hints_for_rust_workspace() {
1498        let temp_dir = TempDir::new().unwrap();
1499        std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]\nname='x'\n").unwrap();
1500
1501        let hints = detect_validation_command_hints(temp_dir.path());
1502        assert!(hints.build_and_lint.contains("cargo check"));
1503        assert!(hints.build_and_lint.contains("cargo clippy"));
1504        assert!(hints.tests.contains("cargo test"));
1505    }
1506
1507    #[test]
1508    fn test_detect_validation_hints_for_node_workspace() {
1509        let temp_dir = TempDir::new().unwrap();
1510        std::fs::write(
1511            temp_dir.path().join("package.json"),
1512            r#"{"name":"x","scripts":{"build":"tsc","lint":"eslint .","test":"vitest run"}}"#,
1513        )
1514        .unwrap();
1515        std::fs::write(temp_dir.path().join("pnpm-lock.yaml"), "lockfileVersion: 9").unwrap();
1516
1517        let hints = detect_validation_command_hints(temp_dir.path());
1518        assert!(hints.build_and_lint.contains("pnpm run build"));
1519        assert!(hints.build_and_lint.contains("pnpm run lint"));
1520        assert_eq!(hints.tests, "`pnpm run test`");
1521    }
1522
1523    #[tokio::test]
1524    async fn test_finish_planning() {
1525        let temp_dir = TempDir::new().unwrap();
1526        let state = PlanningWorkflowState::new(temp_dir.path().to_path_buf());
1527
1528        // Set up planning workflow
1529        state.enable();
1530        let plans_dir = state.plans_dir();
1531        std::fs::create_dir_all(&plans_dir).unwrap();
1532        let plan_file = plans_dir.join("test.md");
1533        std::fs::write(
1534            &plan_file,
1535            "# 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",
1536        )
1537        .unwrap();
1538        state.set_plan_file(Some(plan_file)).await;
1539
1540        let tool = FinishPlanningTool::new(state.clone());
1541
1542        // Exit planning workflow
1543        let result = tool
1544            .execute(json!({
1545                "reason": "planning complete"
1546            }))
1547            .await
1548            .unwrap();
1549
1550        // Planning workflow should still be active - waiting for user confirmation (HITL)
1551        assert!(state.is_active());
1552        assert_eq!(result["status"], "pending_confirmation");
1553        assert!(result["requires_confirmation"].as_bool().unwrap());
1554        assert!(
1555            result["plan_content"]
1556                .as_str()
1557                .unwrap()
1558                .contains("Test Plan")
1559        );
1560        // Verify structured plan summary is included
1561        assert!(result["plan_summary"].is_object());
1562        let summary = &result["plan_summary"];
1563        assert!(summary["total_steps"].as_u64().unwrap_or_default() >= 2);
1564        assert_eq!(summary["completed_steps"], 0);
1565        assert_eq!(state.phase(), PlanLifecyclePhase::ReviewPending);
1566    }
1567
1568    #[tokio::test]
1569    async fn test_finish_planning_merges_plan_tracker_sidecar_content() {
1570        let temp_dir = TempDir::new().unwrap();
1571        let state = PlanningWorkflowState::new(temp_dir.path().to_path_buf());
1572
1573        state.enable();
1574        let plans_dir = state.plans_dir();
1575        std::fs::create_dir_all(&plans_dir).unwrap();
1576        let plan_file = plans_dir.join("merge-test.md");
1577        std::fs::write(
1578            &plan_file,
1579            "# 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",
1580        )
1581        .unwrap();
1582        let tracker_file = plans_dir.join("merge-test.tasks.md");
1583        std::fs::write(
1584            &tracker_file,
1585            "# Updated Plan\n\n## Plan of Work\n- [~] Tracker step\n",
1586        )
1587        .unwrap();
1588        state.set_plan_file(Some(plan_file)).await;
1589
1590        let tool = FinishPlanningTool::new(state.clone());
1591        let result = tool
1592            .execute(json!({ "reason": "merge test" }))
1593            .await
1594            .unwrap();
1595
1596        assert_eq!(result["status"], "pending_confirmation");
1597        assert_eq!(
1598            result["plan_tracker_file"],
1599            tracker_file.display().to_string()
1600        );
1601        let plan_content = result["plan_content"].as_str().unwrap_or_default();
1602        assert!(plan_content.contains("Keep the base plan content"));
1603        assert!(plan_content.contains("Tracker step"));
1604    }
1605
1606    #[tokio::test]
1607    async fn test_finish_planning_not_ready_without_actionable_steps() {
1608        let temp_dir = TempDir::new().unwrap();
1609        let state = PlanningWorkflowState::new(temp_dir.path().to_path_buf());
1610
1611        state.enable();
1612        let plans_dir = state.plans_dir();
1613        std::fs::create_dir_all(&plans_dir).unwrap();
1614        let plan_file = plans_dir.join("test.md");
1615        std::fs::write(
1616            &plan_file,
1617            "# Test Plan\n\n## Plan of Work\n(Describe the sequence of edits and additions. For each edit, name the file and location.)\n",
1618        )
1619        .unwrap();
1620        state.set_plan_file(Some(plan_file)).await;
1621
1622        let tool = FinishPlanningTool::new(state.clone());
1623        let result = tool.execute(json!({})).await.unwrap();
1624
1625        assert_eq!(result["status"], "not_ready");
1626        assert_eq!(result["requires_confirmation"], false);
1627        assert!(
1628            result["validation"]["missing_sections"]
1629                .as_array()
1630                .unwrap()
1631                .iter()
1632                .any(|value| value.as_str() == Some("Summary"))
1633        );
1634    }
1635
1636    #[tokio::test]
1637    async fn test_finish_planning_auto_trigger_incomplete() {
1638        let temp_dir = TempDir::new().unwrap();
1639        let state = PlanningWorkflowState::new(temp_dir.path().to_path_buf());
1640
1641        state.enable();
1642        let plans_dir = state.plans_dir();
1643        std::fs::create_dir_all(&plans_dir).unwrap();
1644        let plan_file = plans_dir.join("draft.md");
1645        std::fs::write(&plan_file, "# Test Plan\n\n## Plan of Work\n- Draft step\n").unwrap();
1646        state.set_plan_file(Some(plan_file)).await;
1647
1648        let tool = FinishPlanningTool::new(state.clone());
1649        let result = tool
1650            .execute(json!({ "reason": "auto_trigger_on_plan_ready" }))
1651            .await
1652            .unwrap();
1653
1654        assert_eq!(result["status"], "pending_confirmation");
1655        assert_eq!(result["requires_confirmation"], true);
1656        assert_eq!(result["draft_incomplete"], true);
1657        assert_eq!(state.phase(), PlanLifecyclePhase::ReviewPending);
1658    }
1659
1660    #[tokio::test]
1661    async fn test_finish_planning_not_ready_when_plan_not_updated_since_baseline() {
1662        let temp_dir = TempDir::new().unwrap();
1663        let state = PlanningWorkflowState::new(temp_dir.path().to_path_buf());
1664        let tool = StartPlanningTool::new(state.clone());
1665
1666        let result = tool
1667            .execute(json!({ "plan_name": "baseline-test" }))
1668            .await
1669            .unwrap();
1670        assert_eq!(result["status"], "success");
1671
1672        let plan_file = state.get_plan_file().await.unwrap();
1673        std::fs::write(&plan_file, "# Test Plan\n\n## Plan of Work\n- Step one\n").unwrap();
1674
1675        // Reset baseline to simulate no updates after template creation.
1676        let baseline = std::fs::metadata(&plan_file)
1677            .and_then(|meta| meta.modified())
1678            .unwrap();
1679        state.set_plan_baseline(Some(baseline)).await;
1680
1681        let exit_tool = FinishPlanningTool::new(state.clone());
1682        let exit_result = exit_tool.execute(json!({})).await.unwrap();
1683
1684        assert_eq!(exit_result["status"], "not_ready");
1685        assert_eq!(exit_result["requires_confirmation"], false);
1686    }
1687
1688    #[tokio::test]
1689    async fn test_already_in_planning_workflow() {
1690        let temp_dir = TempDir::new().unwrap();
1691        let state = PlanningWorkflowState::new(temp_dir.path().to_path_buf());
1692        state.enable();
1693        let plans_dir = state.plans_dir();
1694        std::fs::create_dir_all(&plans_dir).unwrap();
1695        let plan_file = plans_dir.join("test.md");
1696        std::fs::write(&plan_file, "# Test Plan\n").unwrap();
1697        state.set_plan_file(Some(plan_file)).await;
1698
1699        let tool = StartPlanningTool::new(state);
1700        let result = tool.execute(json!({})).await.unwrap();
1701
1702        assert_eq!(result["status"], "already_active");
1703    }
1704
1705    #[tokio::test]
1706    async fn test_already_active_initializes_missing_plan_file() {
1707        let temp_dir = TempDir::new().unwrap();
1708        let state = PlanningWorkflowState::new(temp_dir.path().to_path_buf());
1709        state.enable();
1710
1711        let tool = StartPlanningTool::new(state.clone());
1712        let result = tool
1713            .execute(json!({
1714                "plan_name": "missing-plan"
1715            }))
1716            .await
1717            .unwrap();
1718
1719        assert_eq!(result["status"], "already_active");
1720        let plan_file = state
1721            .get_plan_file()
1722            .await
1723            .expect("plan file should be set");
1724        assert!(plan_file.exists());
1725        assert!(
1726            plan_file
1727                .file_name()
1728                .and_then(|name| name.to_str())
1729                .unwrap_or_default()
1730                .contains("missing-plan")
1731        );
1732    }
1733
1734    #[test]
1735    fn validate_plan_content_rejects_placeholder_template() {
1736        let report = validate_plan_content(
1737            r#"# Test Plan
1738
1739Repository facts checked:
1740- [file, symbol, or behavior confirmed from the repo]
1741
1742Next open decision: [if any], otherwise: No remaining scope decisions.
1743
1744## Summary
1745[2-4 lines: goal, user impact, what will change, what will not]
1746
1747## Implementation Steps
17481. [Step] -> files: [paths] -> verify: [check]
1749
1750## Test Cases and Validation
17511. Build and lint: [project build and lint command(s)]
1752
1753## Assumptions and Defaults
17541. [Explicit assumption]
1755"#,
1756        );
1757
1758        assert!(!report.is_ready());
1759        assert!(!report.placeholder_tokens.is_empty());
1760        assert!(
1761            report
1762                .placeholder_tokens
1763                .iter()
1764                .any(|token| token.contains("file, symbol"))
1765        );
1766    }
1767
1768    #[test]
1769    fn validate_plan_content_accepts_concrete_plan() {
1770        let report = validate_plan_content(
1771            r#"# Fix Planning workflow
1772
1773## Summary
1774Persist the reviewed plan draft and route execution through explicit approval.
1775
1776## Implementation Steps
17771. Add plan lifecycle state -> files: [vtcode-core/src/tools/handlers/planning_workflow.rs] -> verify: [cargo test -p vtcode-core test_start_planning -- --nocapture]
17782. Gate plan entry with overlay approval -> files: [src/agent/runloop/unified/tool_pipeline/execution_planning.rs] -> verify: [cargo test -p vtcode test_run_tool_call_prevalidated_allows_task_tracker_in_planning_workflow -- --nocapture]
1779
1780## Test Cases and Validation
17811. Build and lint: cargo check
17822. Tests: cargo test -p vtcode-core test_start_planning -- --nocapture
1783
1784## Assumptions and Defaults
17851. Keep tracker sidecars for compatibility.
17862. Reuse the existing overlay infrastructure.
1787"#,
1788        );
1789
1790        assert!(report.is_ready());
1791    }
1792
1793    #[tokio::test]
1794    async fn persist_plan_draft_generates_tracker_and_global_task_file() {
1795        let temp_dir = TempDir::new().unwrap();
1796        let state = PlanningWorkflowState::new(temp_dir.path().to_path_buf());
1797        let tool = StartPlanningTool::new(state.clone());
1798        tool.execute(json!({"plan_name":"draft-sync","approved":true}))
1799            .await
1800            .unwrap();
1801
1802        let persisted = persist_plan_draft(
1803            &state,
1804            r#"# Draft Sync
1805
1806## Summary
1807Persist a concrete draft and seed tracker state.
1808
1809## Implementation Steps
18101. Persist the plan -> files: [vtcode-core/src/tools/handlers/planning_workflow.rs] -> verify: [cargo test]
18112. Sync the tracker -> files: [vtcode-core/src/tools/handlers/task_tracker.rs] -> verify: [cargo test]
1812
1813## Test Cases and Validation
18141. Build and lint: cargo check
18152. Tests: cargo test
1816
1817## Assumptions and Defaults
18181. Keep task tracker mirrors.
1819"#,
1820        )
1821        .await
1822        .unwrap();
1823
1824        let tracker_file = persisted.tracker_file.expect("tracker file should exist");
1825        let plan_content = std::fs::read_to_string(&persisted.plan_file).unwrap();
1826        let tracker_content = std::fs::read_to_string(&tracker_file).unwrap();
1827        let global_task = std::fs::read_to_string(
1828            temp_dir
1829                .path()
1830                .join(".vtcode")
1831                .join("tasks")
1832                .join("current_task.md"),
1833        )
1834        .unwrap();
1835
1836        assert!(persisted.validation.is_ready());
1837        assert!(plan_content.contains(PLAN_TRACKER_START));
1838        assert!(plan_content.contains("Persist the plan"));
1839        assert!(tracker_content.contains("- [ ] Persist the plan"));
1840        assert!(global_task.contains("- [ ] Persist the plan"));
1841    }
1842}