1use 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#[derive(Debug, Clone)]
115pub struct PlanModeState {
116 is_active: Arc<AtomicBool>,
118 current_plan_file: Arc<tokio::sync::RwLock<Option<PathBuf>>>,
120 plan_baseline: Arc<tokio::sync::RwLock<Option<SystemTime>>>,
122 workspace_root: PathBuf,
124 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 pub fn is_active(&self) -> bool {
143 self.is_active.load(Ordering::Relaxed)
144 }
145
146 pub fn enable(&self) {
148 self.is_active.store(true, Ordering::Relaxed);
149 }
150
151 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 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 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 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 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 pub async fn plan_baseline(&self) -> Option<SystemTime> {
199 *self.plan_baseline.read().await
200 }
201
202 pub async fn get_plan_file(&self) -> Option<PathBuf> {
204 self.current_plan_file.read().await.clone()
205 }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct EnterPlanModeArgs {
215 #[serde(default)]
217 pub plan_name: Option<String>,
218
219 #[serde(default)]
221 pub plan_path: Option<String>,
222
223 #[serde(default)]
225 pub description: Option<String>,
226
227 #[serde(default)]
229 pub require_confirmation: bool,
230
231 #[serde(default)]
233 pub approved: bool,
234}
235
236pub 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 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 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 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 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 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 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 }
1162
1163 fn is_parallel_safe(&self) -> bool {
1164 false }
1166}
1167
1168#[derive(Debug, Clone, Serialize, Deserialize)]
1174pub struct ExitPlanModeArgs {
1175 #[serde(default)]
1177 pub reason: Option<String>,
1178}
1179
1180pub 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 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 let plan_file = self.state.get_plan_file().await;
1211 let plan_baseline = self.state.plan_baseline().await;
1212
1213 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 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 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 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 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#[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 assert!(!state.is_active());
1438
1439 let result = tool
1441 .execute(json!({
1442 "plan_name": "test-plan",
1443 "description": "Test planning"
1444 }))
1445 .await
1446 .unwrap();
1447
1448 assert!(state.is_active());
1450 assert_eq!(result["status"], "success");
1451
1452 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 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 let result = tool
1543 .execute(json!({
1544 "reason": "planning complete"
1545 }))
1546 .await
1547 .unwrap();
1548
1549 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 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 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}