Skip to main content

vtcode_core/tools/handlers/
task_tracker.rs

1//! Task Tracker tool for structured task management during complex sessions.
2//!
3//! Based on NL2Repo-Bench findings: agents that leverage explicit planning
4//! tools achieve significantly better scores. This tool provides a first-class
5//! mechanism for the agent to create, update, and query a task checklist
6//! persisted to `.vtcode/tasks/`.
7//!
8//! ## Actions
9//!
10//! - `create`: Create a new task checklist with a title and list of items
11//! - `update`: Mark a specific task item as completed, in_progress, or pending
12//! - `list`: Show the current task checklist and its status
13//! - `add`: Add a new item to an existing checklist
14
15use 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/// A single task item
46#[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/// The full task checklist
56#[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/// Arguments for the task_tracker tool
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct TaskTrackerArgs {
365    /// Action to perform: create, update, list, add
366    pub action: String,
367
368    /// Title for the checklist (required for `create`)
369    #[serde(default)]
370    pub title: Option<String>,
371
372    /// List of task descriptions (required for `create`)
373    #[serde(default)]
374    pub items: Option<Vec<TaskItemInput>>,
375
376    /// Index of item to update (required for `update`, 1-indexed)
377    #[serde(default)]
378    pub index: Option<usize>,
379
380    /// Hierarchical index path for update (Planning workflow, optional)
381    #[serde(default)]
382    pub index_path: Option<String>,
383
384    /// New status for the item (required for `update`)
385    #[serde(default)]
386    pub status: Option<String>,
387
388    /// Description for a new item (required for `add`)
389    #[serde(default)]
390    pub description: Option<String>,
391
392    /// Optional file paths associated with a step
393    #[serde(default)]
394    pub files: Option<Vec<String>>,
395
396    /// Optional expected outcome associated with a step
397    #[serde(default)]
398    pub outcome: Option<String>,
399
400    /// Optional verification command or commands associated with a step
401    #[serde(
402        default,
403        deserialize_with = "crate::tools::handlers::task_tracking::deserialize_optional_string_list"
404    )]
405    pub verify: Option<Vec<String>>,
406
407    /// Optional parent path for add in Planning workflow (example: "2")
408    #[serde(default)]
409    pub parent_index_path: Option<String>,
410
411    /// Optional notes to append
412    #[serde(default)]
413    pub notes: Option<String>,
414}
415
416/// Task Tracker tool state
417pub 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
572/// Model-facing correction for `task_tracker` shape failures.
573///
574/// The schema uses conditional (`if`/`then`/`not`) rules, so a bare
575/// "required fields" hint is not enough: `create` forbids `index`/`index_path`,
576/// `update` needs exactly one shape, and `add`/`list` have their own shapes.
577/// Without this, a `create` bundled with update fields fails with a generic
578/// root error and the model retries blindly.
579///
580/// Note: the schema only *forbids* `index`/`index_path` on
581/// `create`/`list`/`add`; other single-item fields (`status`, `description`,
582/// `files`, `outcome`, `verify`) are ignored by those actions, not rejected.
583/// The wording below matches that enforced contract: it forbids only what the
584/// schema forbids and recommends (not requires) the minimal shapes.
585pub 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
587/// Whether a `task_tracker` schema error is a shape error (conditional
588/// `if`/`then`/`not`, `anyOf`/`oneOf`, missing required, fallback) rather
589/// than a single-field value error (`enum`, `type`, `const`, ...). The shape
590/// correction above only helps the former; appending it to a bad-enum error
591/// would point the model at the wrong axis.
592pub(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    /// Check if a `create` call is idempotent (same checklist already active).
836    ///
837    /// Returns `Some(unchanged_response)` when the existing checklist should be
838    /// preserved, or `None` when a new checklist must be created.
839    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 // Writes tracker artifacts only (.vtcode/tasks and .vtcode/plans)
1138    }
1139
1140    fn is_parallel_safe(&self) -> bool {
1141        false // State management should be sequential
1142    }
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}