1use super::planning_task_tracker::{PlanningTaskTrackerArgs, PlanningTaskTrackerTool};
16use super::planning_workflow::{
17 PlanningWorkflowState, plan_file_for_tracker_file, sync_tracker_into_plan_file, tracker_file_for_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 TASK_ITEMS_DESCRIPTION, TaskCounts, TaskItemInput, TaskStepMetadata, TaskTrackingStatus, TaskTreeNode,
25 append_notes, append_notes_section, append_task_step_metadata, compact_task_tree_view, is_bulk_sync_update,
26 metadata_from_input, normalize_optional_text, normalize_string_items, parse_marked_status_prefix,
27 parse_status_prefix, task_files_property_schema, task_items_array_schema, task_status_property_schema,
28 task_verify_property_schema, validate_action_index_fields, validate_task_item_inputs, validate_update_shape,
29};
30use crate::utils::file_utils::{ensure_dir_exists, read_file_with_context, write_file_with_context};
31use anyhow::{Context, Result, bail};
32use async_trait::async_trait;
33use serde::{Deserialize, Serialize};
34use serde_json::{Value, json};
35use std::fmt::Write as _;
36use std::path::{Path, PathBuf};
37use std::sync::Arc;
38use tokio::sync::RwLock;
39use vtcode_commons::workspace_relative_display;
40
41use crate::tools::traits::Tool;
42
43pub type TaskStatus = TaskTrackingStatus;
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct TaskItem {
48 pub index: usize,
49 pub description: String,
50 pub status: TaskStatus,
51 #[serde(default, flatten)]
52 pub metadata: TaskStepMetadata,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub struct TaskChecklist {
58 pub title: String,
59 pub items: Vec<TaskItem>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub notes: Option<String>,
62}
63
64impl TaskChecklist {
65 fn to_markdown(&self) -> String {
66 let mut md = format!("# {}\n\n", self.title);
67 for item in &self.items {
68 let _ = writeln!(md, "- {} {}", item.status.flat_checkbox(), item.description);
69 append_task_step_metadata(&mut md, "", &item.metadata);
70 }
71 append_notes_section(&mut md, self.notes.as_deref());
72 md
73 }
74
75 fn to_plan_markdown(&self) -> String {
76 let mut md = format!("# {}\n\n## Plan of Work\n\n", self.title);
77 for item in &self.items {
78 let trimmed = item.description.trim_start();
79 let indent = &item.description[..item.description.len() - trimmed.len()];
80 let _ = writeln!(md, "{}- {} {}", indent, item.status.plan_checkbox(), trimmed);
81 append_task_step_metadata(&mut md, indent, &item.metadata);
82 }
83 append_notes_section(&mut md, self.notes.as_deref());
84 md
85 }
86
87 fn summary(&self) -> Value {
88 let mut counts = TaskCounts::default();
89 for item in &self.items {
90 counts.add(&item.status);
91 }
92
93 json!({
94 "title": self.title,
95 "total": counts.total,
96 "completed": counts.completed,
97 "in_progress": counts.in_progress,
98 "pending": counts.pending,
99 "blocked": counts.blocked,
100 "progress_percent": counts.progress_percent(),
101 "items": self.items.iter().map(|item| {
102 json!({
103 "index": item.index,
104 "description": item.description,
105 "status": item.status.to_string(),
106 "files": item.metadata.files.clone(),
107 "outcome": item.metadata.outcome.clone(),
108 "verify": item.metadata.verify.clone(),
109 })
110 }).collect::<Vec<_>>()
111 ,
112 "notes": self.notes.clone(),
113 })
114 }
115
116 fn view(&self) -> Value {
117 let nodes = self
118 .items
119 .iter()
120 .map(|item| TaskTreeNode {
121 index_path: item.index.to_string(),
122 description: item.description.clone(),
123 status: item.status,
124 metadata: item.metadata.clone(),
125 children: Vec::new(),
126 })
127 .collect::<Vec<_>>();
128
129 json!({
130 "title": self.title,
131 "lines": compact_task_tree_view(&nodes),
132 })
133 }
134}
135
136fn parse_input_items(items: &[TaskItemInput]) -> Result<Vec<TaskItem>> {
137 validate_task_item_inputs(items)?;
138 items
139 .iter()
140 .filter_map(|item| match item {
141 TaskItemInput::Text(raw) => {
142 let (status, description) = parse_status_prefix(raw);
143 let description = description.trim().to_string();
144 if description.is_empty() {
145 return None;
146 }
147 Some(Ok((status, description, TaskStepMetadata::default())))
148 }
149 TaskItemInput::Structured(payload) => {
150 let (parsed_status, parsed_description) = parse_status_prefix(&payload.description);
151 let description = parsed_description.trim().to_string();
152 if description.is_empty() {
153 return None;
154 }
155 let status = match payload.status.as_deref() {
156 Some(raw) => match TaskStatus::from_str(raw) {
157 Ok(status) => status,
158 Err(err) => return Some(Err(err)),
159 },
160 None => parsed_status,
161 };
162 let metadata = metadata_from_input(
163 payload.files.as_deref(),
164 payload.outcome.as_deref(),
165 payload.verify.as_deref(),
166 );
167 Some(Ok((status, description, metadata)))
168 }
169 })
170 .enumerate()
171 .map(|(idx, item)| {
172 let (status, description, metadata) = item?;
173 Ok(TaskItem { index: idx + 1, description, status, metadata })
174 })
175 .collect()
176}
177
178fn parse_single_index_from_path(index_path: &str) -> Result<usize> {
179 let mut parts = index_path.trim().split('.');
180 let first = parts.next().context("index_path cannot be empty")?;
181 if parts.next().is_some() {
182 bail!(
183 "Hierarchical index_path '{index_path}' requires Planning workflow support. Use 'index' for standard task-tracker updates or switch to Planning workflow."
184 );
185 }
186 let parsed = first
187 .parse::<usize>()
188 .with_context(|| format!("Invalid index_path '{index_path}': expected integer"))?;
189 if parsed == 0 {
190 bail!("index_path must be >= 1");
191 }
192 Ok(parsed)
193}
194
195fn parse_files_metadata(value: &str) -> Vec<String> {
196 super::planning_workflow::split_bracket_items(value)
197 .into_iter()
198 .map(|item| item.trim().to_owned())
199 .filter(|item| !item.is_empty())
200 .collect()
201}
202
203fn apply_task_metadata_line(item: &mut TaskItem, raw: &str, in_verify_block: &mut bool) -> bool {
204 let trimmed = raw.trim_start();
205
206 if *in_verify_block {
207 if let Some(command) = trimmed
208 .strip_prefix("- ")
209 .or_else(|| trimmed.strip_prefix("* "))
210 .or_else(|| trimmed.strip_prefix("+ "))
211 {
212 if let Some(command) = normalize_optional_text(Some(command)) {
213 item.metadata.verify.push(command);
214 }
215 return true;
216 }
217 *in_verify_block = false;
218 }
219
220 if let Some(rest) = trimmed.strip_prefix("files:") {
221 item.metadata.files = parse_files_metadata(rest);
222 return true;
223 }
224
225 if let Some(rest) = trimmed.strip_prefix("outcome:") {
226 item.metadata.outcome = normalize_optional_text(Some(rest));
227 return true;
228 }
229
230 if trimmed == "verify:" {
231 item.metadata.verify.clear();
232 *in_verify_block = true;
233 return true;
234 }
235
236 if let Some(rest) = trimmed.strip_prefix("verify:") {
237 item.metadata.verify = normalize_string_items(Some(&[rest.to_string()]));
238 return true;
239 }
240
241 false
242}
243
244fn parse_plan_mirror_markdown(content: &str) -> Option<TaskChecklist> {
245 let mut title = String::new();
246 let mut items = Vec::new();
247 let mut notes_lines = Vec::new();
248 let mut in_notes = false;
249 let mut in_verify_block = false;
250 let mut idx = 1usize;
251
252 for raw in content.lines() {
253 let trimmed = raw.trim();
254
255 if title.is_empty()
256 && let Some(rest) = trimmed.strip_prefix("# ")
257 {
258 title = rest.trim().to_string();
259 continue;
260 }
261
262 if trimmed == "## Notes" {
263 in_notes = true;
264 continue;
265 }
266
267 if let Some(header) = trimmed.strip_prefix("## ") {
268 let lowered = header.trim().to_ascii_lowercase();
269 in_notes = lowered == "notes";
270 continue;
271 }
272
273 if in_notes {
274 notes_lines.push(raw.to_string());
275 continue;
276 }
277
278 if let Some(last) = items.last_mut() {
279 let indent = raw.chars().take_while(|c| *c == ' ').count();
280 if indent >= 2 && apply_task_metadata_line(last, raw, &mut in_verify_block) {
281 continue;
282 }
283 in_verify_block = false;
284 }
285
286 let Some(rest) = trimmed
287 .strip_prefix("- ")
288 .or_else(|| trimmed.strip_prefix("* "))
289 .or_else(|| trimmed.strip_prefix("+ "))
290 else {
291 continue;
292 };
293
294 if let Some((status, description)) = parse_marked_status_prefix(rest) {
295 let leading_spaces = raw.chars().take_while(|c| *c == ' ').count();
296 let description = format!("{}{}", " ".repeat(leading_spaces), description.trim());
297 items.push(TaskItem {
298 index: idx,
299 description,
300 status,
301 metadata: TaskStepMetadata::default(),
302 });
303 idx += 1;
304 in_verify_block = false;
305 }
306 }
307
308 if title.is_empty() && items.is_empty() {
309 return None;
310 }
311
312 let notes = if notes_lines.is_empty() {
313 None
314 } else {
315 Some(notes_lines.join("\n").trim().to_string())
316 };
317
318 Some(TaskChecklist { title, items, notes })
319}
320
321fn newer_source(
322 global_modified: Option<std::time::SystemTime>,
323 plan_modified: Option<std::time::SystemTime>,
324 planning_active: bool,
325) -> TrackerSource {
326 if planning_active {
327 return if plan_modified.is_some() {
328 TrackerSource::Plan
329 } else {
330 TrackerSource::Global
331 };
332 }
333
334 match (global_modified, plan_modified) {
335 (Some(global), Some(plan)) => {
336 if global > plan {
337 TrackerSource::Global
338 } else if plan > global {
339 TrackerSource::Plan
340 } else {
341 TrackerSource::Global
342 }
343 }
344 (Some(_), None) => TrackerSource::Global,
345 (None, Some(_)) => TrackerSource::Plan,
346 (None, None) => {
347 if planning_active {
348 TrackerSource::Plan
349 } else {
350 TrackerSource::Global
351 }
352 }
353 }
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357enum TrackerSource {
358 Global,
359 Plan,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct TaskTrackerArgs {
365 pub action: String,
367
368 #[serde(default)]
370 pub title: Option<String>,
371
372 #[serde(default)]
374 pub items: Option<Vec<TaskItemInput>>,
375
376 #[serde(default)]
378 pub index: Option<usize>,
379
380 #[serde(default)]
382 pub index_path: Option<String>,
383
384 #[serde(default)]
386 pub status: Option<String>,
387
388 #[serde(default)]
390 pub description: Option<String>,
391
392 #[serde(default)]
394 pub files: Option<Vec<String>>,
395
396 #[serde(default)]
398 pub outcome: Option<String>,
399
400 #[serde(
402 default,
403 deserialize_with = "crate::tools::handlers::task_tracking::deserialize_optional_string_list"
404 )]
405 pub verify: Option<Vec<String>>,
406
407 #[serde(default)]
409 pub parent_index_path: Option<String>,
410
411 #[serde(default)]
413 pub notes: Option<String>,
414}
415
416pub struct TaskTrackerTool {
418 workspace_root: PathBuf,
419 planning_workflow_state: PlanningWorkflowState,
420 checklist: Arc<RwLock<Option<TaskChecklist>>>,
421}
422
423fn standard_task_tracker_parameter_schema() -> Value {
424 json!({
425 "type": "object",
426 "properties": {
427 "action": {
428 "type": "string",
429 "enum": ["create", "update", "list", "add"],
430 "description": "Action to perform on the task checklist."
431 },
432 "title": {
433 "type": "string",
434 "description": "Title for the checklist (used with 'create')."
435 },
436 "items": task_items_array_schema(TASK_ITEMS_DESCRIPTION),
437 "index": {
438 "type": "integer",
439 "minimum": 0,
440 "description": "Action=update only: use a 1-based item index. index: 0 is reserved for standard checklist-level completion and is valid only with action: update and status: completed."
441 },
442 "index_path": {
443 "type": "string",
444 "pattern": "^[1-9][0-9]*$",
445 "description": "Action=update only: positive flat index path for compatibility (example: '2'). Hierarchical paths are accepted only by Planning workflow."
446 },
447 "status": task_status_property_schema("New status for the item (used with single-item 'update')."),
448 "description": {
449 "type": "string",
450 "description": "Description for a new item (used with 'add')."
451 },
452 "files": task_files_property_schema("Optional file paths associated with a single add/update item."),
453 "outcome": {
454 "type": "string",
455 "description": "Optional expected outcome associated with a single add/update item."
456 },
457 "verify": task_verify_property_schema("Optional verification command or commands associated with a single add/update item."),
458 "parent_index_path": {
459 "type": "string",
460 "description": "Optional parent path for add in Planning workflow (example: '2')."
461 },
462 "notes": {
463 "type": "string",
464 "description": "Optional notes to append to the checklist."
465 }
466 },
467 "required": ["action"],
468 "allOf": [
469 {
470 "if": {
471 "properties": { "action": { "const": "create" } },
472 "required": ["action"]
473 },
474 "then": {
475 "required": ["items"]
476 }
477 },
478 {
479 "if": {
480 "properties": { "action": { "const": "update" } },
481 "required": ["action"]
482 },
483 "then": {
484 "anyOf": [
485 {
486 "required": ["index", "status"],
487 "not": {
488 "anyOf": [
489 { "required": ["items"] },
490 { "required": ["index_path"] }
491 ]
492 }
493 },
494 {
495 "required": ["index_path", "status"],
496 "not": {
497 "anyOf": [
498 { "required": ["items"] },
499 { "required": ["index"] }
500 ]
501 }
502 },
503 {
504 "required": ["items"],
505 "not": {
506 "anyOf": [
507 { "required": ["index"] },
508 { "required": ["index_path"] },
509 { "required": ["status"] }
510 ]
511 }
512 }
513 ]
514 }
515 },
516 {
517 "if": {
518 "properties": { "index": { "const": 0 } },
519 "required": ["index"]
520 },
521 "then": {
522 "properties": {
523 "action": { "const": "update" },
524 "status": { "const": "completed" }
525 },
526 "required": ["action", "status"]
527 }
528 },
529 {
530 "if": {
531 "properties": { "action": { "enum": ["create", "list", "add"] } },
532 "required": ["action"]
533 },
534 "then": {
535 "not": {
536 "anyOf": [
537 { "required": ["index"] },
538 { "required": ["index_path"] }
539 ]
540 }
541 }
542 },
543 {
544 "if": {
545 "properties": { "action": { "const": "add" } },
546 "required": ["action"]
547 },
548 "then": {
549 "required": ["description"]
550 }
551 }
552 ]
553 })
554}
555
556pub(crate) fn task_tracker_description_for_workflow(planning_active: bool) -> &'static str {
557 if planning_active {
558 "Adaptive task tracking for planning. Persists hierarchical plan progress under .vtcode/plans/<plan>.tasks.md and mirrors updates to .vtcode/tasks/current_task.md. Actions: create, update, list, add. Calling action=create again replaces the plan task list and its progress; use action=update or action=add to change it. For action=update, planning item indices are positive 1-based flat or hierarchical index_path values; index: 0 is invalid. Use items only to replace the full checklist with descriptions and explicit statuses, not indexed updates or JSON-encoded strings."
559 } else {
560 "Track task progress through a single checklist API (action: create | update | list | add). Use with action=create at the start of a multi-step plan; action=update as work progresses; action=list to review current state. For action=update, item indices are 1-based; standard checklist-level completion alone may use index: 0 with status: completed. Planning workflow accepts only positive flat or hierarchical index paths. Use items only to replace the full checklist with descriptions and explicit statuses, not indexed updates or JSON-encoded strings. Calling action=create again replaces the current checklist and its progress, unless the title and items match the active checklist, in which case the call is a no-op; use action=update or action=add to change an existing checklist. Tracker state mirrors between .vtcode/tasks/current_task.md and active plan sidecar files when available."
561 }
562}
563
564pub(crate) fn task_tracker_parameter_schema_for_workflow(planning_active: bool) -> Value {
565 if planning_active {
566 super::planning_task_tracker::planning_task_tracker_parameter_schema()
567 } else {
568 standard_task_tracker_parameter_schema()
569 }
570}
571
572pub const TASK_TRACKER_ARGUMENT_CORRECTION: &str = "Invalid task_tracker shape. For create, index/index_path are forbidden (prefer title/items + optional notes); action='update' needs index|index_path + status OR items for bulk sync; action='add' needs description; action='list' takes no index fields. Fix the named field(s) and retry once.";
586
587pub(crate) fn is_task_tracker_shape_error(error_msg: &str) -> bool {
593 const MARKERS: [&str; 6] = [
594 "must not include",
595 "does not match any allowed shape",
596 "matches more than one allowed shape",
597 "is forbidden by schema",
598 "missing required property",
599 "failed validation (schema",
600 ];
601 MARKERS.iter().any(|marker| error_msg.contains(marker))
602}
603
604impl TaskTrackerTool {
605 pub fn new(workspace_root: PathBuf, planning_workflow_state: PlanningWorkflowState) -> Self {
606 Self {
607 workspace_root,
608 planning_workflow_state,
609 checklist: Arc::new(RwLock::new(None)),
610 }
611 }
612
613 fn tasks_dir(&self) -> PathBuf {
614 self.workspace_root.join(".vtcode").join("tasks")
615 }
616
617 fn task_file(&self) -> PathBuf {
618 self.tasks_dir().join("current_task.md")
619 }
620
621 fn display_path(&self, path: &Path) -> String {
622 workspace_relative_display(&self.workspace_root, path)
623 }
624
625 async fn plan_task_file(&self) -> Option<PathBuf> {
626 let plan_file = self.planning_workflow_state.get_plan_file().await?;
627 tracker_file_for_plan_file(&plan_file)
628 }
629
630 async fn save_checklist(&self, checklist: &TaskChecklist) -> Result<()> {
631 let dir = self.tasks_dir();
632 ensure_dir_exists(&dir)
633 .await
634 .with_context(|| format!("Failed to create tasks directory: {}", dir.display()))?;
635 let md = checklist.to_markdown();
636 write_file_with_context(&self.task_file(), &md, "task checklist")
637 .await
638 .with_context(|| "Failed to write task checklist")?;
639 Ok(())
640 }
641
642 async fn save_plan_mirror_to_file(&self, tracker_file: &Path, checklist: &TaskChecklist) -> Result<()> {
643 if let Some(parent) = tracker_file.parent() {
644 ensure_dir_exists(parent)
645 .await
646 .with_context(|| format!("Failed to create plan tracker directory: {}", parent.display()))?;
647 }
648 write_file_with_context(tracker_file, &checklist.to_plan_markdown(), "plan task tracker file")
649 .await
650 .with_context(|| format!("Failed to write plan task tracker file: {}", tracker_file.display()))?;
651 if let Some(plan_file) = plan_file_for_tracker_file(tracker_file)
652 && tokio::fs::try_exists(&plan_file).await.unwrap_or(false)
653 {
654 sync_tracker_into_plan_file(&plan_file, &checklist.to_plan_markdown()).await?;
655 }
656 Ok(())
657 }
658
659 async fn save_plan_mirror(&self, checklist: &TaskChecklist) -> Result<()> {
660 let Some(tracker_file) = self.plan_task_file().await else {
661 return Ok(());
662 };
663 self.save_plan_mirror_to_file(&tracker_file, checklist).await?;
664 Ok(())
665 }
666
667 async fn load_global_checklist(&self) -> Result<Option<TaskChecklist>> {
668 let file = self.task_file();
669 if !tokio::fs::try_exists(&file).await.unwrap_or(false) {
670 return Ok(None);
671 }
672 let content = read_file_with_context(&file, "task checklist").await?;
673
674 let mut title = String::new();
675 let mut items = Vec::new();
676 let mut notes_lines = Vec::new();
677 let mut in_notes = false;
678 let mut in_verify_block = false;
679 let mut idx = 1;
680
681 for line in content.lines() {
682 let trimmed = line.trim();
683 if trimmed.starts_with("# ") && title.is_empty() {
684 title = trimmed.strip_prefix("# ").unwrap_or(trimmed).to_string();
685 continue;
686 }
687 if trimmed == "## Notes" {
688 in_notes = true;
689 continue;
690 }
691 if in_notes {
692 notes_lines.push(line.to_string());
693 continue;
694 }
695 if let Some(last) = items.last_mut() {
696 let indent = line.chars().take_while(|c| *c == ' ').count();
697 if indent >= 2 && apply_task_metadata_line(last, line, &mut in_verify_block) {
698 continue;
699 }
700 in_verify_block = false;
701 }
702 if let Some(rest) = trimmed.strip_prefix("- ")
703 && let Some((status, description)) = parse_marked_status_prefix(rest)
704 {
705 items.push(TaskItem {
706 index: idx,
707 description,
708 status,
709 metadata: TaskStepMetadata::default(),
710 });
711 idx += 1;
712 in_verify_block = false;
713 }
714 }
715
716 if title.is_empty() && items.is_empty() {
717 return Ok(None);
718 }
719
720 let notes = if notes_lines.is_empty() {
721 None
722 } else {
723 Some(notes_lines.join("\n").trim().to_string())
724 };
725
726 Ok(Some(TaskChecklist { title, items, notes }))
727 }
728
729 async fn load_plan_checklist_from(&self, tracker_file: &Path) -> Result<Option<TaskChecklist>> {
730 if !tokio::fs::try_exists(tracker_file).await.unwrap_or(false) {
731 return Ok(None);
732 }
733 let content = read_file_with_context(tracker_file, "plan task tracker file").await?;
734 Ok(parse_plan_mirror_markdown(&content))
735 }
736
737 async fn load_preferred_checklist(&self) -> Result<Option<TaskChecklist>> {
738 let task_file = self.task_file();
739 let plan_file = self.plan_task_file().await;
740
741 let global_exists = tokio::fs::try_exists(&task_file).await.unwrap_or(false);
742 let plan_exists = match plan_file.as_ref() {
743 Some(path) => tokio::fs::try_exists(path).await.unwrap_or(false),
744 None => false,
745 };
746
747 if !global_exists && !plan_exists {
748 return Ok(None);
749 }
750
751 let selected = if global_exists && plan_exists {
752 let global_modified = tokio::fs::metadata(&task_file).await.ok().and_then(|meta| meta.modified().ok());
753 let plan_modified = match &plan_file {
754 Some(path) => tokio::fs::metadata(path).await.ok().and_then(|meta| meta.modified().ok()),
755 None => None,
756 };
757 newer_source(global_modified, plan_modified, self.planning_workflow_state.is_active())
758 } else if plan_exists {
759 TrackerSource::Plan
760 } else {
761 TrackerSource::Global
762 };
763
764 let loaded = match selected {
765 TrackerSource::Global => self.load_global_checklist().await?,
766 TrackerSource::Plan => {
767 if let Some(path) = plan_file.as_ref() {
768 self.load_plan_checklist_from(path).await?
769 } else {
770 None
771 }
772 }
773 };
774
775 if let Some(checklist) = loaded.as_ref() {
776 match selected {
777 TrackerSource::Global => {
778 if let Some(path) = plan_file.as_ref() {
779 self.save_plan_mirror_to_file(path, checklist).await?;
780 }
781 }
782 TrackerSource::Plan => {
783 self.save_checklist(checklist).await?;
784 }
785 }
786 }
787
788 Ok(loaded)
789 }
790
791 async fn ensure_checklist_loaded(&self) -> Result<()> {
792 let loaded = self.load_preferred_checklist().await?;
793 let mut guard = self.checklist.write().await;
794 *guard = loaded;
795 Ok(())
796 }
797
798 async fn persist_edit_mode_snapshot(&self, checklist: &TaskChecklist) -> Result<()> {
799 self.save_checklist(checklist).await?;
800 self.save_plan_mirror(checklist).await?;
801 Ok(())
802 }
803
804 async fn persist_and_build_view(&self, checklist: &TaskChecklist) -> Result<(Value, Value)> {
805 self.persist_edit_mode_snapshot(checklist).await?;
806 Ok((checklist.summary(), checklist.view()))
807 }
808
809 fn to_plan_args(args: &TaskTrackerArgs) -> PlanningTaskTrackerArgs {
810 PlanningTaskTrackerArgs {
811 action: args.action.clone(),
812 title: args.title.clone(),
813 items: args.items.clone(),
814 index: args.index,
815 index_path: args.index_path.clone(),
816 status: args.status.clone(),
817 description: args.description.clone(),
818 files: args.files.clone(),
819 outcome: args.outcome.clone(),
820 verify: args.verify.clone(),
821 parent_index_path: args.parent_index_path.clone(),
822 notes: args.notes.clone(),
823 }
824 }
825
826 async fn execute_in_planning_workflow(&self, args: &TaskTrackerArgs) -> Result<Value> {
827 let plan_tool = PlanningTaskTrackerTool::new(self.planning_workflow_state.clone());
828 let mapped = Self::to_plan_args(args);
829 let output = plan_tool.execute(serde_json::to_value(mapped)?).await?;
830 self.ensure_checklist_loaded().await?;
831
832 Ok(output)
833 }
834
835 fn check_create_idempotency(
840 existing: &TaskChecklist,
841 title: &str,
842 items: &[TaskItem],
843 task_file_display: &str,
844 ) -> Option<Value> {
845 let same_structure = existing.title == title
846 && existing.items.len() == items.len()
847 && existing
848 .items
849 .iter()
850 .zip(items.iter())
851 .all(|(left, right)| left.description == right.description);
852 let requested_has_explicit_status = items.iter().any(|item| item.status != TaskStatus::Pending);
853 let requested_has_step_metadata = items.iter().any(|item| {
854 !item.metadata.files.is_empty() || item.metadata.outcome.is_some() || !item.metadata.verify.is_empty()
855 });
856
857 let unchanged = if same_structure && !requested_has_explicit_status && !requested_has_step_metadata {
858 Some("Checklist already active; preserved current progress.")
859 } else if existing.title == title
860 && existing.items.len() == items.len()
861 && existing.items.iter().zip(items.iter()).all(|(l, r)| l == r)
862 {
863 Some("Requested checklist already matches current tracker state.")
864 } else {
865 None
866 };
867
868 unchanged.map(|message| {
869 json!({
870 "status": "unchanged",
871 "message": message,
872 "task_file": task_file_display,
873 "checklist": existing.summary(),
874 "view": existing.view()
875 })
876 })
877 }
878
879 async fn handle_create(&self, args: &TaskTrackerArgs) -> Result<Value> {
880 let title = args.title.as_deref().unwrap_or("Task Checklist").to_string();
881 let item_descs = args.items.as_deref().unwrap_or(&[]);
882 if item_descs.is_empty() {
883 anyhow::bail!("At least one item is required for 'create'. Provide items: [\"step 1\", \"step 2\", ...]");
884 }
885
886 let items = parse_input_items(item_descs)?;
887 if items.is_empty() {
888 anyhow::bail!("No valid task items were provided for create.");
889 }
890 let notes = append_notes(None, args.notes.as_deref());
891
892 self.ensure_checklist_loaded().await?;
893 let guard = self.checklist.write().await;
894 let mut existing_item_count = 0usize;
895 if let Some(existing) = guard.as_ref() {
896 existing_item_count = existing.items.len();
897 if let Some(unchanged) =
898 Self::check_create_idempotency(existing, &title, &items, &self.display_path(&self.task_file()))
899 {
900 return Ok(unchanged);
901 }
902 }
903
904 let checklist = TaskChecklist { title, items, notes };
905
906 drop(guard);
907 let (summary, view) = self.persist_and_build_view(&checklist).await?;
908 let mut guard = self.checklist.write().await;
909 *guard = Some(checklist);
910
911 let (status, message) = if existing_item_count > 0 {
912 (
913 "replaced",
914 format!("Previous checklist replaced with new structure (was {existing_item_count} items).",),
915 )
916 } else {
917 ("created", "Task checklist created successfully.".to_string())
918 };
919
920 Ok(json!({
921 "status": status,
922 "message": message,
923 "task_file": self.display_path(&self.task_file()),
924 "checklist": summary,
925 "view": view
926 }))
927 }
928
929 async fn handle_update(&self, args: &TaskTrackerArgs) -> Result<Value> {
930 validate_update_shape(args.items.as_deref(), args.index, args.index_path.as_deref(), args.status.as_deref())?;
931 let is_bulk_update =
932 is_bulk_sync_update(args.items.as_deref(), args.index, args.index_path.as_deref(), args.status.as_deref());
933 self.ensure_checklist_loaded().await?;
934 let mut guard = self.checklist.write().await;
935 if is_bulk_update {
936 let input_items = args.items.as_deref().unwrap_or(&[]);
937 let items = parse_input_items(input_items)?;
938 if items.is_empty() {
939 anyhow::bail!("No valid items provided for checklist sync.");
940 }
941
942 let title = args
943 .title
944 .clone()
945 .or_else(|| guard.as_ref().map(|checklist| checklist.title.clone()))
946 .unwrap_or_else(|| "Task Checklist".to_string());
947
948 let checklist = guard.get_or_insert(TaskChecklist {
949 title: title.clone(),
950 items: Vec::new(),
951 notes: None,
952 });
953
954 checklist.title = title;
955 checklist.items = items;
956 checklist.notes = append_notes(checklist.notes.take(), args.notes.as_deref());
957 let snapshot = checklist.clone();
958 drop(guard);
959 let (summary, view) = self.persist_and_build_view(&snapshot).await?;
960 return Ok(json!({
961 "status": "updated",
962 "message": "Checklist synchronized from provided items.",
963 "checklist": summary,
964 "view": view
965 }));
966 }
967
968 let checklist = guard.as_mut().context("No active checklist. Use action='create' first.")?;
969
970 let index = match (args.index, args.index_path.as_deref()) {
971 (Some(idx), _) => idx,
972 (None, Some(path)) => parse_single_index_from_path(path)?,
973 (None, None) => {
974 bail!(
975 "'index' is required for 'update' (1-indexed), or provide 'index_path' for Planning workflow updates, or 'items' for bulk sync"
976 )
977 }
978 };
979
980 let status_str = args
981 .status
982 .as_deref()
983 .context("'status' is required for 'update' (pending|in_progress|completed|blocked), or provide 'items' for bulk sync")?;
984
985 let new_status = TaskStatus::from_str(status_str)?;
986
987 if index == 0 {
988 if new_status != TaskStatus::Completed {
989 bail!("index 0 is reserved for checklist-level completion; individual item indices are 1-indexed");
990 }
991
992 if let Some(outcome) = normalize_optional_text(args.outcome.as_deref()) {
993 let checklist_outcome = format!("Checklist outcome: {outcome}");
994 checklist.notes = append_notes(checklist.notes.take(), Some(checklist_outcome.as_str()));
995 }
996 checklist.notes = append_notes(checklist.notes.take(), args.notes.as_deref());
997
998 let snapshot = checklist.clone();
999 drop(guard);
1000 let (summary, view) = self.persist_and_build_view(&snapshot).await?;
1001
1002 return Ok(json!({
1003 "status": "updated",
1004 "message": "Checklist-level completion acknowledged; checklist progress remains derived from item statuses.",
1005 "checklist": summary,
1006 "view": view
1007 }));
1008 }
1009
1010 let item_count = checklist.items.len();
1011 let pos = checklist
1012 .items
1013 .iter()
1014 .position(|i| i.index == index)
1015 .with_context(|| format!("No item at index {index}. Valid range: 1-{item_count}"))?;
1016
1017 let old_status = checklist.items[pos].status.to_string();
1018 checklist.items[pos].status = new_status;
1019 let new_status_str = checklist.items[pos].status.to_string();
1020 if let Some(files) = args.files.as_deref() {
1021 checklist.items[pos].metadata.files = normalize_string_items(Some(files));
1022 }
1023 if args.outcome.is_some() {
1024 checklist.items[pos].metadata.outcome = normalize_optional_text(args.outcome.as_deref());
1025 }
1026 if let Some(verify) = args.verify.as_deref() {
1027 checklist.items[pos].metadata.verify = normalize_string_items(Some(verify));
1028 }
1029 checklist.notes = append_notes(checklist.notes.take(), args.notes.as_deref());
1030
1031 let snapshot = checklist.clone();
1032 drop(guard);
1033 let (summary, view) = self.persist_and_build_view(&snapshot).await?;
1034
1035 Ok(json!({
1036 "status": "updated",
1037 "message": format!("Item {} status changed: {} → {}", index, old_status, new_status_str),
1038 "checklist": summary,
1039 "view": view
1040 }))
1041 }
1042
1043 async fn handle_list(&self) -> Result<Value> {
1044 self.ensure_checklist_loaded().await?;
1045 let guard = self.checklist.read().await;
1046
1047 match guard.as_ref() {
1048 Some(checklist) => Ok(json!({
1049 "status": "ok",
1050 "checklist": checklist.summary(),
1051 "view": checklist.view()
1052 })),
1053 None => Ok(json!({
1054 "status": "empty",
1055 "message": "No active checklist. Use action='create' to start one."
1056 })),
1057 }
1058 }
1059
1060 async fn handle_add(&self, args: &TaskTrackerArgs) -> Result<Value> {
1061 if let Some(parent_path) = args.parent_index_path.as_deref()
1062 && !parent_path.trim().is_empty()
1063 {
1064 bail!(
1065 "'parent_index_path' is only supported for hierarchical Planning workflow updates. Use Planning workflow or omit parent_index_path for standard task-tracker updates."
1066 );
1067 }
1068
1069 self.ensure_checklist_loaded().await?;
1070 let mut guard = self.checklist.write().await;
1071 let checklist = guard.as_mut().context("No active checklist. Use action='create' first.")?;
1072
1073 let desc = args.description.as_deref().context("'description' is required for 'add'")?;
1074 let (status, parsed_description) = parse_status_prefix(desc);
1075 let description = parsed_description.trim().to_string();
1076 if description.is_empty() {
1077 bail!("description cannot be empty");
1078 }
1079
1080 let new_index = checklist.items.len() + 1;
1081 checklist.items.push(TaskItem {
1082 index: new_index,
1083 description: description.clone(),
1084 status,
1085 metadata: metadata_from_input(args.files.as_deref(), args.outcome.as_deref(), args.verify.as_deref()),
1086 });
1087
1088 checklist.notes = append_notes(checklist.notes.take(), args.notes.as_deref());
1089 let snapshot = checklist.clone();
1090 drop(guard);
1091 let (summary, view) = self.persist_and_build_view(&snapshot).await?;
1092
1093 Ok(json!({
1094 "status": "added",
1095 "message": format!("Added item {}: {}", new_index, description),
1096 "checklist": summary,
1097 "view": view
1098 }))
1099 }
1100}
1101
1102#[async_trait]
1103impl Tool for TaskTrackerTool {
1104 async fn execute(&self, args: Value) -> Result<Value> {
1105 let args: TaskTrackerArgs = deserialize_tool_args(&args, "task_tracker")?;
1106 validate_action_index_fields(&args.action, args.index, args.index_path.as_deref())?;
1107
1108 if self.planning_workflow_state.is_active() {
1109 return self.execute_in_planning_workflow(&args).await;
1110 }
1111
1112 match args.action.as_str() {
1113 "create" => self.handle_create(&args).await,
1114 "update" => self.handle_update(&args).await,
1115 "list" => self.handle_list().await,
1116 "add" => self.handle_add(&args).await,
1117 other => Ok(json!({
1118 "status": "error",
1119 "message": format!("Unknown action '{}'. Use: create, update, list, add", other)
1120 })),
1121 }
1122 }
1123
1124 fn name(&self) -> &str {
1125 tools::TASK_TRACKER
1126 }
1127
1128 fn description(&self) -> &str {
1129 task_tracker_description_for_workflow(self.planning_workflow_state.is_active())
1130 }
1131
1132 fn parameter_schema(&self) -> Option<Value> {
1133 Some(task_tracker_parameter_schema_for_workflow(self.planning_workflow_state.is_active()))
1134 }
1135
1136 fn is_mutating(&self) -> bool {
1137 false }
1139
1140 fn is_parallel_safe(&self) -> bool {
1141 false }
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147 use super::*;
1148 use tempfile::TempDir;
1149
1150 fn setup_tool(temp: &TempDir) -> (PlanningWorkflowState, TaskTrackerTool) {
1151 let state = PlanningWorkflowState::new(temp.path().to_path_buf());
1152 let tool = TaskTrackerTool::new(temp.path().to_path_buf(), state.clone());
1153 (state, tool)
1154 }
1155
1156 #[tokio::test]
1157 async fn test_create_checklist() {
1158 let temp = TempDir::new().unwrap();
1159 let (_state, tool) = setup_tool(&temp);
1160
1161 let result = tool
1162 .execute(json!({
1163 "action": "create",
1164 "title": "Refactor Auth",
1165 "items": ["Extract middleware", "Add tests", "Update docs"]
1166 }))
1167 .await
1168 .unwrap();
1169
1170 assert_eq!(result["status"], "created");
1171 assert_eq!(result["checklist"]["total"], 3);
1172 assert_eq!(result["checklist"]["completed"], 0);
1173 assert_eq!(result["view"]["title"], "Refactor Auth");
1174 assert_eq!(result["task_file"], ".vtcode/tasks/current_task.md");
1175 }
1176
1177 #[tokio::test]
1178 async fn test_create_accepts_metadata_and_verify_string_forms() {
1179 let temp = TempDir::new().unwrap();
1180 let (_state, tool) = setup_tool(&temp);
1181
1182 let result = tool
1183 .execute(json!({
1184 "action": "create",
1185 "title": "Harness tracker",
1186 "items": [
1187 {
1188 "description": "Analyze current harness",
1189 "files": ["docs/ARCHITECTURE.md"],
1190 "outcome": "Document the harness map",
1191 "verify": "cargo check"
1192 },
1193 {
1194 "description": "Wire continuation",
1195 "verify": ["cargo test -p vtcode-core continuation", "cargo check -p vtcode"]
1196 }
1197 ]
1198 }))
1199 .await
1200 .unwrap();
1201
1202 assert_eq!(result["checklist"]["items"][0]["files"], json!(["docs/ARCHITECTURE.md"]));
1203 assert_eq!(result["checklist"]["items"][0]["outcome"], "Document the harness map");
1204 assert_eq!(result["checklist"]["items"][0]["verify"], json!(["cargo check"]));
1205 assert_eq!(
1206 result["checklist"]["items"][1]["verify"],
1207 json!(["cargo test -p vtcode-core continuation", "cargo check -p vtcode"])
1208 );
1209
1210 let persisted = std::fs::read_to_string(temp.path().join(".vtcode/tasks/current_task.md")).unwrap();
1211 assert!(persisted.contains("files: docs/ARCHITECTURE.md"));
1212 assert!(persisted.contains("outcome: Document the harness map"));
1213 assert!(persisted.contains("verify: cargo check"));
1214 }
1215
1216 #[tokio::test]
1217 async fn test_update_item() {
1218 let temp = TempDir::new().unwrap();
1219 let (_state, tool) = setup_tool(&temp);
1220
1221 tool.execute(json!({
1222 "action": "create",
1223 "title": "Test",
1224 "items": ["Step 1", "Step 2"]
1225 }))
1226 .await
1227 .unwrap();
1228
1229 let result = tool
1230 .execute(json!({
1231 "action": "update",
1232 "index": 1,
1233 "status": "completed"
1234 }))
1235 .await
1236 .unwrap();
1237
1238 assert_eq!(result["status"], "updated");
1239 assert_eq!(result["checklist"]["completed"], 1);
1240 assert_eq!(result["checklist"]["progress_percent"], 50);
1241 }
1242
1243 #[tokio::test]
1244 async fn test_update_index_zero_allows_checklist_completion_note() {
1245 let temp = TempDir::new().unwrap();
1246 let (_state, tool) = setup_tool(&temp);
1247
1248 tool.execute(json!({
1249 "action": "create",
1250 "title": "Test",
1251 "items": ["Step 1", "Step 2"]
1252 }))
1253 .await
1254 .unwrap();
1255
1256 let result = tool
1257 .execute(json!({
1258 "action": "update",
1259 "index": 0,
1260 "status": "completed",
1261 "outcome": "Reported summary to user"
1262 }))
1263 .await
1264 .unwrap();
1265
1266 assert_eq!(result["status"], "updated");
1267 assert_eq!(result["checklist"]["completed"], 0);
1268 assert_eq!(result["checklist"]["notes"], "Checklist outcome: Reported summary to user");
1269 }
1270
1271 #[test]
1272 fn task_tracker_schema_describes_action_aware_indices() {
1273 let (_state, tool) = setup_tool(&TempDir::new().unwrap());
1274 let schema = tool.parameter_schema().expect("task tracker schema");
1275
1276 assert_eq!(schema["properties"]["index"]["minimum"], 0);
1277 let items_description = schema["properties"]["items"]["description"].as_str().unwrap();
1278 assert!(items_description.contains("Full checklist replacement"));
1279 assert!(items_description.contains("never JSON-encoded strings"));
1280 assert_eq!(schema["properties"]["index_path"]["pattern"], "^[1-9][0-9]*$");
1281 let description = schema["properties"]["index"]["description"]
1282 .as_str()
1283 .expect("index description");
1284 assert!(description.contains("index: 0"));
1285 assert!(description.contains("action: update"));
1286 assert!(description.contains("completed"));
1287 assert_eq!(schema["allOf"][2]["then"]["properties"]["status"]["const"], "completed");
1288 }
1289
1290 #[test]
1291 fn standard_task_tracker_schema_rejects_zero_except_legacy_completion() {
1292 let schema = standard_task_tracker_parameter_schema();
1293 let valid_cases = [
1294 json!({"action": "update", "index": 0, "status": "completed"}),
1295 json!({"action": "update", "index": 1, "status": "completed"}),
1296 json!({"action": "update", "items": ["[x] Done"]}),
1297 ];
1298 let invalid_cases = [
1299 json!({"action": "list", "index": 0}),
1300 json!({"action": "list", "index": 1}),
1301 json!({"action": "list", "index_path": "1"}),
1302 json!({"action": "add", "index": 0, "description": "New item"}),
1303 json!({"action": "add", "index": 1, "description": "New item"}),
1304 json!({"action": "create", "index": 1, "items": ["New item"]}),
1305 json!({"action": "update", "index": 0, "status": "pending"}),
1306 ];
1307
1308 for args in valid_cases {
1309 assert!(jsonschema::validate(&schema, &args).is_ok(), "expected valid args: {args}");
1310 }
1311 for args in invalid_cases {
1312 assert!(jsonschema::validate(&schema, &args).is_err(), "expected invalid args: {args}");
1313 }
1314 }
1315
1316 #[test]
1317 fn standard_task_tracker_schema_rejects_mixed_bulk_and_single_updates() {
1318 let schema = standard_task_tracker_parameter_schema();
1319 let invalid_cases = [
1320 json!({"action": "update", "items": ["Done"], "index": 1, "status": "completed"}),
1321 json!({"action": "update", "items": ["Done"], "index_path": "1", "status": "completed"}),
1322 json!({"action": "update", "index": 1, "index_path": "1", "status": "completed"}),
1323 json!({"action": "update", "items": ["Done"], "status": "completed"}),
1324 json!({"action": "update", "items": ["Done"], "index": 0, "status": "completed"}),
1325 ];
1326
1327 for args in invalid_cases {
1328 assert!(jsonschema::validate(&schema, &args).is_err(), "expected invalid args: {args}");
1329 }
1330 }
1331
1332 #[tokio::test]
1333 async fn bulk_update_rejects_encoded_commands_without_resetting_completed_steps() {
1334 let temp = TempDir::new().unwrap();
1335 let (_state, tool) = setup_tool(&temp);
1336 tool.execute(json!({
1337 "action": "create",
1338 "title": "Preserve progress",
1339 "items": [
1340 {"description": "Update introduction", "status": "completed", "files": ["README.md"], "outcome": "Clear overview", "verify": ["review headings"]},
1341 {"description": "Review links", "status": "blocked", "outcome": "Await network"}
1342 ]
1343 })).await.unwrap();
1344 let tracker_path = temp.path().join(".vtcode/tasks/current_task.md");
1345 let persisted_before = std::fs::read_to_string(&tracker_path).unwrap();
1346 let before = tool.execute(json!({"action": "list"})).await.unwrap();
1347
1348 for (action, encoded) in [
1349 ("update", r#"{"index_path":"1","status":"completed","outcome":"Done","verify":"review headings"}"#),
1350 ("update", r#" [x] {"index_path":"1","status":"completed"}"#),
1351 ("create", r#"{"index":1,"status":"completed"}"#),
1352 ] {
1353 let error = tool
1354 .execute(json!({
1355 "action": action,
1356 "title": "Incorrect replacement",
1357 "items": ["Replacement must not leak", encoded]
1358 }))
1359 .await
1360 .expect_err("encoded updates must not replace the tracker");
1361 assert!(error.to_string().contains("JSON-encoded task updates"));
1362 assert!(
1363 error
1364 .to_string()
1365 .contains(r#"{"action":"update","index_path":"1","status":"completed"}"#)
1366 );
1367 assert_eq!(tool.execute(json!({"action": "list"})).await.unwrap(), before);
1368 assert_eq!(std::fs::read_to_string(&tracker_path).unwrap(), persisted_before);
1369 }
1370
1371 tool.execute(json!({"action": "update", "index_path": "2", "status": "completed"}))
1372 .await
1373 .unwrap();
1374 let after = tool.execute(json!({"action": "list"})).await.unwrap();
1375 assert_eq!(after["checklist"]["completed"], 2);
1376 assert_eq!(after["checklist"]["items"][0]["description"], "Update introduction");
1377 assert_eq!(after["checklist"]["items"][0]["files"], json!(["README.md"]));
1378 }
1379
1380 #[tokio::test]
1381 async fn update_rejects_mixed_bulk_and_single_fields_before_mutation() {
1382 let temp = TempDir::new().unwrap();
1383 let (_state, tool) = setup_tool(&temp);
1384
1385 tool.execute(json!({
1386 "action": "create",
1387 "title": "Test",
1388 "items": ["Original"]
1389 }))
1390 .await
1391 .unwrap();
1392
1393 let error = tool
1394 .execute(json!({
1395 "action": "update",
1396 "items": ["Replacement"],
1397 "index": 1,
1398 "status": "completed"
1399 }))
1400 .await
1401 .expect_err("mixed update must fail closed");
1402 assert!(error.to_string().contains("cannot combine 'items'"));
1403
1404 let result = tool.execute(json!({"action": "list"})).await.unwrap();
1405 assert_eq!(result["checklist"]["items"][0]["description"], "Original");
1406 assert_eq!(result["checklist"]["items"][0]["status"], "pending");
1407 }
1408
1409 #[tokio::test]
1410 async fn update_rejects_both_index_forms_before_mutation() {
1411 let temp = TempDir::new().unwrap();
1412 let (_state, tool) = setup_tool(&temp);
1413
1414 tool.execute(json!({
1415 "action": "create",
1416 "title": "Test",
1417 "items": ["Original"]
1418 }))
1419 .await
1420 .unwrap();
1421
1422 let error = tool
1423 .execute(json!({
1424 "action": "update",
1425 "index": 1,
1426 "index_path": "1",
1427 "status": "completed"
1428 }))
1429 .await
1430 .expect_err("ambiguous index forms must fail closed");
1431 assert!(error.to_string().contains("cannot combine 'index' and 'index_path'"));
1432
1433 let result = tool.execute(json!({"action": "list"})).await.unwrap();
1434 assert_eq!(result["checklist"]["items"][0]["description"], "Original");
1435 assert_eq!(result["checklist"]["items"][0]["status"], "pending");
1436 }
1437
1438 #[tokio::test]
1439 async fn execute_rejects_indices_for_non_update_actions() {
1440 let temp = TempDir::new().unwrap();
1441 let (_state, tool) = setup_tool(&temp);
1442
1443 let invalid_inputs = [
1444 json!({"action": "list", "index": 1}),
1445 json!({"action": "create", "index_path": "1", "items": ["New item"]}),
1446 json!({"action": "add", "index": 1, "description": "New item"}),
1447 json!({"action": "unexpected", "index_path": "1"}),
1448 ];
1449
1450 for input in invalid_inputs {
1451 let error = tool.execute(input).await.expect_err("non-update index must fail closed");
1452 assert!(error.to_string().contains("cannot use 'index' or 'index_path'"), "unexpected error: {error}");
1453 }
1454 }
1455
1456 #[test]
1457 fn task_tracker_metadata_switches_with_planning_state() {
1458 let temp = TempDir::new().unwrap();
1459 let (state, tool) = setup_tool(&temp);
1460
1461 assert_eq!(tool.description(), task_tracker_description_for_workflow(false));
1462 assert_eq!(tool.parameter_schema().expect("standard schema")["properties"]["index"]["minimum"], 0);
1463
1464 state.enable();
1465
1466 assert_eq!(tool.description(), task_tracker_description_for_workflow(true));
1467 assert_eq!(tool.parameter_schema().expect("planning schema")["properties"]["index"]["minimum"], 1);
1468 }
1469
1470 #[tokio::test]
1471 async fn test_add_item() {
1472 let temp = TempDir::new().unwrap();
1473 let (_state, tool) = setup_tool(&temp);
1474
1475 tool.execute(json!({
1476 "action": "create",
1477 "title": "Test",
1478 "items": ["Step 1"]
1479 }))
1480 .await
1481 .unwrap();
1482
1483 let result = tool
1484 .execute(json!({
1485 "action": "add",
1486 "description": "Step 2"
1487 }))
1488 .await
1489 .unwrap();
1490
1491 assert_eq!(result["status"], "added");
1492 assert_eq!(result["checklist"]["total"], 2);
1493 }
1494
1495 #[tokio::test]
1496 async fn test_create_is_idempotent_for_same_structure() {
1497 let temp = TempDir::new().unwrap();
1498 let (_state, tool) = setup_tool(&temp);
1499
1500 tool.execute(json!({
1501 "action": "create",
1502 "title": "Clippy Warnings",
1503 "items": ["Fix A", "Fix B"]
1504 }))
1505 .await
1506 .unwrap();
1507
1508 tool.execute(json!({
1509 "action": "update",
1510 "index": 1,
1511 "status": "completed"
1512 }))
1513 .await
1514 .unwrap();
1515
1516 let duplicate = tool
1517 .execute(json!({
1518 "action": "create",
1519 "title": "Clippy Warnings",
1520 "items": ["Fix A", "Fix B"]
1521 }))
1522 .await
1523 .unwrap();
1524
1525 assert_eq!(duplicate["status"], "unchanged");
1526 assert_eq!(duplicate["checklist"]["completed"], 1);
1527 }
1528
1529 #[tokio::test]
1530 async fn test_create_with_new_structure_replaces_checklist() {
1531 let temp = TempDir::new().unwrap();
1532 let (_state, tool) = setup_tool(&temp);
1533
1534 tool.execute(json!({
1535 "action": "create",
1536 "title": "First",
1537 "items": ["Fix A", "Fix B"]
1538 }))
1539 .await
1540 .unwrap();
1541
1542 tool.execute(json!({
1543 "action": "update",
1544 "index": 1,
1545 "status": "completed"
1546 }))
1547 .await
1548 .unwrap();
1549
1550 let replaced = tool
1551 .execute(json!({
1552 "action": "create",
1553 "title": "Second",
1554 "items": ["Fix C"]
1555 }))
1556 .await
1557 .unwrap();
1558
1559 assert_eq!(replaced["status"], "replaced");
1560 assert_eq!(replaced["checklist"]["total"], 1);
1561 assert_eq!(replaced["checklist"]["completed"], 0);
1562 }
1563
1564 #[test]
1565 fn task_tracker_descriptions_state_that_repeat_create_replaces() {
1566 for planning_active in [false, true] {
1567 let description = task_tracker_description_for_workflow(planning_active);
1568 assert!(
1569 description.contains("Calling action=create again replaces"),
1570 "planning_active={planning_active}: {description}"
1571 );
1572 assert!(!description.contains("subsequent calls update"), "planning_active={planning_active}");
1573 }
1574 assert!(task_tracker_description_for_workflow(false).contains("no-op"));
1575 }
1576
1577 #[tokio::test]
1578 async fn test_update_supports_bulk_item_sync() {
1579 let temp = TempDir::new().unwrap();
1580 let (_state, tool) = setup_tool(&temp);
1581
1582 tool.execute(json!({
1583 "action": "create",
1584 "title": "Sync Test",
1585 "items": ["Step 1", "Step 2", "Step 3"]
1586 }))
1587 .await
1588 .unwrap();
1589
1590 let updated = tool
1591 .execute(json!({
1592 "action": "update",
1593 "items": ["[x] Step 1", "[~] Step 2", "[ ] Step 3"]
1594 }))
1595 .await
1596 .unwrap();
1597
1598 assert_eq!(updated["status"], "updated");
1599 assert_eq!(updated["checklist"]["completed"], 1);
1600 assert_eq!(updated["checklist"]["in_progress"], 1);
1601 assert_eq!(updated["checklist"]["pending"], 1);
1602 }
1603
1604 #[tokio::test]
1605 async fn test_list_empty() {
1606 let temp = TempDir::new().unwrap();
1607 let (_state, tool) = setup_tool(&temp);
1608
1609 let result = tool.execute(json!({"action": "list"})).await.unwrap();
1610 assert_eq!(result["status"], "empty");
1611 }
1612
1613 #[tokio::test]
1614 async fn test_persistence_across_loads() {
1615 let temp = TempDir::new().unwrap();
1616
1617 {
1618 let (_state, tool) = setup_tool(&temp);
1619 tool.execute(json!({
1620 "action": "create",
1621 "title": "Persist Test",
1622 "items": ["Alpha", "Beta"]
1623 }))
1624 .await
1625 .unwrap();
1626
1627 tool.execute(json!({
1628 "action": "update",
1629 "index": 1,
1630 "status": "completed"
1631 }))
1632 .await
1633 .unwrap();
1634 }
1635
1636 let (_state, tool2) = setup_tool(&temp);
1637 let result = tool2.execute(json!({"action": "list"})).await.unwrap();
1638
1639 assert_eq!(result["status"], "ok");
1640 assert_eq!(result["checklist"]["total"], 2);
1641 assert_eq!(result["checklist"]["completed"], 1);
1642 }
1643
1644 #[tokio::test]
1645 async fn test_planning_workflow_task_tracker_delegates_and_mirrors_global() {
1646 let temp = TempDir::new().unwrap();
1647 let (state, tool) = setup_tool(&temp);
1648
1649 let plans_dir = state.plans_dir();
1650 std::fs::create_dir_all(&plans_dir).unwrap();
1651 let plan_file = plans_dir.join("adaptive.md");
1652 std::fs::write(&plan_file, "# Adaptive\n").unwrap();
1653 state.set_plan_file(Some(plan_file)).await;
1654 state.enable();
1655
1656 let created = tool
1657 .execute(json!({
1658 "action": "create",
1659 "title": "Adaptive Plan",
1660 "items": ["Root task", " Child task"]
1661 }))
1662 .await
1663 .unwrap();
1664
1665 assert_eq!(created["status"], "created");
1666 assert_eq!(created["checklist"]["total"], 2);
1667
1668 let task_file = temp.path().join(".vtcode/tasks/current_task.md");
1669 let persisted = std::fs::read_to_string(task_file).unwrap();
1670 assert!(persisted.contains("Root task"));
1671 assert!(persisted.contains("Child task"));
1672 }
1673
1674 #[tokio::test]
1675 async fn test_planning_workflow_adaptive_tool_accepts_hierarchical_index_path() {
1676 let temp = TempDir::new().unwrap();
1677 let (state, tool) = setup_tool(&temp);
1678
1679 let plans_dir = state.plans_dir();
1680 std::fs::create_dir_all(&plans_dir).unwrap();
1681 let plan_file = plans_dir.join("hierarchical.md");
1682 std::fs::write(&plan_file, "# Hierarchical\n").unwrap();
1683 state.set_plan_file(Some(plan_file)).await;
1684 state.enable();
1685
1686 tool.execute(json!({
1687 "action": "create",
1688 "items": ["Parent task", " Child task"]
1689 }))
1690 .await
1691 .unwrap();
1692
1693 let updated = tool
1694 .execute(json!({
1695 "action": "update",
1696 "index_path": "1.1",
1697 "status": "completed"
1698 }))
1699 .await
1700 .unwrap();
1701
1702 assert_eq!(updated["status"], "updated");
1703 assert_eq!(updated["checklist"]["completed"], 1);
1704 }
1705
1706 #[tokio::test]
1707 async fn test_planning_workflow_adaptive_tool_rejects_index_zero() {
1708 let temp = TempDir::new().unwrap();
1709 let (state, tool) = setup_tool(&temp);
1710
1711 let plans_dir = state.plans_dir();
1712 std::fs::create_dir_all(&plans_dir).unwrap();
1713 let plan_file = plans_dir.join("reject-zero.md");
1714 std::fs::write(&plan_file, "# Reject zero\n").unwrap();
1715 state.set_plan_file(Some(plan_file)).await;
1716 state.enable();
1717
1718 tool.execute(json!({
1719 "action": "create",
1720 "items": ["Parent task"]
1721 }))
1722 .await
1723 .unwrap();
1724
1725 let error = tool
1726 .execute(json!({
1727 "action": "update",
1728 "index": 0,
1729 "status": "completed"
1730 }))
1731 .await
1732 .expect_err("planning index zero must be rejected");
1733
1734 assert!(error.to_string().contains("index_path components must be >= 1"));
1735 }
1736
1737 #[tokio::test]
1738 async fn test_planning_workflow_mirror_preserves_notes() {
1739 let temp = TempDir::new().unwrap();
1740 let (state, tool) = setup_tool(&temp);
1741
1742 let plans_dir = state.plans_dir();
1743 std::fs::create_dir_all(&plans_dir).unwrap();
1744 let plan_file = plans_dir.join("notes.md");
1745 std::fs::write(&plan_file, "# Notes\n").unwrap();
1746 state.set_plan_file(Some(plan_file)).await;
1747 state.enable();
1748
1749 tool.execute(json!({
1750 "action": "create",
1751 "items": ["Root task"],
1752 "notes": "Keep this note"
1753 }))
1754 .await
1755 .unwrap();
1756
1757 let task_file = temp.path().join(".vtcode/tasks/current_task.md");
1758 let persisted = std::fs::read_to_string(task_file).unwrap();
1759 assert!(persisted.contains("## Notes"));
1760 assert!(persisted.contains("Keep this note"));
1761 }
1762
1763 #[tokio::test]
1764 async fn test_edit_mode_prefers_newer_plan_mirror_when_present() {
1765 let temp = TempDir::new().unwrap();
1766 let (state, tool) = setup_tool(&temp);
1767
1768 let plans_dir = state.plans_dir();
1769 std::fs::create_dir_all(&plans_dir).unwrap();
1770 let plan_file = plans_dir.join("freshness.md");
1771 std::fs::write(&plan_file, "# Freshness\n").unwrap();
1772 state.set_plan_file(Some(plan_file.clone())).await;
1773
1774 let global_file = temp.path().join(".vtcode/tasks/current_task.md");
1775 std::fs::create_dir_all(global_file.parent().unwrap()).unwrap();
1776 std::fs::write(&global_file, "# Freshness\n\n- [ ] stale global\n").unwrap();
1777
1778 std::thread::sleep(std::time::Duration::from_millis(15));
1779
1780 let sidecar = plans_dir.join("freshness.tasks.md");
1781 std::fs::write(&sidecar, "# Freshness\n\n## Plan of Work\n\n- [x] newer plan\n").unwrap();
1782
1783 let listed = tool.execute(json!({"action": "list"})).await.unwrap();
1784 assert_eq!(listed["status"], "ok");
1785 assert_eq!(listed["checklist"]["completed"], 1);
1786 assert_eq!(listed["checklist"]["pending"], 0);
1787
1788 let global_synced = std::fs::read_to_string(global_file).unwrap();
1789 assert!(global_synced.contains("newer plan"));
1790 }
1791
1792 #[tokio::test]
1793 async fn test_planning_workflow_prefers_plan_sidecar_even_if_global_is_newer() {
1794 let temp = TempDir::new().unwrap();
1795 let (state, tool) = setup_tool(&temp);
1796
1797 let plans_dir = state.plans_dir();
1798 std::fs::create_dir_all(&plans_dir).unwrap();
1799 let plan_file = plans_dir.join("plan-primary.md");
1800 std::fs::write(&plan_file, "# Plan Primary\n").unwrap();
1801 state.set_plan_file(Some(plan_file.clone())).await;
1802 state.enable();
1803
1804 let global_file = temp.path().join(".vtcode/tasks/current_task.md");
1805 std::fs::create_dir_all(global_file.parent().unwrap()).unwrap();
1806 std::fs::write(&global_file, "# Plan Primary\n\n- [x] global newer\n").unwrap();
1807 std::thread::sleep(std::time::Duration::from_millis(15));
1808
1809 let sidecar = plans_dir.join("plan-primary.tasks.md");
1810 std::fs::write(&sidecar, "# Plan Primary\n\n## Plan of Work\n\n- [ ] plan source\n").unwrap();
1811 std::thread::sleep(std::time::Duration::from_millis(15));
1812 std::fs::write(&global_file, "# Plan Primary\n\n- [x] global newest\n").unwrap();
1813
1814 let listed = tool.execute(json!({"action": "list"})).await.unwrap();
1815 assert_eq!(listed["status"], "ok");
1816 assert_eq!(listed["checklist"]["pending"], 1);
1817 assert_eq!(listed["checklist"]["completed"], 0);
1818 }
1819
1820 #[test]
1821 fn parse_files_metadata_keeps_quoted_commas_as_single_item() {
1822 let items = parse_files_metadata("'src/a.rs, b.rs', src/c.rs");
1823 assert_eq!(items, vec!["'src/a.rs, b.rs'".to_string(), "src/c.rs".to_string()]);
1824 }
1825}