Skip to main content

vtcode_core/tools/handlers/
plan_task_tracker.rs

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