Skip to main content

vtcode_core/tools/handlers/
planning_task_tracker.rs

1//! Planning workflow scoped task tracker persisted next to the active plan file.
2//!
3//! This tracker is intended for Planning workflow only and writes a sidecar markdown
4//! file next to the active plan file (`<plan>.tasks.md`).
5
6use super::planning_workflow::{PlanningWorkflowState, tracker_file_for_plan_file};
7use crate::config::constants::tools;
8use crate::tools::error_helpers::deserialize_tool_args;
9use crate::tools::handlers::task_tracking::{
10    TASK_ITEMS_DESCRIPTION, TaskCounts, TaskItemInput, TaskMetadataRef, TaskStepMetadata, TaskTrackingStatus,
11    TaskTreeNode, TrackerArtifact, append_notes, append_notes_section, append_task_step_metadata, apply_metadata_input,
12    apply_metadata_line, compact_task_tree_view, is_bulk_sync_update, metadata_from_input, parse_marked_status_prefix,
13    parse_status_prefix, parse_task_items, persist_tracker_artifacts, task_files_property_schema,
14    task_items_array_schema, task_status_property_schema, task_verify_property_schema, tracker_schema_allof,
15    validate_action_index_fields, validate_update_shape,
16};
17use crate::tools::traits::Tool;
18use crate::utils::file_utils::read_file_with_context;
19use anyhow::{Context, Result, bail};
20use async_trait::async_trait;
21use serde::{Deserialize, Serialize};
22use serde_json::{Value, json};
23use std::fmt::Write as _;
24use std::path::{Path, PathBuf};
25use std::str::FromStr;
26use vtcode_commons::workspace_relative_display;
27
28type PlanTaskStatus = TaskTrackingStatus;
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31struct PlanTaskNode {
32    description: String,
33    status: PlanTaskStatus,
34    #[serde(default, flatten)]
35    metadata: TaskStepMetadata,
36    children: Vec<PlanTaskNode>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40struct PlanTaskDocument {
41    title: String,
42    items: Vec<PlanTaskNode>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    notes: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct PlanningTaskTrackerArgs {
49    /// Action to perform: create, update, list, add
50    pub action: String,
51
52    /// Title for the checklist (used with create)
53    #[serde(default)]
54    pub title: Option<String>,
55
56    /// Initial tasks for create
57    #[serde(default)]
58    pub items: Option<Vec<TaskItemInput>>,
59
60    /// Hierarchical index path (example: "2.1")
61    #[serde(default)]
62    pub index_path: Option<String>,
63
64    /// Flat index fallback for compatibility with task_tracker calls
65    #[serde(default)]
66    pub index: Option<usize>,
67
68    /// New status for update
69    #[serde(default)]
70    pub status: Option<String>,
71
72    /// Description for add
73    #[serde(default)]
74    pub description: Option<String>,
75
76    /// Optional file paths associated with a single add/update step
77    #[serde(default)]
78    pub files: Option<Vec<String>>,
79
80    /// Optional expected outcome associated with a single add/update step
81    #[serde(default)]
82    pub outcome: Option<String>,
83
84    /// Optional verification command or commands associated with a single add/update step
85    #[serde(
86        default,
87        deserialize_with = "crate::tools::handlers::task_tracking::deserialize_optional_string_list"
88    )]
89    pub verify: Option<Vec<String>>,
90
91    /// Parent path for add (example: "2")
92    #[serde(default)]
93    pub parent_index_path: Option<String>,
94
95    /// Optional notes to append
96    #[serde(default)]
97    pub notes: Option<String>,
98}
99
100#[derive(Debug, Clone)]
101struct FlatTaskLine {
102    level: usize,
103    status: PlanTaskStatus,
104    description: String,
105    metadata: TaskStepMetadata,
106}
107
108impl PlanTaskDocument {
109    fn to_markdown(&self) -> String {
110        let mut out = format!("# {}\n\n## Plan of Work\n\n", self.title);
111        write_markdown_nodes(&self.items, 0, &mut out);
112        append_notes_section(&mut out, self.notes.as_deref());
113        out
114    }
115
116    fn summary_json(&self) -> Value {
117        let mut counts = TaskCounts::default();
118        count_nodes(&self.items, &mut counts);
119
120        json!({
121            "title": self.title,
122            "total": counts.total,
123            "completed": counts.completed,
124            "in_progress": counts.in_progress,
125            "pending": counts.pending,
126            "blocked": counts.blocked,
127            "progress_percent": counts.progress_percent(),
128            "items": flatten_items_json(&self.items),
129            "notes": self.notes.clone(),
130        })
131    }
132
133    fn view_json(&self) -> Value {
134        json!({
135            "title": self.title,
136            "lines": compact_task_tree_view(&compact_view_nodes(&self.items, "")),
137        })
138    }
139}
140
141fn count_nodes(nodes: &[PlanTaskNode], counts: &mut TaskCounts) {
142    for node in nodes {
143        counts.add(&node.status);
144        count_nodes(&node.children, counts);
145    }
146}
147
148fn write_markdown_nodes(nodes: &[PlanTaskNode], level: usize, out: &mut String) {
149    let indent = "  ".repeat(level);
150    for node in nodes {
151        out.push_str(&indent);
152        out.push_str("- ");
153        out.push_str(node.status.plan_checkbox());
154        out.push(' ');
155        out.push_str(&node.description);
156        out.push('\n');
157        append_task_step_metadata(out, &indent, &node.metadata);
158        write_markdown_nodes(&node.children, level + 1, out);
159    }
160}
161
162fn flatten_items_json(nodes: &[PlanTaskNode]) -> Vec<Value> {
163    let mut items = Vec::new();
164    flatten_items_json_inner(nodes, "", 0, &mut items);
165    items
166}
167
168fn flatten_for_global_items(nodes: &[PlanTaskNode], level: usize, out: &mut Vec<FlatTaskLine>) {
169    for node in nodes {
170        out.push(FlatTaskLine {
171            level,
172            status: node.status,
173            description: node.description.clone(),
174            metadata: node.metadata.clone(),
175        });
176        flatten_for_global_items(&node.children, level + 1, out);
177    }
178}
179
180fn flatten_items_json_inner(nodes: &[PlanTaskNode], index_prefix: &str, level: usize, out: &mut Vec<Value>) {
181    for (idx, node) in nodes.iter().enumerate() {
182        let index_path = if index_prefix.is_empty() {
183            format!("{}", idx + 1)
184        } else {
185            format!("{index_prefix}.{}", idx + 1)
186        };
187        out.push(json!({
188            "index_path": index_path,
189            "description": node.description,
190            "status": node.status.as_str(),
191            "level": level,
192            "files": node.metadata.files.clone(),
193            "outcome": node.metadata.outcome.clone(),
194            "verify": node.metadata.verify.clone(),
195        }));
196        flatten_items_json_inner(&node.children, &index_path, level + 1, out);
197    }
198}
199
200fn compact_view_nodes(nodes: &[PlanTaskNode], index_prefix: &str) -> Vec<TaskTreeNode> {
201    nodes
202        .iter()
203        .enumerate()
204        .map(|(index, node)| {
205            let index_path = if index_prefix.is_empty() {
206                format!("{}", index + 1)
207            } else {
208                format!("{index_prefix}.{}", index + 1)
209            };
210            TaskTreeNode {
211                index_path: index_path.clone(),
212                description: node.description.clone(),
213                status: node.status,
214                metadata: node.metadata.clone(),
215                children: compact_view_nodes(&node.children, &index_path),
216            }
217        })
218        .collect()
219}
220
221fn parse_task_line(line: &str) -> Option<FlatTaskLine> {
222    let indent_spaces = line.chars().take_while(|c| *c == ' ').count();
223    let level = indent_spaces / 2;
224    let trimmed = line.trim_start();
225    let rest = trimmed
226        .strip_prefix("- ")
227        .or_else(|| trimmed.strip_prefix("* "))
228        .or_else(|| trimmed.strip_prefix("+ "))?;
229
230    let (status, description) = parse_marked_status_prefix(rest)?;
231    if description.trim().is_empty() {
232        return None;
233    }
234    Some(FlatTaskLine {
235        level,
236        status,
237        description: description.trim().to_string(),
238        metadata: TaskStepMetadata::default(),
239    })
240}
241
242fn build_tree_from_flat(lines: &[FlatTaskLine]) -> Vec<PlanTaskNode> {
243    let mut roots = Vec::<PlanTaskNode>::new();
244    let mut current_path = Vec::<usize>::new();
245    let mut previous_level = 0usize;
246
247    for line in lines {
248        let mut level = line.level;
249        if level > previous_level + 1 {
250            level = previous_level + 1;
251        }
252        while current_path.len() > level {
253            current_path.pop();
254        }
255        if level > current_path.len() {
256            level = current_path.len();
257        }
258
259        let node = PlanTaskNode {
260            description: line.description.clone(),
261            status: line.status,
262            metadata: line.metadata.clone(),
263            children: Vec::new(),
264        };
265
266        if level == 0 || current_path.is_empty() {
267            roots.push(node);
268            current_path.clear();
269            current_path.push(roots.len() - 1);
270            previous_level = 0;
271            continue;
272        }
273
274        if let Some(parent) = get_node_mut_by_indices(&mut roots, &current_path) {
275            parent.children.push(node);
276            let child_idx = parent.children.len() - 1;
277            current_path.push(child_idx);
278        } else {
279            roots.push(node);
280            current_path.clear();
281            current_path.push(roots.len() - 1);
282        }
283
284        previous_level = level;
285    }
286
287    roots
288}
289
290fn get_node_mut_by_indices<'a>(nodes: &'a mut [PlanTaskNode], path: &[usize]) -> Option<&'a mut PlanTaskNode> {
291    let (&head, tail) = path.split_first()?;
292    let node = nodes.get_mut(head)?;
293    if tail.is_empty() {
294        Some(node)
295    } else {
296        get_node_mut_by_indices(node.children.as_mut_slice(), tail)
297    }
298}
299
300fn get_node_mut_by_index_path<'a>(nodes: &'a mut [PlanTaskNode], path: &[usize]) -> Option<&'a mut PlanTaskNode> {
301    let (&head, tail) = path.split_first()?;
302    let idx = head.checked_sub(1)?;
303    let node = nodes.get_mut(idx)?;
304    if tail.is_empty() {
305        Some(node)
306    } else {
307        get_node_mut_by_index_path(node.children.as_mut_slice(), tail)
308    }
309}
310
311fn parse_index_path(value: &str) -> Result<Vec<usize>> {
312    let trimmed = value.trim();
313    if trimmed.is_empty() {
314        bail!("index_path cannot be empty");
315    }
316
317    trimmed
318        .split('.')
319        .map(|token| {
320            let parsed = token
321                .parse::<usize>()
322                .with_context(|| format!("Invalid index component '{token}'"))?;
323            if parsed == 0 {
324                bail!("index_path components must be >= 1");
325            }
326            Ok(parsed)
327        })
328        .collect()
329}
330
331fn parse_document_from_markdown(content: &str) -> Option<PlanTaskDocument> {
332    let mut title = String::new();
333    let mut in_plan_section = false;
334    let mut in_notes = false;
335    let mut notes_lines = Vec::new();
336    let mut task_lines = Vec::<FlatTaskLine>::new();
337    let mut in_verify_block = false;
338
339    for raw in content.lines() {
340        let trimmed = raw.trim();
341
342        if title.is_empty()
343            && let Some(rest) = trimmed.strip_prefix("# ")
344        {
345            title = rest.trim().to_string();
346            continue;
347        }
348
349        if let Some(header) = trimmed.strip_prefix("## ") {
350            let lowered = header.trim().to_ascii_lowercase();
351            in_plan_section = matches!(lowered.as_str(), "plan of work" | "concrete steps" | "updated plan")
352                || lowered.starts_with("phase ");
353            in_notes = lowered == "notes";
354            continue;
355        }
356
357        if in_notes {
358            notes_lines.push(raw.to_string());
359            continue;
360        }
361
362        if in_plan_section {
363            if let Some(line) = parse_task_line(raw) {
364                task_lines.push(line);
365                in_verify_block = false;
366                continue;
367            }
368
369            if let Some(last) = task_lines.last_mut() {
370                let leading_spaces = raw.chars().take_while(|c| *c == ' ').count();
371                let min_indent = (last.level + 1) * 2;
372                if leading_spaces >= min_indent && apply_metadata_line(&mut last.metadata, raw, &mut in_verify_block) {
373                    continue;
374                }
375            }
376            in_verify_block = false;
377        }
378    }
379
380    if title.is_empty() && task_lines.is_empty() {
381        return None;
382    }
383
384    let notes = if notes_lines.is_empty() {
385        None
386    } else {
387        Some(notes_lines.join("\n").trim().to_string())
388    };
389    let items = build_tree_from_flat(&task_lines);
390
391    Some(PlanTaskDocument { title, items, notes })
392}
393
394fn collect_task_metadata<'a>(nodes: &'a [PlanTaskNode], metadata: &mut Vec<TaskMetadataRef<'a>>) {
395    for node in nodes {
396        metadata.push(TaskMetadataRef {
397            description: &node.description,
398            metadata: &node.metadata,
399        });
400        collect_task_metadata(&node.children, metadata);
401    }
402}
403
404fn build_flat_task_lines(items: &[TaskItemInput], previous: &[PlanTaskNode]) -> Result<Vec<FlatTaskLine>> {
405    let mut previous_metadata = Vec::new();
406    collect_task_metadata(previous, &mut previous_metadata);
407    Ok(parse_task_items(items, &previous_metadata)?
408        .into_iter()
409        .map(|item| FlatTaskLine {
410            level: item.level,
411            status: item.status,
412            description: item.description,
413            metadata: item.metadata,
414        })
415        .collect())
416}
417
418pub struct PlanningTaskTrackerTool {
419    state: PlanningWorkflowState,
420}
421
422pub(crate) fn planning_task_tracker_parameter_schema() -> Value {
423    json!({
424        "type": "object",
425        "properties": {
426            "action": {
427                "type": "string",
428                "enum": ["create", "update", "list", "add"],
429                "description": "Action to perform on the plan-scoped tracker."
430            },
431            "title": {
432                "type": "string",
433                "description": "Title for tracker document (used with create)."
434            },
435            "items": task_items_array_schema(&format!("{TASK_ITEMS_DESCRIPTION} Leading 2-space indentation in description indicates nesting.")),
436            "index_path": {
437                "type": "string",
438                "pattern": "^[1-9][0-9]*(\\.[1-9][0-9]*)*$",
439                "description": "Action=update only: positive flat or hierarchical item path (examples: '2' or '2.1'). Zero is not a valid planning index."
440            },
441            "index": {
442                "type": "integer",
443                "minimum": 1,
444                "description": "Action=update only: positive top-level item index compatibility fallback. Planning index 0 is invalid; use items for bulk updates."
445            },
446            "status": task_status_property_schema("New status for update."),
447            "description": {
448                "type": "string",
449                "description": "Task description for add. Optional prefix like '[x] ' or '[~] ' is supported."
450            },
451            "files": task_files_property_schema("Optional file paths associated with a single add/update item."),
452            "outcome": {
453                "type": "string",
454                "description": "Optional expected outcome associated with a single add/update item."
455            },
456            "verify": task_verify_property_schema("Optional verification command or commands associated with a single add/update item."),
457            "parent_index_path": {
458                "type": "string",
459                "description": "Optional parent path for add (example: '2'). If omitted, adds top-level task."
460            },
461            "notes": {
462                "type": "string",
463                "description": "Optional notes to append."
464            }
465        },
466        "required": ["action"],
467        "allOf": tracker_schema_allof(false)
468    })
469}
470
471impl PlanningTaskTrackerTool {
472    pub fn new(state: PlanningWorkflowState) -> Self {
473        Self { state }
474    }
475
476    fn display_path(&self, path: &Path) -> String {
477        self.state
478            .workspace_root()
479            .map(|workspace| workspace_relative_display(&workspace, path))
480            .unwrap_or_else(|| path.to_string_lossy().into_owned())
481    }
482
483    fn tracker_file_for_plan(plan_file: &Path) -> Result<PathBuf> {
484        tracker_file_for_plan_file(plan_file).context("Active plan file is missing a valid file stem")
485    }
486
487    async fn active_plan_file(&self) -> Result<PathBuf> {
488        if !self.state.is_active() {
489            bail!("task_tracker planning storage is only available while planning");
490        }
491        self.state
492            .get_plan_file()
493            .await
494            .context("No active plan file. Call start_planning first.")
495    }
496
497    async fn tracker_file(&self) -> Result<PathBuf> {
498        let plan_file = self.active_plan_file().await?;
499        Self::tracker_file_for_plan(&plan_file)
500    }
501
502    async fn load_document(&self) -> Result<Option<PlanTaskDocument>> {
503        let tracker_file = self.tracker_file().await?;
504        if !tokio::fs::try_exists(&tracker_file).await.unwrap_or(false) {
505            return Ok(None);
506        }
507        let content = read_file_with_context(&tracker_file, "plan task tracker file").await?;
508        Ok(parse_document_from_markdown(&content))
509    }
510
511    fn global_task_file(&self) -> Option<PathBuf> {
512        self.state
513            .workspace_root()
514            .map(|workspace| workspace.join(".vtcode").join("tasks").join("current_task.md"))
515    }
516
517    fn global_task_markdown(document: &PlanTaskDocument) -> String {
518        let mut lines = Vec::new();
519        flatten_for_global_items(&document.items, 0, &mut lines);
520
521        let mut markdown = format!("# {}\n\n", document.title);
522        for line in lines {
523            let _ =
524                writeln!(markdown, "- {} {}{}", line.status.flat_checkbox(), "  ".repeat(line.level), line.description);
525            append_task_step_metadata(&mut markdown, "", &line.metadata);
526        }
527        append_notes_section(&mut markdown, document.notes.as_deref());
528
529        markdown
530    }
531
532    fn success_payload(
533        &self,
534        status: &str,
535        message: String,
536        tracker_file: &Path,
537        document: &PlanTaskDocument,
538    ) -> Value {
539        json!({
540            "status": status,
541            "message": message,
542            "tracker_file": self.display_path(tracker_file),
543            "checklist": document.summary_json(),
544            "view": document.view_json(),
545        })
546    }
547
548    async fn persist_document_and_payload(
549        &self,
550        status: &str,
551        message: String,
552        document: &PlanTaskDocument,
553    ) -> Result<Value> {
554        let tracker_file = self.tracker_file().await?;
555        let markdown = document.to_markdown();
556        let mut artifacts =
557            vec![TrackerArtifact::new(tracker_file.clone(), markdown.clone(), "plan task tracker file").await?];
558        if let Some(task_file) = self.global_task_file() {
559            artifacts
560                .push(TrackerArtifact::new(task_file, Self::global_task_markdown(document), "task checklist").await?);
561        }
562        if let Some(plan_file) = self.state.get_plan_file().await
563            && let Some(artifact) = TrackerArtifact::embedded_plan(plan_file, &markdown).await?
564        {
565            artifacts.push(artifact);
566        }
567        persist_tracker_artifacts(artifacts).await?;
568        Ok(self.success_payload(status, message, &tracker_file, document))
569    }
570
571    async fn handle_create(&self, args: &PlanningTaskTrackerArgs) -> Result<Value> {
572        let items = args.items.as_deref().unwrap_or(&[]);
573        if items.is_empty() {
574            bail!("At least one item is required for 'create'. Provide items: [\"step 1\", \"step 2\", ...]");
575        }
576
577        let flat_lines = build_flat_task_lines(items, &[])?;
578        if flat_lines.is_empty() {
579            bail!("No valid task items were provided for create");
580        }
581
582        let mut document = PlanTaskDocument {
583            title: args.title.clone().unwrap_or_else(|| "Updated Plan".to_string()),
584            items: build_tree_from_flat(&flat_lines),
585            notes: None,
586        };
587        document.notes = append_notes(document.notes.take(), args.notes.as_deref());
588
589        self.persist_document_and_payload("created", "Plan task tracker created successfully.".to_string(), &document)
590            .await
591    }
592
593    async fn handle_update(&self, args: &PlanningTaskTrackerArgs) -> Result<Value> {
594        validate_update_shape(args.items.as_deref(), args.index, args.index_path.as_deref(), args.status.as_deref())?;
595        let is_bulk_update =
596            is_bulk_sync_update(args.items.as_deref(), args.index, args.index_path.as_deref(), args.status.as_deref());
597        let mut document = self
598            .load_document()
599            .await?
600            .context("No active plan tracker. Use action='create' first.")?;
601        let previous = document.clone();
602
603        if is_bulk_update {
604            let input_items = args.items.as_deref().unwrap_or(&[]);
605            let flat_lines = build_flat_task_lines(input_items, &document.items)?;
606            if flat_lines.is_empty() {
607                bail!("No valid items provided for checklist sync");
608            }
609            if let Some(title) = args.title.as_deref() {
610                document.title = title.to_string();
611            }
612            document.items = build_tree_from_flat(&flat_lines);
613            document.notes = append_notes(document.notes.take(), args.notes.as_deref());
614
615            return self
616                .persist_update(&previous, &document, "Checklist synchronized from provided items.".to_string())
617                .await;
618        }
619
620        let index_path = args
621            .index_path
622            .clone()
623            .or_else(|| args.index.map(|value| value.to_string()))
624            .context(
625                "'index_path' is required for 'update' (example: \"2.1\"), or provide 'index' for top-level compatibility",
626            )?;
627        let path = parse_index_path(&index_path)?;
628        let status_str = args
629            .status
630            .as_deref()
631            .context("'status' is required for 'update' (pending|in_progress|completed|blocked)")?;
632        let new_status = PlanTaskStatus::from_str(status_str)?;
633
634        let (old_status, new_status_str) = {
635            let node = get_node_mut_by_index_path(document.items.as_mut_slice(), &path)
636                .with_context(|| format!("No item at index_path '{index_path}'"))?;
637            let old_status = node.status.as_str().to_string();
638            node.status = new_status;
639            apply_metadata_input(
640                &mut node.metadata,
641                args.files.as_deref(),
642                args.outcome.as_deref(),
643                args.verify.as_deref(),
644            );
645            (old_status, node.status.as_str().to_string())
646        };
647
648        document.notes = append_notes(document.notes.take(), args.notes.as_deref());
649
650        self.persist_update(
651            &previous,
652            &document,
653            format!("Item {index_path} status changed: {old_status} -> {new_status_str}"),
654        )
655        .await
656    }
657
658    async fn persist_update(
659        &self,
660        previous: &PlanTaskDocument,
661        document: &PlanTaskDocument,
662        message: String,
663    ) -> Result<Value> {
664        let (status, message) = if previous == document {
665            ("unchanged", "Checklist already matches requested state.".to_string())
666        } else {
667            ("updated", message)
668        };
669        self.persist_document_and_payload(status, message, document).await
670    }
671
672    async fn handle_list(&self) -> Result<Value> {
673        let tracker_file = self.tracker_file().await?;
674        match self.load_document().await? {
675            Some(document) => {
676                Ok(self.success_payload("ok", "Plan task tracker loaded.".to_string(), &tracker_file, &document))
677            }
678            None => Ok(json!({
679                "status": "empty",
680                "message": "No active plan tracker. Use action='create' to start one.",
681                "tracker_file": self.display_path(&tracker_file),
682            })),
683        }
684    }
685
686    async fn handle_add(&self, args: &PlanningTaskTrackerArgs) -> Result<Value> {
687        let mut document = self
688            .load_document()
689            .await?
690            .context("No active plan tracker. Use action='create' first.")?;
691
692        let description = args.description.as_deref().context("'description' is required for 'add'")?;
693        let (status, parsed_description) = parse_status_prefix(description);
694        let node = PlanTaskNode {
695            description: parsed_description.trim().to_string(),
696            status,
697            metadata: metadata_from_input(args.files.as_deref(), args.outcome.as_deref(), args.verify.as_deref()),
698            children: Vec::new(),
699        };
700        if node.description.is_empty() {
701            bail!("description cannot be empty");
702        }
703
704        if let Some(parent_path_str) = args.parent_index_path.as_deref() {
705            let parent_path = parse_index_path(parent_path_str)?;
706            let parent = get_node_mut_by_index_path(document.items.as_mut_slice(), &parent_path)
707                .with_context(|| format!("No parent item at parent_index_path '{parent_path_str}'"))?;
708            parent.children.push(node);
709        } else {
710            document.items.push(node);
711        }
712
713        document.notes = append_notes(document.notes.take(), args.notes.as_deref());
714
715        self.persist_document_and_payload("added", "Plan task added successfully.".to_string(), &document)
716            .await
717    }
718}
719
720#[async_trait]
721impl Tool for PlanningTaskTrackerTool {
722    async fn execute(&self, args: Value) -> Result<Value> {
723        let args: PlanningTaskTrackerArgs = deserialize_tool_args(&args, "task_tracker")?;
724        validate_action_index_fields(&args.action, args.index, args.index_path.as_deref())?;
725
726        match args.action.as_str() {
727            "create" => self.handle_create(&args).await,
728            "update" => self.handle_update(&args).await,
729            "list" => self.handle_list().await,
730            "add" => self.handle_add(&args).await,
731            other => Ok(json!({
732                "status": "error",
733                "message": format!("Unknown action '{}'. Use: create, update, list, add", other),
734            })),
735        }
736    }
737
738    fn name(&self) -> &str {
739        tools::TASK_TRACKER
740    }
741
742    fn description(&self) -> &str {
743        super::task_tracker::task_tracker_description_for_workflow(true)
744    }
745
746    fn parameter_schema(&self) -> Option<Value> {
747        Some(planning_task_tracker_parameter_schema())
748    }
749
750    fn is_mutating(&self) -> bool {
751        false
752    }
753
754    fn is_parallel_safe(&self) -> bool {
755        false
756    }
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762    use tempfile::TempDir;
763
764    async fn setup_planning_workflow() -> (TempDir, PlanningWorkflowState, PlanningTaskTrackerTool) {
765        let temp_dir = TempDir::new().expect("temp dir");
766        let state = PlanningWorkflowState::new(temp_dir.path().to_path_buf());
767        let plans_dir = state.plans_dir();
768        std::fs::create_dir_all(&plans_dir).expect("create plans dir");
769        let plan_file = plans_dir.join("test-plan.md");
770        std::fs::write(&plan_file, "# Test Plan\n").expect("write plan");
771        state.set_plan_file(Some(plan_file)).await;
772        state.enable();
773
774        let tool = PlanningTaskTrackerTool::new(state.clone());
775        (temp_dir, state, tool)
776    }
777
778    #[tokio::test]
779    async fn create_and_list_tracker_with_hierarchy() {
780        let (_temp_dir, _state, tool) = setup_planning_workflow().await;
781
782        let created = tool
783            .execute(json!({
784                "action": "create",
785                "title": "Updated Plan",
786                "items": [
787                    "Add config cap",
788                    "  Use cap in guard logic",
789                    "[~] Expose setting in template"
790                ]
791            }))
792            .await
793            .expect("create tracker");
794
795        assert_eq!(created["status"], "created");
796        assert_eq!(created["checklist"]["total"], 3);
797        assert_eq!(created["checklist"]["in_progress"], 1);
798        assert_eq!(created["view"]["title"], "Updated Plan");
799        assert_eq!(created["tracker_file"], ".vtcode/plans/test-plan.tasks.md");
800
801        let lines = created["view"]["lines"].as_array().expect("view lines array");
802        assert!(!lines.is_empty());
803        let first = lines[0]["display"].as_str().unwrap_or_default();
804        assert!(first.contains('└') || first.contains('├'));
805    }
806
807    #[tokio::test]
808    async fn create_accepts_metadata_and_verify_string_forms() {
809        let (_temp_dir, _state, tool) = setup_planning_workflow().await;
810
811        let created = tool
812            .execute(json!({
813                "action": "create",
814                "title": "Harness plan",
815                "items": [
816                    {
817                        "description": "Analyze",
818                        "files": ["docs/ARCHITECTURE.md"],
819                        "outcome": "Map the harness",
820                        "verify": "cargo check"
821                    },
822                    {
823                        "description": "Implement",
824                        "verify": ["cargo test -p vtcode-core task_tracker", "cargo check -p vtcode"]
825                    }
826                ]
827            }))
828            .await
829            .expect("create tracker");
830
831        assert_eq!(created["checklist"]["items"][0]["files"], json!(["docs/ARCHITECTURE.md"]));
832        assert_eq!(created["checklist"]["items"][0]["outcome"], "Map the harness");
833        assert_eq!(created["checklist"]["items"][0]["verify"], json!(["cargo check"]));
834        assert_eq!(
835            created["checklist"]["items"][1]["verify"],
836            json!(["cargo test -p vtcode-core task_tracker", "cargo check -p vtcode"])
837        );
838    }
839
840    #[tokio::test]
841    async fn add_and_update_nested_item() {
842        let (_temp_dir, _state, tool) = setup_planning_workflow().await;
843
844        tool.execute(json!({
845            "action": "create",
846            "items": ["Parent task"]
847        }))
848        .await
849        .expect("create tracker");
850
851        tool.execute(json!({
852            "action": "add",
853            "parent_index_path": "1",
854            "description": "Child task"
855        }))
856        .await
857        .expect("add nested task");
858
859        let updated = tool
860            .execute(json!({
861                "action": "update",
862                "index_path": "1.1",
863                "status": "completed"
864            }))
865            .await
866            .expect("update nested task");
867
868        assert_eq!(updated["status"], "updated");
869        assert_eq!(updated["checklist"]["completed"], 1);
870    }
871
872    #[tokio::test]
873    async fn persistence_across_instances() {
874        let (_temp_dir, state, tool) = setup_planning_workflow().await;
875
876        tool.execute(json!({
877            "action": "create",
878            "items": ["Persisted step"]
879        }))
880        .await
881        .expect("create tracker");
882
883        tool.execute(json!({
884            "action": "update",
885            "index_path": "1",
886            "status": "completed"
887        }))
888        .await
889        .expect("update tracker");
890
891        let tool2 = PlanningTaskTrackerTool::new(state);
892        let listed = tool2.execute(json!({"action": "list"})).await.expect("list tracker");
893
894        assert_eq!(listed["status"], "ok");
895        assert_eq!(listed["checklist"]["completed"], 1);
896    }
897
898    #[tokio::test]
899    async fn update_supports_bulk_item_sync_and_global_mirror() {
900        let (temp_dir, _state, tool) = setup_planning_workflow().await;
901
902        tool.execute(json!({
903            "action": "create",
904            "items": ["Step 1", "Step 2"]
905        }))
906        .await
907        .expect("create tracker");
908
909        let updated = tool
910            .execute(json!({
911                "action": "update",
912                "items": ["[x] Step 1", "[~] Step 2", "[ ] Step 3"]
913            }))
914            .await
915            .expect("bulk update");
916
917        assert_eq!(updated["status"], "updated");
918        assert_eq!(updated["checklist"]["completed"], 1);
919        assert_eq!(updated["checklist"]["in_progress"], 1);
920        assert_eq!(updated["checklist"]["pending"], 1);
921
922        let mirrored = temp_dir.path().join(".vtcode").join("tasks").join("current_task.md");
923        let mirrored_content = std::fs::read_to_string(mirrored).expect("read mirrored checklist");
924        assert!(mirrored_content.contains("Step 3"));
925    }
926
927    #[tokio::test]
928    async fn update_accepts_flat_index_fallback() {
929        let (_temp_dir, _state, tool) = setup_planning_workflow().await;
930
931        tool.execute(json!({
932            "action": "create",
933            "items": ["Parent task"]
934        }))
935        .await
936        .expect("create tracker");
937
938        let updated = tool
939            .execute(json!({
940                "action": "update",
941                "index": 1,
942                "status": "completed"
943            }))
944            .await
945            .expect("flat-index update");
946
947        assert_eq!(updated["status"], "updated");
948        assert_eq!(updated["checklist"]["completed"], 1);
949    }
950
951    #[tokio::test]
952    async fn update_rejects_zero_flat_index_even_for_checklist_completion() {
953        let (_temp_dir, _state, tool) = setup_planning_workflow().await;
954
955        tool.execute(json!({
956            "action": "create",
957            "items": ["Parent task"]
958        }))
959        .await
960        .expect("create tracker");
961
962        let error = tool
963            .execute(json!({
964                "action": "update",
965                "index": 0,
966                "status": "completed"
967            }))
968            .await
969            .expect_err("planning index zero must be rejected");
970
971        assert!(error.to_string().contains("index_path components must be >= 1"));
972    }
973
974    #[tokio::test]
975    async fn planning_task_tracker_schema_requires_positive_indices() {
976        let (_temp_dir, _state, tool) = setup_planning_workflow().await;
977        let schema = tool.parameter_schema().expect("planning task tracker schema");
978
979        assert_eq!(schema["properties"]["index"]["minimum"], 1);
980        let items_description = schema["properties"]["items"]["description"].as_str().unwrap();
981        assert!(items_description.contains("Full checklist replacement"));
982        assert!(items_description.contains("never JSON-encoded strings"));
983        assert!(items_description.contains("2-space indentation"));
984        assert_eq!(schema["properties"]["index_path"]["pattern"], "^[1-9][0-9]*(\\.[1-9][0-9]*)*$");
985        assert!(
986            schema["properties"]["index_path"]["description"]
987                .as_str()
988                .expect("index path description")
989                .contains("positive")
990        );
991    }
992
993    #[tokio::test]
994    async fn planning_task_tracker_schema_rejects_mixed_bulk_and_single_updates() {
995        let (_temp_dir, _state, tool) = setup_planning_workflow().await;
996        let schema = tool.parameter_schema().expect("planning task tracker schema");
997        let invalid_cases = [
998            json!({"action": "list", "index": 1}),
999            json!({"action": "list", "index_path": "1"}),
1000            json!({"action": "create", "index": 1, "items": ["New item"]}),
1001            json!({"action": "add", "index_path": "1", "description": "New item"}),
1002            json!({"action": "update", "items": ["Done"], "index": 1, "status": "completed"}),
1003            json!({"action": "update", "items": ["Done"], "index_path": "1.1", "status": "completed"}),
1004            json!({"action": "update", "index": 1, "index_path": "1.1", "status": "completed"}),
1005            json!({"action": "update", "items": ["Done"], "status": "completed"}),
1006        ];
1007
1008        for args in invalid_cases {
1009            assert!(jsonschema::validate(&schema, &args).is_err(), "expected invalid args: {args}");
1010        }
1011    }
1012
1013    #[tokio::test]
1014    async fn bulk_update_rejects_encoded_commands_without_resetting_completed_steps() {
1015        let (_temp_dir, _state, tool) = setup_planning_workflow().await;
1016        tool.execute(json!({
1017            "action": "create",
1018            "title": "Preserve progress",
1019            "items": [
1020                {"description": "Update introduction", "status": "completed", "files": ["README.md"], "outcome": "Clear overview", "verify": ["review headings"]},
1021                {"description": "Review links", "status": "blocked", "outcome": "Await network"}
1022            ]
1023        })).await.unwrap();
1024        let tracker_path = _temp_dir.path().join(".vtcode/tasks/current_task.md");
1025        let persisted_before = std::fs::read_to_string(&tracker_path).unwrap();
1026        let before = tool.execute(json!({"action": "list"})).await.unwrap();
1027
1028        for (action, encoded) in [
1029            ("update", r#"{"index_path":"1","status":"completed","outcome":"Done","verify":"review headings"}"#),
1030            ("update", r#"  [x] {"index_path":"1","status":"completed"}"#),
1031            ("create", r#"{"index":1,"status":"completed"}"#),
1032        ] {
1033            let error = tool
1034                .execute(json!({
1035                    "action": action,
1036                    "title": "Incorrect replacement",
1037                    "items": ["Replacement must not leak", encoded]
1038                }))
1039                .await
1040                .expect_err("encoded updates must not replace the tracker");
1041            assert!(error.to_string().contains("JSON-encoded task updates"));
1042            assert!(
1043                error
1044                    .to_string()
1045                    .contains(r#"{"action":"update","index_path":"1","status":"completed"}"#)
1046            );
1047            assert_eq!(tool.execute(json!({"action": "list"})).await.unwrap(), before);
1048            assert_eq!(std::fs::read_to_string(&tracker_path).unwrap(), persisted_before);
1049        }
1050
1051        tool.execute(json!({"action": "update", "index_path": "2", "status": "completed"}))
1052            .await
1053            .unwrap();
1054        let after = tool.execute(json!({"action": "list"})).await.unwrap();
1055        assert_eq!(after["checklist"]["completed"], 2);
1056        assert_eq!(after["checklist"]["items"][0]["description"], "Update introduction");
1057        assert_eq!(after["checklist"]["items"][0]["files"], json!(["README.md"]));
1058    }
1059
1060    #[tokio::test]
1061    async fn planning_update_rejects_mixed_bulk_and_single_fields_before_mutation() {
1062        let (_temp_dir, _state, tool) = setup_planning_workflow().await;
1063
1064        tool.execute(json!({
1065            "action": "create",
1066            "items": ["Original"]
1067        }))
1068        .await
1069        .expect("create tracker");
1070
1071        let error = tool
1072            .execute(json!({
1073                "action": "update",
1074                "items": ["Replacement"],
1075                "index_path": "1",
1076                "status": "completed"
1077            }))
1078            .await
1079            .expect_err("mixed update must fail closed");
1080        assert!(error.to_string().contains("cannot combine 'items'"));
1081
1082        let result = tool.execute(json!({"action": "list"})).await.unwrap();
1083        assert_eq!(result["checklist"]["items"][0]["description"], "Original");
1084        assert_eq!(result["checklist"]["items"][0]["status"], "pending");
1085    }
1086
1087    #[tokio::test]
1088    async fn planning_update_rejects_both_index_forms_before_mutation() {
1089        let (_temp_dir, _state, tool) = setup_planning_workflow().await;
1090
1091        tool.execute(json!({
1092            "action": "create",
1093            "items": ["Original"]
1094        }))
1095        .await
1096        .expect("create tracker");
1097
1098        let error = tool
1099            .execute(json!({
1100                "action": "update",
1101                "index": 1,
1102                "index_path": "1",
1103                "status": "completed"
1104            }))
1105            .await
1106            .expect_err("ambiguous index forms must fail closed");
1107        assert!(error.to_string().contains("cannot combine 'index' and 'index_path'"));
1108
1109        let result = tool.execute(json!({"action": "list"})).await.unwrap();
1110        assert_eq!(result["checklist"]["items"][0]["description"], "Original");
1111        assert_eq!(result["checklist"]["items"][0]["status"], "pending");
1112    }
1113
1114    #[tokio::test]
1115    async fn execute_rejects_indices_for_non_update_actions() {
1116        let (_temp_dir, _state, tool) = setup_planning_workflow().await;
1117
1118        let invalid_inputs = [
1119            json!({"action": "list", "index": 1}),
1120            json!({"action": "create", "index_path": "1", "items": ["New item"]}),
1121            json!({"action": "add", "index": 1, "description": "New item"}),
1122            json!({"action": "unexpected", "index_path": "1"}),
1123        ];
1124
1125        for input in invalid_inputs {
1126            let error = tool.execute(input).await.expect_err("non-update index must fail closed");
1127            assert!(error.to_string().contains("cannot use 'index' or 'index_path'"), "unexpected error: {error}");
1128        }
1129    }
1130
1131    #[tokio::test]
1132    async fn rejects_when_planning_workflow_is_inactive() {
1133        let temp_dir = TempDir::new().expect("temp dir");
1134        let state = PlanningWorkflowState::new(temp_dir.path().to_path_buf());
1135        let tool = PlanningTaskTrackerTool::new(state);
1136
1137        let err = tool
1138            .execute(json!({"action": "list"}))
1139            .await
1140            .expect_err("should fail outside planning workflow");
1141
1142        assert!(err.to_string().contains("only available while planning"));
1143    }
1144}