1use 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 pub action: String,
49
50 #[serde(default)]
52 pub title: Option<String>,
53
54 #[serde(default)]
56 pub items: Option<Vec<TaskItemInput>>,
57
58 #[serde(default)]
60 pub index_path: Option<String>,
61
62 #[serde(default)]
64 pub index: Option<usize>,
65
66 #[serde(default)]
68 pub status: Option<String>,
69
70 #[serde(default)]
72 pub description: Option<String>,
73
74 #[serde(default)]
76 pub files: Option<Vec<String>>,
77
78 #[serde(default)]
80 pub outcome: Option<String>,
81
82 #[serde(
84 default,
85 deserialize_with = "crate::tools::handlers::task_tracking::deserialize_optional_string_list"
86 )]
87 pub verify: Option<Vec<String>>,
88
89 #[serde(default)]
91 pub parent_index_path: Option<String>,
92
93 #[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, ¤t_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}