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