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