1use super::planning_task_tracker::{PlanningTaskTrackerArgs, PlanningTaskTrackerTool};
16use super::planning_workflow::{
17 PlanningWorkflowState, plan_file_for_tracker_file, sync_tracker_into_plan_file,
18};
19use std::str::FromStr;
20
21use crate::config::constants::tools;
22use crate::tools::error_helpers::deserialize_tool_args;
23use crate::tools::handlers::task_tracking::{
24 TaskCounts, TaskItemInput, TaskStepMetadata, TaskTrackingStatus, append_notes,
25 append_notes_section, append_task_step_metadata, is_bulk_sync_update, metadata_from_input,
26 normalize_optional_text, normalize_string_items, parse_marked_status_prefix,
27 parse_status_prefix,
28};
29use crate::utils::file_utils::{
30 ensure_dir_exists, read_file_with_context, write_file_with_context,
31};
32use anyhow::{Context, Result, bail};
33use async_trait::async_trait;
34use serde::{Deserialize, Serialize};
35use serde_json::{Value, json};
36use std::path::{Path, PathBuf};
37use std::sync::Arc;
38use tokio::sync::RwLock;
39
40use crate::tools::traits::Tool;
41
42pub type TaskStatus = TaskTrackingStatus;
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46pub struct TaskItem {
47 pub index: usize,
48 pub description: String,
49 pub status: TaskStatus,
50 #[serde(default, flatten)]
51 pub metadata: TaskStepMetadata,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56pub struct TaskChecklist {
57 pub title: String,
58 pub items: Vec<TaskItem>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub notes: Option<String>,
61}
62
63impl TaskChecklist {
64 fn to_markdown(&self) -> String {
65 let mut md = format!("# {}\n\n", self.title);
66 for item in &self.items {
67 md.push_str(&format!(
68 "- {} {}\n",
69 item.status.flat_checkbox(),
70 item.description
71 ));
72 append_task_step_metadata(&mut md, "", &item.metadata);
73 }
74 append_notes_section(&mut md, self.notes.as_deref());
75 md
76 }
77
78 fn to_plan_markdown(&self) -> String {
79 let mut md = format!("# {}\n\n## Plan of Work\n\n", self.title);
80 for item in &self.items {
81 let trimmed = item.description.trim_start();
82 let indent = &item.description[..item.description.len() - trimmed.len()];
83 md.push_str(&format!(
84 "{}- {} {}\n",
85 indent,
86 item.status.plan_checkbox(),
87 trimmed
88 ));
89 append_task_step_metadata(&mut md, indent, &item.metadata);
90 }
91 append_notes_section(&mut md, self.notes.as_deref());
92 md
93 }
94
95 fn summary(&self) -> Value {
96 let mut counts = TaskCounts::default();
97 for item in &self.items {
98 counts.add(&item.status);
99 }
100
101 json!({
102 "title": self.title,
103 "total": counts.total,
104 "completed": counts.completed,
105 "in_progress": counts.in_progress,
106 "pending": counts.pending,
107 "blocked": counts.blocked,
108 "progress_percent": counts.progress_percent(),
109 "items": self.items.iter().map(|item| {
110 json!({
111 "index": item.index,
112 "description": item.description,
113 "status": item.status.to_string(),
114 "files": item.metadata.files.clone(),
115 "outcome": item.metadata.outcome.clone(),
116 "verify": item.metadata.verify.clone(),
117 })
118 }).collect::<Vec<_>>()
119 ,
120 "notes": self.notes.clone(),
121 })
122 }
123
124 fn view(&self) -> Value {
125 let mut lines = Vec::new();
126 for (idx, item) in self.items.iter().enumerate() {
127 let branch = if idx + 1 == self.items.len() {
128 "└"
129 } else {
130 "├"
131 };
132 lines.push(json!({
133 "display": format!("{} {} {}", branch, item.status.view_symbol(), item.description),
134 "status": item.status.to_string(),
135 "text": item.description,
136 "index_path": item.index.to_string(),
137 "files": item.metadata.files.clone(),
138 "outcome": item.metadata.outcome.clone(),
139 "verify": item.metadata.verify.clone(),
140 }));
141
142 if !item.metadata.files.is_empty() {
143 lines.push(json!({
144 "display": format!(" files: {}", item.metadata.files.join(", ")),
145 "status": item.status.to_string(),
146 "text": format!("files: {}", item.metadata.files.join(", ")),
147 }));
148 }
149 if let Some(outcome) = item.metadata.outcome.as_deref() {
150 lines.push(json!({
151 "display": format!(" outcome: {}", outcome),
152 "status": item.status.to_string(),
153 "text": format!("outcome: {}", outcome),
154 }));
155 }
156 for command in &item.metadata.verify {
157 lines.push(json!({
158 "display": format!(" verify: {}", command),
159 "status": item.status.to_string(),
160 "text": format!("verify: {}", command),
161 }));
162 }
163 }
164
165 json!({
166 "title": self.title,
167 "lines": lines,
168 })
169 }
170}
171
172fn parse_input_items(items: &[TaskItemInput]) -> Result<Vec<TaskItem>> {
173 items
174 .iter()
175 .filter_map(|item| match item {
176 TaskItemInput::Text(raw) => {
177 let (status, description) = parse_status_prefix(raw);
178 let description = description.trim().to_string();
179 if description.is_empty() {
180 return None;
181 }
182 Some(Ok((status, description, TaskStepMetadata::default())))
183 }
184 TaskItemInput::Structured(payload) => {
185 let (parsed_status, parsed_description) = parse_status_prefix(&payload.description);
186 let description = parsed_description.trim().to_string();
187 if description.is_empty() {
188 return None;
189 }
190 let status = match payload.status.as_deref() {
191 Some(raw) => match TaskStatus::from_str(raw) {
192 Ok(status) => status,
193 Err(err) => return Some(Err(err)),
194 },
195 None => parsed_status,
196 };
197 let metadata = metadata_from_input(
198 payload.files.as_deref(),
199 payload.outcome.as_deref(),
200 payload.verify.as_deref(),
201 );
202 Some(Ok((status, description, metadata)))
203 }
204 })
205 .enumerate()
206 .map(|(idx, item)| {
207 let (status, description, metadata) = item?;
208 Ok(TaskItem {
209 index: idx + 1,
210 description,
211 status,
212 metadata,
213 })
214 })
215 .collect()
216}
217
218fn parse_single_index_from_path(index_path: &str) -> Result<usize> {
219 let mut parts = index_path.trim().split('.');
220 let first = parts.next().context("index_path cannot be empty")?;
221 if parts.next().is_some() {
222 bail!(
223 "Hierarchical index_path '{}' requires Planning workflow support. Use 'index' for standard task-tracker updates or switch to Planning workflow.",
224 index_path
225 );
226 }
227 let parsed = first
228 .parse::<usize>()
229 .with_context(|| format!("Invalid index_path '{}': expected integer", index_path))?;
230 if parsed == 0 {
231 bail!("index_path must be >= 1");
232 }
233 Ok(parsed)
234}
235
236fn parse_files_metadata(value: &str) -> Vec<String> {
237 value
238 .split(',')
239 .map(str::trim)
240 .filter(|item| !item.is_empty())
241 .map(ToOwned::to_owned)
242 .collect()
243}
244
245fn apply_task_metadata_line(item: &mut TaskItem, raw: &str, in_verify_block: &mut bool) -> bool {
246 let trimmed = raw.trim_start();
247
248 if *in_verify_block {
249 if let Some(command) = trimmed
250 .strip_prefix("- ")
251 .or_else(|| trimmed.strip_prefix("* "))
252 .or_else(|| trimmed.strip_prefix("+ "))
253 {
254 if let Some(command) = normalize_optional_text(Some(command)) {
255 item.metadata.verify.push(command);
256 }
257 return true;
258 }
259 *in_verify_block = false;
260 }
261
262 if let Some(rest) = trimmed.strip_prefix("files:") {
263 item.metadata.files = parse_files_metadata(rest);
264 return true;
265 }
266
267 if let Some(rest) = trimmed.strip_prefix("outcome:") {
268 item.metadata.outcome = normalize_optional_text(Some(rest));
269 return true;
270 }
271
272 if trimmed == "verify:" {
273 item.metadata.verify.clear();
274 *in_verify_block = true;
275 return true;
276 }
277
278 if let Some(rest) = trimmed.strip_prefix("verify:") {
279 item.metadata.verify = normalize_string_items(Some(&[rest.to_string()]));
280 return true;
281 }
282
283 false
284}
285
286fn parse_plan_mirror_markdown(content: &str) -> Option<TaskChecklist> {
287 let mut title = String::new();
288 let mut items = Vec::new();
289 let mut notes_lines = Vec::new();
290 let mut in_notes = false;
291 let mut in_verify_block = false;
292 let mut idx = 1usize;
293
294 for raw in content.lines() {
295 let trimmed = raw.trim();
296
297 if title.is_empty()
298 && let Some(rest) = trimmed.strip_prefix("# ")
299 {
300 title = rest.trim().to_string();
301 continue;
302 }
303
304 if trimmed == "## Notes" {
305 in_notes = true;
306 continue;
307 }
308
309 if let Some(header) = trimmed.strip_prefix("## ") {
310 let lowered = header.trim().to_ascii_lowercase();
311 in_notes = lowered == "notes";
312 continue;
313 }
314
315 if in_notes {
316 notes_lines.push(raw.to_string());
317 continue;
318 }
319
320 if let Some(last) = items.last_mut() {
321 let indent = raw.chars().take_while(|c| *c == ' ').count();
322 if indent >= 2 && apply_task_metadata_line(last, raw, &mut in_verify_block) {
323 continue;
324 }
325 in_verify_block = false;
326 }
327
328 let Some(rest) = trimmed
329 .strip_prefix("- ")
330 .or_else(|| trimmed.strip_prefix("* "))
331 .or_else(|| trimmed.strip_prefix("+ "))
332 else {
333 continue;
334 };
335
336 if let Some((status, description)) = parse_marked_status_prefix(rest) {
337 let leading_spaces = raw.chars().take_while(|c| *c == ' ').count();
338 let description = format!("{}{}", " ".repeat(leading_spaces), description.trim());
339 items.push(TaskItem {
340 index: idx,
341 description,
342 status,
343 metadata: TaskStepMetadata::default(),
344 });
345 idx += 1;
346 in_verify_block = false;
347 }
348 }
349
350 if title.is_empty() && items.is_empty() {
351 return None;
352 }
353
354 let notes = if notes_lines.is_empty() {
355 None
356 } else {
357 Some(notes_lines.join("\n").trim().to_string())
358 };
359
360 Some(TaskChecklist {
361 title,
362 items,
363 notes,
364 })
365}
366
367fn newer_source(
368 global_modified: Option<std::time::SystemTime>,
369 plan_modified: Option<std::time::SystemTime>,
370 planning_active: bool,
371) -> TrackerSource {
372 if planning_active {
373 return if plan_modified.is_some() {
374 TrackerSource::Plan
375 } else {
376 TrackerSource::Global
377 };
378 }
379
380 match (global_modified, plan_modified) {
381 (Some(global), Some(plan)) => {
382 if global > plan {
383 TrackerSource::Global
384 } else if plan > global {
385 TrackerSource::Plan
386 } else {
387 TrackerSource::Global
388 }
389 }
390 (Some(_), None) => TrackerSource::Global,
391 (None, Some(_)) => TrackerSource::Plan,
392 (None, None) => {
393 if planning_active {
394 TrackerSource::Plan
395 } else {
396 TrackerSource::Global
397 }
398 }
399 }
400}
401
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
403enum TrackerSource {
404 Global,
405 Plan,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct TaskTrackerArgs {
411 pub action: String,
413
414 #[serde(default)]
416 pub title: Option<String>,
417
418 #[serde(default)]
420 pub items: Option<Vec<TaskItemInput>>,
421
422 #[serde(default)]
424 pub index: Option<usize>,
425
426 #[serde(default)]
428 pub index_path: Option<String>,
429
430 #[serde(default)]
432 pub status: Option<String>,
433
434 #[serde(default)]
436 pub description: Option<String>,
437
438 #[serde(default)]
440 pub files: Option<Vec<String>>,
441
442 #[serde(default)]
444 pub outcome: Option<String>,
445
446 #[serde(
448 default,
449 deserialize_with = "crate::tools::handlers::task_tracking::deserialize_optional_string_list"
450 )]
451 pub verify: Option<Vec<String>>,
452
453 #[serde(default)]
455 pub parent_index_path: Option<String>,
456
457 #[serde(default)]
459 pub notes: Option<String>,
460}
461
462pub struct TaskTrackerTool {
464 workspace_root: PathBuf,
465 planning_workflow_state: PlanningWorkflowState,
466 checklist: Arc<RwLock<Option<TaskChecklist>>>,
467}
468
469impl TaskTrackerTool {
470 pub fn new(workspace_root: PathBuf, planning_workflow_state: PlanningWorkflowState) -> Self {
471 Self {
472 workspace_root,
473 planning_workflow_state,
474 checklist: Arc::new(RwLock::new(None)),
475 }
476 }
477
478 fn tasks_dir(&self) -> PathBuf {
479 self.workspace_root.join(".vtcode").join("tasks")
480 }
481
482 fn task_file(&self) -> PathBuf {
483 self.tasks_dir().join("current_task.md")
484 }
485
486 async fn plan_task_file(&self) -> Option<PathBuf> {
487 let plan_file = self.planning_workflow_state.get_plan_file().await?;
488 let stem = plan_file.file_stem()?.to_str()?;
489 Some(plan_file.with_file_name(format!("{stem}.tasks.md")))
490 }
491
492 async fn save_checklist(&self, checklist: &TaskChecklist) -> Result<()> {
493 let dir = self.tasks_dir();
494 ensure_dir_exists(&dir)
495 .await
496 .with_context(|| format!("Failed to create tasks directory: {}", dir.display()))?;
497 let md = checklist.to_markdown();
498 write_file_with_context(&self.task_file(), &md, "task checklist")
499 .await
500 .with_context(|| "Failed to write task checklist")?;
501 Ok(())
502 }
503
504 async fn save_plan_mirror_to_file(
505 &self,
506 tracker_file: &Path,
507 checklist: &TaskChecklist,
508 ) -> Result<()> {
509 if let Some(parent) = tracker_file.parent() {
510 ensure_dir_exists(parent).await.with_context(|| {
511 format!(
512 "Failed to create plan tracker directory: {}",
513 parent.display()
514 )
515 })?;
516 }
517 write_file_with_context(
518 tracker_file,
519 &checklist.to_plan_markdown(),
520 "plan task tracker file",
521 )
522 .await
523 .with_context(|| {
524 format!(
525 "Failed to write plan task tracker file: {}",
526 tracker_file.display()
527 )
528 })?;
529 if let Some(plan_file) = plan_file_for_tracker_file(tracker_file)
530 && plan_file.exists()
531 {
532 sync_tracker_into_plan_file(&plan_file, &checklist.to_plan_markdown()).await?;
533 }
534 Ok(())
535 }
536
537 async fn save_plan_mirror(&self, checklist: &TaskChecklist) -> Result<()> {
538 let Some(tracker_file) = self.plan_task_file().await else {
539 return Ok(());
540 };
541 self.save_plan_mirror_to_file(&tracker_file, checklist)
542 .await?;
543 Ok(())
544 }
545
546 async fn load_global_checklist(&self) -> Result<Option<TaskChecklist>> {
547 let file = self.task_file();
548 if !file.exists() {
549 return Ok(None);
550 }
551 let content = read_file_with_context(&file, "task checklist").await?;
552
553 let mut title = String::new();
554 let mut items = Vec::new();
555 let mut notes_lines = Vec::new();
556 let mut in_notes = false;
557 let mut in_verify_block = false;
558 let mut idx = 1;
559
560 for line in content.lines() {
561 let trimmed = line.trim();
562 if trimmed.starts_with("# ") && title.is_empty() {
563 title = trimmed.strip_prefix("# ").unwrap_or(trimmed).to_string();
564 continue;
565 }
566 if trimmed == "## Notes" {
567 in_notes = true;
568 continue;
569 }
570 if in_notes {
571 notes_lines.push(line.to_string());
572 continue;
573 }
574 if let Some(last) = items.last_mut() {
575 let indent = line.chars().take_while(|c| *c == ' ').count();
576 if indent >= 2 && apply_task_metadata_line(last, line, &mut in_verify_block) {
577 continue;
578 }
579 in_verify_block = false;
580 }
581 if let Some(rest) = trimmed.strip_prefix("- ")
582 && let Some((status, description)) = parse_marked_status_prefix(rest)
583 {
584 items.push(TaskItem {
585 index: idx,
586 description,
587 status,
588 metadata: TaskStepMetadata::default(),
589 });
590 idx += 1;
591 in_verify_block = false;
592 }
593 }
594
595 if title.is_empty() && items.is_empty() {
596 return Ok(None);
597 }
598
599 let notes = if notes_lines.is_empty() {
600 None
601 } else {
602 Some(notes_lines.join("\n").trim().to_string())
603 };
604
605 Ok(Some(TaskChecklist {
606 title,
607 items,
608 notes,
609 }))
610 }
611
612 async fn load_plan_checklist_from(&self, tracker_file: &Path) -> Result<Option<TaskChecklist>> {
613 if !tracker_file.exists() {
614 return Ok(None);
615 }
616 let content = read_file_with_context(tracker_file, "plan task tracker file").await?;
617 Ok(parse_plan_mirror_markdown(&content))
618 }
619
620 async fn load_preferred_checklist(&self) -> Result<Option<TaskChecklist>> {
621 let task_file = self.task_file();
622 let plan_file = self.plan_task_file().await;
623
624 let global_exists = task_file.exists();
625 let plan_exists = plan_file.as_ref().is_some_and(|path| path.exists());
626
627 if !global_exists && !plan_exists {
628 return Ok(None);
629 }
630
631 let selected = if global_exists && plan_exists {
632 let global_modified = tokio::fs::metadata(&task_file)
633 .await
634 .ok()
635 .and_then(|meta| meta.modified().ok());
636 let plan_modified = match &plan_file {
637 Some(path) => tokio::fs::metadata(path)
638 .await
639 .ok()
640 .and_then(|meta| meta.modified().ok()),
641 None => None,
642 };
643 newer_source(
644 global_modified,
645 plan_modified,
646 self.planning_workflow_state.is_active(),
647 )
648 } else if plan_exists {
649 TrackerSource::Plan
650 } else {
651 TrackerSource::Global
652 };
653
654 let loaded = match selected {
655 TrackerSource::Global => self.load_global_checklist().await?,
656 TrackerSource::Plan => {
657 if let Some(path) = plan_file.as_ref() {
658 self.load_plan_checklist_from(path).await?
659 } else {
660 None
661 }
662 }
663 };
664
665 if let Some(checklist) = loaded.as_ref() {
666 match selected {
667 TrackerSource::Global => {
668 if let Some(path) = plan_file.as_ref() {
669 self.save_plan_mirror_to_file(path, checklist).await?;
670 }
671 }
672 TrackerSource::Plan => {
673 self.save_checklist(checklist).await?;
674 }
675 }
676 }
677
678 Ok(loaded)
679 }
680
681 async fn ensure_checklist_loaded(&self) -> Result<()> {
682 let loaded = self.load_preferred_checklist().await?;
683 let mut guard = self.checklist.write().await;
684 *guard = loaded;
685 Ok(())
686 }
687
688 async fn persist_edit_mode_snapshot(&self, checklist: &TaskChecklist) -> Result<()> {
689 self.save_checklist(checklist).await?;
690 self.save_plan_mirror(checklist).await?;
691 Ok(())
692 }
693
694 async fn persist_and_build_view(&self, checklist: &TaskChecklist) -> Result<(Value, Value)> {
695 self.persist_edit_mode_snapshot(checklist).await?;
696 Ok((checklist.summary(), checklist.view()))
697 }
698
699 fn to_plan_args(args: &TaskTrackerArgs) -> PlanningTaskTrackerArgs {
700 PlanningTaskTrackerArgs {
701 action: args.action.clone(),
702 title: args.title.clone(),
703 items: args.items.clone(),
704 index: args.index,
705 index_path: args
706 .index_path
707 .clone()
708 .or_else(|| args.index.map(|value| value.to_string())),
709 status: args.status.clone(),
710 description: args.description.clone(),
711 files: args.files.clone(),
712 outcome: args.outcome.clone(),
713 verify: args.verify.clone(),
714 parent_index_path: args.parent_index_path.clone(),
715 notes: args.notes.clone(),
716 }
717 }
718
719 async fn execute_in_planning_workflow(&self, args: &TaskTrackerArgs) -> Result<Value> {
720 let plan_tool = PlanningTaskTrackerTool::new(self.planning_workflow_state.clone());
721 let mapped = Self::to_plan_args(args);
722 let output = plan_tool.execute(serde_json::to_value(mapped)?).await?;
723 self.ensure_checklist_loaded().await?;
724
725 Ok(output)
726 }
727
728 async fn handle_create(&self, args: &TaskTrackerArgs) -> Result<Value> {
729 let title = args
730 .title
731 .as_deref()
732 .unwrap_or("Task Checklist")
733 .to_string();
734 let item_descs = args.items.as_deref().unwrap_or(&[]);
735 if item_descs.is_empty() {
736 anyhow::bail!(
737 "At least one item is required for 'create'. Provide items: [\"step 1\", \"step 2\", ...]"
738 );
739 }
740
741 let items = parse_input_items(item_descs)?;
742 if items.is_empty() {
743 anyhow::bail!("No valid task items were provided for create.");
744 }
745 let notes = append_notes(None, args.notes.as_deref());
746 let requested = TaskChecklist {
747 title: title.clone(),
748 items: items.clone(),
749 notes: notes.clone(),
750 };
751
752 self.ensure_checklist_loaded().await?;
753 let guard = self.checklist.write().await;
754 if let Some(existing) = guard.as_ref() {
755 let same_structure = existing.title == title
756 && existing.items.len() == items.len()
757 && existing
758 .items
759 .iter()
760 .zip(items.iter())
761 .all(|(left, right)| left.description == right.description);
762 let requested_has_explicit_status =
763 items.iter().any(|item| item.status != TaskStatus::Pending);
764 let requested_has_step_metadata = items.iter().any(|item| {
765 !item.metadata.files.is_empty()
766 || item.metadata.outcome.is_some()
767 || !item.metadata.verify.is_empty()
768 });
769 if same_structure && !requested_has_explicit_status && !requested_has_step_metadata {
770 return Ok(json!({
771 "status": "unchanged",
772 "message": "Checklist already active; preserved current progress.",
773 "task_file": self.task_file().display().to_string(),
774 "checklist": existing.summary(),
775 "view": existing.view()
776 }));
777 }
778
779 if existing == &requested {
780 return Ok(json!({
781 "status": "unchanged",
782 "message": "Requested checklist already matches current tracker state.",
783 "task_file": self.task_file().display().to_string(),
784 "checklist": existing.summary(),
785 "view": existing.view()
786 }));
787 }
788 }
789
790 let checklist = TaskChecklist {
791 title,
792 items,
793 notes,
794 };
795
796 drop(guard);
797 let (summary, view) = self.persist_and_build_view(&checklist).await?;
798 let mut guard = self.checklist.write().await;
799 *guard = Some(checklist);
800
801 Ok(json!({
802 "status": "created",
803 "message": "Task checklist created successfully.",
804 "task_file": self.task_file().display().to_string(),
805 "checklist": summary,
806 "view": view
807 }))
808 }
809
810 async fn handle_update(&self, args: &TaskTrackerArgs) -> Result<Value> {
811 self.ensure_checklist_loaded().await?;
812 let mut guard = self.checklist.write().await;
813 if is_bulk_sync_update(
814 args.items.as_deref(),
815 args.index,
816 args.index_path.as_deref(),
817 args.status.as_deref(),
818 ) {
819 let input_items = args.items.as_deref().unwrap_or(&[]);
820 let items = parse_input_items(input_items)?;
821 if items.is_empty() {
822 anyhow::bail!("No valid items provided for checklist sync.");
823 }
824
825 let title = args
826 .title
827 .clone()
828 .or_else(|| guard.as_ref().map(|checklist| checklist.title.clone()))
829 .unwrap_or_else(|| "Task Checklist".to_string());
830
831 let checklist = guard.get_or_insert(TaskChecklist {
832 title: title.clone(),
833 items: Vec::new(),
834 notes: None,
835 });
836
837 checklist.title = title;
838 checklist.items = items;
839 checklist.notes = append_notes(checklist.notes.take(), args.notes.as_deref());
840 let snapshot = checklist.clone();
841 drop(guard);
842 let (summary, view) = self.persist_and_build_view(&snapshot).await?;
843 return Ok(json!({
844 "status": "updated",
845 "message": "Checklist synchronized from provided items.",
846 "checklist": summary,
847 "view": view
848 }));
849 }
850
851 let checklist = guard
852 .as_mut()
853 .context("No active checklist. Use action='create' first.")?;
854
855 let index = match (args.index, args.index_path.as_deref()) {
856 (Some(idx), _) => idx,
857 (None, Some(path)) => parse_single_index_from_path(path)?,
858 (None, None) => {
859 bail!(
860 "'index' is required for 'update' (1-indexed), or provide 'index_path' for Planning workflow updates, or 'items' for bulk sync"
861 )
862 }
863 };
864
865 let status_str = args
866 .status
867 .as_deref()
868 .context("'status' is required for 'update' (pending|in_progress|completed|blocked), or provide 'items' for bulk sync")?;
869
870 let new_status = TaskStatus::from_str(status_str)?;
871
872 if index == 0 {
873 if new_status != TaskStatus::Completed {
874 bail!(
875 "index 0 is reserved for checklist-level completion; individual item indices are 1-indexed"
876 );
877 }
878
879 if let Some(outcome) = normalize_optional_text(args.outcome.as_deref()) {
880 let checklist_outcome = format!("Checklist outcome: {outcome}");
881 checklist.notes =
882 append_notes(checklist.notes.take(), Some(checklist_outcome.as_str()));
883 }
884 checklist.notes = append_notes(checklist.notes.take(), args.notes.as_deref());
885
886 let snapshot = checklist.clone();
887 drop(guard);
888 let (summary, view) = self.persist_and_build_view(&snapshot).await?;
889
890 return Ok(json!({
891 "status": "updated",
892 "message": "Checklist-level completion acknowledged; checklist progress remains derived from item statuses.",
893 "checklist": summary,
894 "view": view
895 }));
896 }
897
898 let item_count = checklist.items.len();
899 let pos = checklist
900 .items
901 .iter()
902 .position(|i| i.index == index)
903 .with_context(|| {
904 format!("No item at index {}. Valid range: 1-{}", index, item_count)
905 })?;
906
907 let old_status = checklist.items[pos].status.to_string();
908 checklist.items[pos].status = new_status;
909 let new_status_str = checklist.items[pos].status.to_string();
910 if let Some(files) = args.files.as_deref() {
911 checklist.items[pos].metadata.files = normalize_string_items(Some(files));
912 }
913 if args.outcome.is_some() {
914 checklist.items[pos].metadata.outcome =
915 normalize_optional_text(args.outcome.as_deref());
916 }
917 if let Some(verify) = args.verify.as_deref() {
918 checklist.items[pos].metadata.verify = normalize_string_items(Some(verify));
919 }
920 checklist.notes = append_notes(checklist.notes.take(), args.notes.as_deref());
921
922 let snapshot = checklist.clone();
923 drop(guard);
924 let (summary, view) = self.persist_and_build_view(&snapshot).await?;
925
926 Ok(json!({
927 "status": "updated",
928 "message": format!("Item {} status changed: {} → {}", index, old_status, new_status_str),
929 "checklist": summary,
930 "view": view
931 }))
932 }
933
934 async fn handle_list(&self) -> Result<Value> {
935 self.ensure_checklist_loaded().await?;
936 let guard = self.checklist.read().await;
937
938 match guard.as_ref() {
939 Some(checklist) => Ok(json!({
940 "status": "ok",
941 "checklist": checklist.summary(),
942 "view": checklist.view()
943 })),
944 None => Ok(json!({
945 "status": "empty",
946 "message": "No active checklist. Use action='create' to start one."
947 })),
948 }
949 }
950
951 async fn handle_add(&self, args: &TaskTrackerArgs) -> Result<Value> {
952 if let Some(parent_path) = args.parent_index_path.as_deref()
953 && !parent_path.trim().is_empty()
954 {
955 bail!(
956 "'parent_index_path' is only supported for hierarchical Planning workflow updates. Use Planning workflow or omit parent_index_path for standard task-tracker updates."
957 );
958 }
959
960 self.ensure_checklist_loaded().await?;
961 let mut guard = self.checklist.write().await;
962 let checklist = guard
963 .as_mut()
964 .context("No active checklist. Use action='create' first.")?;
965
966 let desc = args
967 .description
968 .as_deref()
969 .context("'description' is required for 'add'")?;
970 let (status, parsed_description) = parse_status_prefix(desc);
971 let description = parsed_description.trim().to_string();
972 if description.is_empty() {
973 bail!("description cannot be empty");
974 }
975
976 let new_index = checklist.items.len() + 1;
977 checklist.items.push(TaskItem {
978 index: new_index,
979 description: description.clone(),
980 status,
981 metadata: metadata_from_input(
982 args.files.as_deref(),
983 args.outcome.as_deref(),
984 args.verify.as_deref(),
985 ),
986 });
987
988 checklist.notes = append_notes(checklist.notes.take(), args.notes.as_deref());
989 let snapshot = checklist.clone();
990 drop(guard);
991 let (summary, view) = self.persist_and_build_view(&snapshot).await?;
992
993 Ok(json!({
994 "status": "added",
995 "message": format!("Added item {}: {}", new_index, description),
996 "checklist": summary,
997 "view": view
998 }))
999 }
1000}
1001
1002#[async_trait]
1003impl Tool for TaskTrackerTool {
1004 async fn execute(&self, args: Value) -> Result<Value> {
1005 let args: TaskTrackerArgs = deserialize_tool_args(&args, "task_tracker")?;
1006
1007 if self.planning_workflow_state.is_active() {
1008 return self.execute_in_planning_workflow(&args).await;
1009 }
1010
1011 match args.action.as_str() {
1012 "create" => self.handle_create(&args).await,
1013 "update" => self.handle_update(&args).await,
1014 "list" => self.handle_list().await,
1015 "add" => self.handle_add(&args).await,
1016 other => Ok(json!({
1017 "status": "error",
1018 "message": format!("Unknown action '{}'. Use: create, update, list, add", other)
1019 })),
1020 }
1021 }
1022
1023 fn name(&self) -> &str {
1024 tools::TASK_TRACKER
1025 }
1026
1027 fn description(&self) -> &str {
1028 "Task tracker for standard sessions and the Planning workflow. Uses one checklist API (`create|update|list|add`) and mirrors tracker state between `.vtcode/tasks/current_task.md` and active plan sidecar files when available."
1029 }
1030
1031 fn parameter_schema(&self) -> Option<Value> {
1032 Some(json!({
1033 "type": "object",
1034 "properties": {
1035 "action": {
1036 "type": "string",
1037 "enum": ["create", "update", "list", "add"],
1038 "description": "Action to perform on the task checklist."
1039 },
1040 "title": {
1041 "type": "string",
1042 "description": "Title for the checklist (used with 'create')."
1043 },
1044 "items": {
1045 "type": "array",
1046 "items": {
1047 "anyOf": [
1048 { "type": "string" },
1049 {
1050 "type": "object",
1051 "properties": {
1052 "description": { "type": "string" },
1053 "status": {
1054 "type": "string",
1055 "enum": ["pending", "in_progress", "completed", "blocked"]
1056 },
1057 "files": {
1058 "type": "array",
1059 "items": { "type": "string" }
1060 },
1061 "outcome": { "type": "string" },
1062 "verify": {
1063 "anyOf": [
1064 { "type": "string" },
1065 {
1066 "type": "array",
1067 "items": { "type": "string" }
1068 }
1069 ]
1070 }
1071 },
1072 "required": ["description"]
1073 }
1074 ]
1075 },
1076 "description": "List of task descriptions or structured task items (used with 'create'; also supports bulk 'update' sync with optional [x]/[~]/[!]/[ ] prefixes and indentation for hierarchy in Planning workflow)."
1077 },
1078 "index": {
1079 "type": "integer",
1080 "description": "1-indexed item number to update in the standard flat checklist."
1081 },
1082 "index_path": {
1083 "type": "string",
1084 "description": "Hierarchical index path for update in Planning workflow (example: '2.1'). A single value such as '2' is equivalent to the flat index."
1085 },
1086 "status": {
1087 "type": "string",
1088 "enum": ["pending", "in_progress", "completed", "blocked"],
1089 "description": "New status for the item (used with single-item 'update')."
1090 },
1091 "description": {
1092 "type": "string",
1093 "description": "Description for a new item (used with 'add')."
1094 },
1095 "files": {
1096 "type": "array",
1097 "items": { "type": "string" },
1098 "description": "Optional file paths associated with a single add/update item."
1099 },
1100 "outcome": {
1101 "type": "string",
1102 "description": "Optional expected outcome associated with a single add/update item."
1103 },
1104 "verify": {
1105 "anyOf": [
1106 { "type": "string" },
1107 {
1108 "type": "array",
1109 "items": { "type": "string" }
1110 }
1111 ],
1112 "description": "Optional verification command or commands associated with a single add/update item."
1113 },
1114 "parent_index_path": {
1115 "type": "string",
1116 "description": "Optional parent path for add in Planning workflow (example: '2')."
1117 },
1118 "notes": {
1119 "type": "string",
1120 "description": "Optional notes to append to the checklist."
1121 }
1122 },
1123 "required": ["action"],
1124 "allOf": [
1125 {
1126 "if": {
1127 "properties": { "action": { "const": "create" } },
1128 "required": ["action"]
1129 },
1130 "then": {
1131 "required": ["items"]
1132 }
1133 },
1134 {
1135 "if": {
1136 "properties": { "action": { "const": "update" } },
1137 "required": ["action"]
1138 },
1139 "then": {
1140 "anyOf": [
1141 { "required": ["index", "status"] },
1142 { "required": ["index_path", "status"] },
1143 { "required": ["items"] }
1144 ]
1145 }
1146 },
1147 {
1148 "if": {
1149 "properties": { "action": { "const": "add" } },
1150 "required": ["action"]
1151 },
1152 "then": {
1153 "required": ["description"]
1154 }
1155 }
1156 ]
1157 }))
1158 }
1159
1160 fn is_mutating(&self) -> bool {
1161 false }
1163
1164 fn is_parallel_safe(&self) -> bool {
1165 false }
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use super::*;
1172 use tempfile::TempDir;
1173
1174 fn setup_tool(temp: &TempDir) -> (PlanningWorkflowState, TaskTrackerTool) {
1175 let state = PlanningWorkflowState::new(temp.path().to_path_buf());
1176 let tool = TaskTrackerTool::new(temp.path().to_path_buf(), state.clone());
1177 (state, tool)
1178 }
1179
1180 #[tokio::test]
1181 async fn test_create_checklist() {
1182 let temp = TempDir::new().unwrap();
1183 let (_state, tool) = setup_tool(&temp);
1184
1185 let result = tool
1186 .execute(json!({
1187 "action": "create",
1188 "title": "Refactor Auth",
1189 "items": ["Extract middleware", "Add tests", "Update docs"]
1190 }))
1191 .await
1192 .unwrap();
1193
1194 assert_eq!(result["status"], "created");
1195 assert_eq!(result["checklist"]["total"], 3);
1196 assert_eq!(result["checklist"]["completed"], 0);
1197 assert_eq!(result["view"]["title"], "Refactor Auth");
1198 }
1199
1200 #[tokio::test]
1201 async fn test_create_accepts_metadata_and_verify_string_forms() {
1202 let temp = TempDir::new().unwrap();
1203 let (_state, tool) = setup_tool(&temp);
1204
1205 let result = tool
1206 .execute(json!({
1207 "action": "create",
1208 "title": "Harness tracker",
1209 "items": [
1210 {
1211 "description": "Analyze current harness",
1212 "files": ["docs/ARCHITECTURE.md"],
1213 "outcome": "Document the harness map",
1214 "verify": "cargo check"
1215 },
1216 {
1217 "description": "Wire continuation",
1218 "verify": ["cargo test -p vtcode-core continuation", "cargo check -p vtcode"]
1219 }
1220 ]
1221 }))
1222 .await
1223 .unwrap();
1224
1225 assert_eq!(
1226 result["checklist"]["items"][0]["files"],
1227 json!(["docs/ARCHITECTURE.md"])
1228 );
1229 assert_eq!(
1230 result["checklist"]["items"][0]["outcome"],
1231 "Document the harness map"
1232 );
1233 assert_eq!(
1234 result["checklist"]["items"][0]["verify"],
1235 json!(["cargo check"])
1236 );
1237 assert_eq!(
1238 result["checklist"]["items"][1]["verify"],
1239 json!([
1240 "cargo test -p vtcode-core continuation",
1241 "cargo check -p vtcode"
1242 ])
1243 );
1244
1245 let persisted =
1246 std::fs::read_to_string(temp.path().join(".vtcode/tasks/current_task.md")).unwrap();
1247 assert!(persisted.contains("files: docs/ARCHITECTURE.md"));
1248 assert!(persisted.contains("outcome: Document the harness map"));
1249 assert!(persisted.contains("verify: cargo check"));
1250 }
1251
1252 #[tokio::test]
1253 async fn test_update_item() {
1254 let temp = TempDir::new().unwrap();
1255 let (_state, tool) = setup_tool(&temp);
1256
1257 tool.execute(json!({
1258 "action": "create",
1259 "title": "Test",
1260 "items": ["Step 1", "Step 2"]
1261 }))
1262 .await
1263 .unwrap();
1264
1265 let result = tool
1266 .execute(json!({
1267 "action": "update",
1268 "index": 1,
1269 "status": "completed"
1270 }))
1271 .await
1272 .unwrap();
1273
1274 assert_eq!(result["status"], "updated");
1275 assert_eq!(result["checklist"]["completed"], 1);
1276 assert_eq!(result["checklist"]["progress_percent"], 50);
1277 }
1278
1279 #[tokio::test]
1280 async fn test_update_index_zero_allows_checklist_completion_note() {
1281 let temp = TempDir::new().unwrap();
1282 let (_state, tool) = setup_tool(&temp);
1283
1284 tool.execute(json!({
1285 "action": "create",
1286 "title": "Test",
1287 "items": ["Step 1", "Step 2"]
1288 }))
1289 .await
1290 .unwrap();
1291
1292 let result = tool
1293 .execute(json!({
1294 "action": "update",
1295 "index": 0,
1296 "status": "completed",
1297 "outcome": "Reported summary to user"
1298 }))
1299 .await
1300 .unwrap();
1301
1302 assert_eq!(result["status"], "updated");
1303 assert_eq!(result["checklist"]["completed"], 0);
1304 assert_eq!(
1305 result["checklist"]["notes"],
1306 "Checklist outcome: Reported summary to user"
1307 );
1308 }
1309
1310 #[tokio::test]
1311 async fn test_add_item() {
1312 let temp = TempDir::new().unwrap();
1313 let (_state, tool) = setup_tool(&temp);
1314
1315 tool.execute(json!({
1316 "action": "create",
1317 "title": "Test",
1318 "items": ["Step 1"]
1319 }))
1320 .await
1321 .unwrap();
1322
1323 let result = tool
1324 .execute(json!({
1325 "action": "add",
1326 "description": "Step 2"
1327 }))
1328 .await
1329 .unwrap();
1330
1331 assert_eq!(result["status"], "added");
1332 assert_eq!(result["checklist"]["total"], 2);
1333 }
1334
1335 #[tokio::test]
1336 async fn test_create_is_idempotent_for_same_structure() {
1337 let temp = TempDir::new().unwrap();
1338 let (_state, tool) = setup_tool(&temp);
1339
1340 tool.execute(json!({
1341 "action": "create",
1342 "title": "Clippy Warnings",
1343 "items": ["Fix A", "Fix B"]
1344 }))
1345 .await
1346 .unwrap();
1347
1348 tool.execute(json!({
1349 "action": "update",
1350 "index": 1,
1351 "status": "completed"
1352 }))
1353 .await
1354 .unwrap();
1355
1356 let duplicate = tool
1357 .execute(json!({
1358 "action": "create",
1359 "title": "Clippy Warnings",
1360 "items": ["Fix A", "Fix B"]
1361 }))
1362 .await
1363 .unwrap();
1364
1365 assert_eq!(duplicate["status"], "unchanged");
1366 assert_eq!(duplicate["checklist"]["completed"], 1);
1367 }
1368
1369 #[tokio::test]
1370 async fn test_update_supports_bulk_item_sync() {
1371 let temp = TempDir::new().unwrap();
1372 let (_state, tool) = setup_tool(&temp);
1373
1374 tool.execute(json!({
1375 "action": "create",
1376 "title": "Sync Test",
1377 "items": ["Step 1", "Step 2", "Step 3"]
1378 }))
1379 .await
1380 .unwrap();
1381
1382 let updated = tool
1383 .execute(json!({
1384 "action": "update",
1385 "items": ["[x] Step 1", "[~] Step 2", "[ ] Step 3"]
1386 }))
1387 .await
1388 .unwrap();
1389
1390 assert_eq!(updated["status"], "updated");
1391 assert_eq!(updated["checklist"]["completed"], 1);
1392 assert_eq!(updated["checklist"]["in_progress"], 1);
1393 assert_eq!(updated["checklist"]["pending"], 1);
1394 }
1395
1396 #[tokio::test]
1397 async fn test_list_empty() {
1398 let temp = TempDir::new().unwrap();
1399 let (_state, tool) = setup_tool(&temp);
1400
1401 let result = tool.execute(json!({"action": "list"})).await.unwrap();
1402 assert_eq!(result["status"], "empty");
1403 }
1404
1405 #[tokio::test]
1406 async fn test_persistence_across_loads() {
1407 let temp = TempDir::new().unwrap();
1408
1409 {
1410 let (_state, tool) = setup_tool(&temp);
1411 tool.execute(json!({
1412 "action": "create",
1413 "title": "Persist Test",
1414 "items": ["Alpha", "Beta"]
1415 }))
1416 .await
1417 .unwrap();
1418
1419 tool.execute(json!({
1420 "action": "update",
1421 "index": 1,
1422 "status": "completed"
1423 }))
1424 .await
1425 .unwrap();
1426 }
1427
1428 let (_state, tool2) = setup_tool(&temp);
1429 let result = tool2.execute(json!({"action": "list"})).await.unwrap();
1430
1431 assert_eq!(result["status"], "ok");
1432 assert_eq!(result["checklist"]["total"], 2);
1433 assert_eq!(result["checklist"]["completed"], 1);
1434 }
1435
1436 #[tokio::test]
1437 async fn test_planning_workflow_task_tracker_delegates_and_mirrors_global() {
1438 let temp = TempDir::new().unwrap();
1439 let (state, tool) = setup_tool(&temp);
1440
1441 let plans_dir = state.plans_dir();
1442 std::fs::create_dir_all(&plans_dir).unwrap();
1443 let plan_file = plans_dir.join("adaptive.md");
1444 std::fs::write(&plan_file, "# Adaptive\n").unwrap();
1445 state.set_plan_file(Some(plan_file)).await;
1446 state.enable();
1447
1448 let created = tool
1449 .execute(json!({
1450 "action": "create",
1451 "title": "Adaptive Plan",
1452 "items": ["Root task", " Child task"]
1453 }))
1454 .await
1455 .unwrap();
1456
1457 assert_eq!(created["status"], "created");
1458 assert_eq!(created["checklist"]["total"], 2);
1459
1460 let task_file = temp.path().join(".vtcode/tasks/current_task.md");
1461 let persisted = std::fs::read_to_string(task_file).unwrap();
1462 assert!(persisted.contains("Root task"));
1463 assert!(persisted.contains("Child task"));
1464 }
1465
1466 #[tokio::test]
1467 async fn test_planning_workflow_mirror_preserves_notes() {
1468 let temp = TempDir::new().unwrap();
1469 let (state, tool) = setup_tool(&temp);
1470
1471 let plans_dir = state.plans_dir();
1472 std::fs::create_dir_all(&plans_dir).unwrap();
1473 let plan_file = plans_dir.join("notes.md");
1474 std::fs::write(&plan_file, "# Notes\n").unwrap();
1475 state.set_plan_file(Some(plan_file)).await;
1476 state.enable();
1477
1478 tool.execute(json!({
1479 "action": "create",
1480 "items": ["Root task"],
1481 "notes": "Keep this note"
1482 }))
1483 .await
1484 .unwrap();
1485
1486 let task_file = temp.path().join(".vtcode/tasks/current_task.md");
1487 let persisted = std::fs::read_to_string(task_file).unwrap();
1488 assert!(persisted.contains("## Notes"));
1489 assert!(persisted.contains("Keep this note"));
1490 }
1491
1492 #[tokio::test]
1493 async fn test_edit_mode_prefers_newer_plan_mirror_when_present() {
1494 let temp = TempDir::new().unwrap();
1495 let (state, tool) = setup_tool(&temp);
1496
1497 let plans_dir = state.plans_dir();
1498 std::fs::create_dir_all(&plans_dir).unwrap();
1499 let plan_file = plans_dir.join("freshness.md");
1500 std::fs::write(&plan_file, "# Freshness\n").unwrap();
1501 state.set_plan_file(Some(plan_file.clone())).await;
1502
1503 let global_file = temp.path().join(".vtcode/tasks/current_task.md");
1504 std::fs::create_dir_all(global_file.parent().unwrap()).unwrap();
1505 std::fs::write(&global_file, "# Freshness\n\n- [ ] stale global\n").unwrap();
1506
1507 std::thread::sleep(std::time::Duration::from_millis(15));
1508
1509 let sidecar = plans_dir.join("freshness.tasks.md");
1510 std::fs::write(
1511 &sidecar,
1512 "# Freshness\n\n## Plan of Work\n\n- [x] newer plan\n",
1513 )
1514 .unwrap();
1515
1516 let listed = tool.execute(json!({"action": "list"})).await.unwrap();
1517 assert_eq!(listed["status"], "ok");
1518 assert_eq!(listed["checklist"]["completed"], 1);
1519 assert_eq!(listed["checklist"]["pending"], 0);
1520
1521 let global_synced = std::fs::read_to_string(global_file).unwrap();
1522 assert!(global_synced.contains("newer plan"));
1523 }
1524
1525 #[tokio::test]
1526 async fn test_planning_workflow_prefers_plan_sidecar_even_if_global_is_newer() {
1527 let temp = TempDir::new().unwrap();
1528 let (state, tool) = setup_tool(&temp);
1529
1530 let plans_dir = state.plans_dir();
1531 std::fs::create_dir_all(&plans_dir).unwrap();
1532 let plan_file = plans_dir.join("plan-primary.md");
1533 std::fs::write(&plan_file, "# Plan Primary\n").unwrap();
1534 state.set_plan_file(Some(plan_file.clone())).await;
1535 state.enable();
1536
1537 let global_file = temp.path().join(".vtcode/tasks/current_task.md");
1538 std::fs::create_dir_all(global_file.parent().unwrap()).unwrap();
1539 std::fs::write(&global_file, "# Plan Primary\n\n- [x] global newer\n").unwrap();
1540 std::thread::sleep(std::time::Duration::from_millis(15));
1541
1542 let sidecar = plans_dir.join("plan-primary.tasks.md");
1543 std::fs::write(
1544 &sidecar,
1545 "# Plan Primary\n\n## Plan of Work\n\n- [ ] plan source\n",
1546 )
1547 .unwrap();
1548 std::thread::sleep(std::time::Duration::from_millis(15));
1549 std::fs::write(&global_file, "# Plan Primary\n\n- [x] global newest\n").unwrap();
1550
1551 let listed = tool.execute(json!({"action": "list"})).await.unwrap();
1552 assert_eq!(listed["status"], "ok");
1553 assert_eq!(listed["checklist"]["pending"], 1);
1554 assert_eq!(listed["checklist"]["completed"], 0);
1555 }
1556}