use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use crate::config::constants::tools;
use crate::tools::handlers::planning_workflow::persistence::{
detect_validation_command_hints, initialize_plan_file, plan_file_baseline, plan_title_seed, resolve_plan_path,
};
use crate::tools::handlers::planning_workflow::state::PlanningWorkflowState;
use crate::tools::traits::Tool;
use crate::utils::file_utils::ensure_dir_exists;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct StartPlanningArgs {
#[serde(default)]
plan_name: Option<String>,
#[serde(default)]
plan_path: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
require_confirmation: bool,
#[serde(default)]
approved: bool,
}
pub struct StartPlanningTool {
state: PlanningWorkflowState,
}
impl StartPlanningTool {
pub fn new(state: PlanningWorkflowState) -> Self {
Self { state }
}
fn generate_plan_name(&self, provided: Option<&str>) -> String {
match provided {
Some(name) => {
name.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() || c == '-' { c } else { '-' })
.collect()
}
None => {
vtcode_commons::slug::create_timestamped()
}
}
}
}
fn title_from_plan_name(plan_name: &str) -> String {
plan_name
.split('-')
.filter(|segment| !segment.is_empty())
.map(|segment| {
let mut chars = segment.chars();
match chars.next() {
Some(first) => {
format!("{}{}", first.to_ascii_uppercase(), chars.as_str().to_ascii_lowercase())
}
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn resolve_plan_file_target(
workspace_root: &Path,
requested_path: Option<&str>,
existing_plan_file: Option<&Path>,
default_plan_file: PathBuf,
fallback_plan_name: &str,
) -> (PathBuf, String) {
if let Some(raw_path) = requested_path {
let resolved = resolve_plan_path(workspace_root, raw_path);
let seed = plan_title_seed(&resolved, fallback_plan_name);
return (resolved, seed);
}
if let Some(existing_plan_file) = existing_plan_file {
let resolved = existing_plan_file.to_path_buf();
let seed = plan_title_seed(&resolved, fallback_plan_name);
return (resolved, seed);
}
(default_plan_file, fallback_plan_name.to_string())
}
#[async_trait]
impl Tool for StartPlanningTool {
async fn execute(&self, args: Value) -> Result<Value> {
let args: StartPlanningArgs = serde_json::from_value(args).unwrap_or(StartPlanningArgs {
plan_name: None,
description: None,
plan_path: None,
require_confirmation: false,
approved: false,
});
let workspace_root = self.state.workspace_root().unwrap_or_else(|| PathBuf::from("."));
let validation_workspace = workspace_root.clone();
let validation_hints =
tokio::task::spawn_blocking(move || detect_validation_command_hints(&validation_workspace))
.await
.context("Validation command discovery task panicked")?;
if self.state.is_active() {
let fallback_plan_name = self.generate_plan_name(args.plan_name.as_deref());
let existing_plan_file = self.state.get_plan_file().await;
let existing_plan_file_exists = match existing_plan_file.as_ref() {
Some(path) => tokio::fs::try_exists(path).await.unwrap_or(false),
None => false,
};
if existing_plan_file_exists {
return Ok(json!({
"status": "already_active",
"message": "Planning workflow is already active. Continue with your planning workflow.",
"plan_file": existing_plan_file.map(|p| p.display().to_string())
}));
}
let (plan_file, plan_title_seed) = resolve_plan_file_target(
&workspace_root,
args.plan_path.as_deref(),
existing_plan_file.as_deref(),
self.state.plans_dir().join(format!("{fallback_plan_name}.md")),
&fallback_plan_name,
);
let plan_title = title_from_plan_name(&plan_title_seed);
if let Some(parent) = plan_file.parent() {
ensure_dir_exists(parent)
.await
.with_context(|| format!("Failed to create plan directory: {}", parent.display()))?;
}
let mut created_plan_file = false;
if !tokio::fs::try_exists(&plan_file).await.unwrap_or(false) {
created_plan_file = true;
initialize_plan_file(&plan_file, &plan_title, args.description.as_deref(), &validation_hints).await?;
}
self.state.set_plan_file(Some(plan_file.clone())).await;
let baseline = plan_file_baseline(&plan_file).await;
self.state.set_plan_baseline(Some(baseline)).await;
let message = if created_plan_file {
"Planning workflow is already active. Initialized plan file for planning workflow."
} else {
"Planning workflow is already active. Using existing plan file for planning workflow."
};
return Ok(json!({
"status": "already_active",
"message": message,
"plan_file": plan_file.display().to_string()
}));
}
let plan_name = self.generate_plan_name(args.plan_name.as_deref());
let (plan_file, plan_title_seed) = resolve_plan_file_target(
&workspace_root,
args.plan_path.as_deref(),
None,
self.state.plans_dir().join(format!("{plan_name}.md")),
&plan_name,
);
let plan_title = title_from_plan_name(&plan_title_seed);
if args.require_confirmation && !args.approved {
return Ok(json!({
"status": "pending_confirmation",
"requires_confirmation": true,
"message": "Planning workflow entry requires user confirmation.",
"plan_file": plan_file.display().to_string(),
"plan_title": plan_title.clone(),
"description": args.description,
}));
}
self.state.enable();
if let Some(parent) = plan_file.parent() {
ensure_dir_exists(parent)
.await
.with_context(|| format!("Failed to create plan directory: {}", parent.display()))?;
}
initialize_plan_file(&plan_file, &plan_title, args.description.as_deref(), &validation_hints).await?;
self.state.set_plan_file(Some(plan_file.clone())).await;
let baseline = plan_file_baseline(&plan_file).await;
self.state.set_plan_baseline(Some(baseline)).await;
Ok(json!({
"status": "success",
"message": "Entered Planning workflow. Mutating actions are disabled for exploration and planning.",
"plan_file": plan_file.display().to_string(),
"instructions": [
"1. Explore files and capture repository facts before drafting the plan",
"2. Ask or close only material blocking decisions",
"3. Emit one compact <proposed_plan> spec (fit ~1500 tokens; steps as `Action -> files/symbols -> verify:`, file:symbol refs over prose) and persist it to the plan file"
],
"workflow_phases": [
"Phase A: Explore facts",
"Phase B: Close open decisions",
"Phase C: Draft one proposed plan"
]
}))
}
fn name(&self) -> &str {
tools::START_PLANNING
}
fn description(&self) -> &str {
"Suggest Planning workflow for demanding, ambiguous, or multi-phase tasks. The user must confirm entry; after confirmation, use exec_command for read-only inspection and search while mutating tools such as apply_patch remain blocked, then emit one compact <proposed_plan> for review. Do NOT call this for straightforward changes or when the implementation is already clear."
}
fn parameter_schema(&self) -> Option<Value> {
Some(json!({
"type": "object",
"properties": {
"plan_name": {
"type": "string",
"description": "Optional name for the plan file (e.g., 'add-auth-middleware'). Defaults to timestamp-based name."
},
"plan_path": {
"type": "string",
"description": "Optional explicit plan file path. Use this to persist plans in a custom workspace path instead of the default .vtcode/plans location."
},
"description": {
"type": "string",
"description": "Optional initial description of what you're planning to implement."
}
},
"required": []
}))
}
fn is_mutating(&self) -> bool {
false }
fn is_parallel_safe(&self) -> bool {
false }
}