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 PlanningWorkflowState {
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 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 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 StartPlanningArgs {
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 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 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: &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 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 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 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 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 }
1162
1163 fn is_parallel_safe(&self) -> bool {
1164 false }
1166}
1167
1168#[derive(Debug, Clone, Serialize, Deserialize)]
1174pub struct FinishPlanningArgs {
1175 #[serde(default)]
1177 pub reason: Option<String>,
1178}
1179
1180pub 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 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 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.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 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 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#[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 assert!(!state.is_active());
1439
1440 let result = tool
1442 .execute(json!({
1443 "plan_name": "test-plan",
1444 "description": "Test planning"
1445 }))
1446 .await
1447 .unwrap();
1448
1449 assert!(state.is_active());
1451 assert_eq!(result["status"], "success");
1452
1453 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 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 let result = tool
1544 .execute(json!({
1545 "reason": "planning complete"
1546 }))
1547 .await
1548 .unwrap();
1549
1550 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 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 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}