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