1use 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 pub action: String,
50
51 #[serde(default)]
53 pub title: Option<String>,
54
55 #[serde(default)]
57 pub items: Option<Vec<TaskItemInput>>,
58
59 #[serde(default)]
61 pub index_path: Option<String>,
62
63 #[serde(default)]
65 pub index: Option<usize>,
66
67 #[serde(default)]
69 pub status: Option<String>,
70
71 #[serde(default)]
73 pub description: Option<String>,
74
75 #[serde(default)]
77 pub files: Option<Vec<String>>,
78
79 #[serde(default)]
81 pub outcome: Option<String>,
82
83 #[serde(
85 default,
86 deserialize_with = "crate::tools::handlers::task_tracking::deserialize_optional_string_list"
87 )]
88 pub verify: Option<Vec<String>>,
89
90 #[serde(default)]
92 pub parent_index_path: Option<String>,
93
94 #[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, ¤t_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}