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