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