syncable_cli/agent/tools/
plan.rs

1//! Plan tools for Forge-style planning workflow
2//!
3//! Provides tools for creating and executing structured plans:
4//! - `PlanCreateTool` - Create plan files with task checkboxes
5//! - `PlanNextTool` - Get next pending task and mark it in-progress
6//! - `PlanUpdateTool` - Update task status (done, failed)
7//!
8//! ## Task Status Format
9//!
10//! ```markdown
11//! - [ ] Task description (PENDING)
12//! - [~] Task description (IN_PROGRESS)
13//! - [x] Task description (DONE)
14//! - [!] Task description (FAILED: reason)
15//! ```
16
17use chrono::Local;
18use regex::Regex;
19use rig::completion::ToolDefinition;
20use rig::tool::Tool;
21use serde::Deserialize;
22use serde_json::json;
23use std::fs;
24use std::path::PathBuf;
25
26// ============================================================================
27// Task Status Types
28// ============================================================================
29
30/// Task status in a plan file
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum TaskStatus {
33    Pending,    // [ ]
34    InProgress, // [~]
35    Done,       // [x]
36    Failed,     // [!]
37}
38
39impl TaskStatus {
40    fn marker(&self) -> &'static str {
41        match self {
42            TaskStatus::Pending => "[ ]",
43            TaskStatus::InProgress => "[~]",
44            TaskStatus::Done => "[x]",
45            TaskStatus::Failed => "[!]",
46        }
47    }
48
49    #[allow(dead_code)]
50    fn from_marker(s: &str) -> Option<Self> {
51        match s {
52            "[ ]" => Some(TaskStatus::Pending),
53            "[~]" => Some(TaskStatus::InProgress),
54            "[x]" => Some(TaskStatus::Done),
55            "[!]" => Some(TaskStatus::Failed),
56            _ => None,
57        }
58    }
59}
60
61/// A task parsed from a plan file
62#[derive(Debug, Clone)]
63pub struct PlanTask {
64    pub index: usize, // 1-based index
65    pub status: TaskStatus,
66    pub description: String,
67    #[allow(dead_code)]
68    pub line_number: usize, // Line number in file (1-based)
69}
70
71// ============================================================================
72// Plan Parser
73// ============================================================================
74
75/// Parse tasks from plan file content
76fn parse_plan_tasks(content: &str) -> Vec<PlanTask> {
77    let task_regex = Regex::new(r"^(\s*)-\s*\[([ x~!])\]\s*(.+)$").unwrap();
78    let mut tasks = Vec::new();
79    let mut task_index = 0;
80
81    for (line_idx, line) in content.lines().enumerate() {
82        if let Some(caps) = task_regex.captures(line) {
83            task_index += 1;
84            let marker_char = caps.get(2).map(|m| m.as_str()).unwrap_or(" ");
85            let description = caps.get(3).map(|m| m.as_str()).unwrap_or("").to_string();
86
87            let status = match marker_char {
88                " " => TaskStatus::Pending,
89                "~" => TaskStatus::InProgress,
90                "x" => TaskStatus::Done,
91                "!" => TaskStatus::Failed,
92                _ => TaskStatus::Pending,
93            };
94
95            tasks.push(PlanTask {
96                index: task_index,
97                status,
98                description,
99                line_number: line_idx + 1,
100            });
101        }
102    }
103
104    tasks
105}
106
107/// Update a task's status in the plan file content
108fn update_task_status(
109    content: &str,
110    task_index: usize,
111    new_status: TaskStatus,
112    note: Option<&str>,
113) -> Option<String> {
114    let task_regex = Regex::new(r"^(\s*)-\s*\[[ x~!]\]\s*(.+)$").unwrap();
115    let mut current_index = 0;
116    let mut lines: Vec<String> = content.lines().map(String::from).collect();
117
118    for (line_idx, line) in content.lines().enumerate() {
119        if task_regex.is_match(line) {
120            current_index += 1;
121            if current_index == task_index {
122                // Found the task to update
123                let caps = task_regex.captures(line)?;
124                let indent = caps.get(1).map(|m| m.as_str()).unwrap_or("");
125                let desc = caps.get(2).map(|m| m.as_str()).unwrap_or("");
126
127                // Build new line with updated status
128                let new_line = if new_status == TaskStatus::Failed {
129                    let fail_note = note.unwrap_or("unknown reason");
130                    format!(
131                        "{}- {} {} (FAILED: {})",
132                        indent,
133                        new_status.marker(),
134                        desc,
135                        fail_note
136                    )
137                } else {
138                    format!("{}- {} {}", indent, new_status.marker(), desc)
139                };
140
141                lines[line_idx] = new_line;
142                return Some(lines.join("\n"));
143            }
144        }
145    }
146
147    None // Task not found
148}
149
150// ============================================================================
151// Plan Create Tool
152// ============================================================================
153
154#[derive(Debug, Deserialize)]
155pub struct PlanCreateArgs {
156    /// Short name for the plan (e.g., "auth-feature", "refactor-db")
157    pub plan_name: String,
158    /// Version identifier (e.g., "v1", "draft")
159    pub version: Option<String>,
160    /// Markdown content with task checkboxes (- [ ] task description)
161    pub content: String,
162}
163
164#[derive(Debug, thiserror::Error)]
165#[error("Plan create error: {0}")]
166pub struct PlanCreateError(String);
167
168#[derive(Debug, Clone)]
169pub struct PlanCreateTool {
170    project_path: PathBuf,
171}
172
173impl PlanCreateTool {
174    pub fn new(project_path: PathBuf) -> Self {
175        Self { project_path }
176    }
177}
178
179impl Tool for PlanCreateTool {
180    const NAME: &'static str = "plan_create";
181
182    type Error = PlanCreateError;
183    type Args = PlanCreateArgs;
184    type Output = String;
185
186    async fn definition(&self, _prompt: String) -> ToolDefinition {
187        ToolDefinition {
188            name: Self::NAME.to_string(),
189            description: r#"Create a structured plan file with task checkboxes. Use this in plan mode to document implementation steps.
190
191The plan file will be created in the `plans/` directory with format: {date}-{plan_name}-{version}.md
192
193IMPORTANT: Each task MUST use the checkbox format: `- [ ] Task description`
194
195Example content:
196```markdown
197# Authentication Feature Plan
198
199## Overview
200Add user authentication to the application.
201
202## Tasks
203
204- [ ] Create User model in src/models/user.rs
205- [ ] Add password hashing with bcrypt
206- [ ] Create login endpoint at POST /api/login
207- [ ] Add JWT token generation
208- [ ] Create authentication middleware
209- [ ] Write tests for auth flow
210```
211
212The task status markers are:
213- `[ ]` - PENDING (not started)
214- `[~]` - IN_PROGRESS (currently being worked on)
215- `[x]` - DONE (completed)
216- `[!]` - FAILED (failed with reason)"#.to_string(),
217            parameters: json!({
218                "type": "object",
219                "properties": {
220                    "plan_name": {
221                        "type": "string",
222                        "description": "Short kebab-case name for the plan (e.g., 'auth-feature', 'refactor-db')"
223                    },
224                    "version": {
225                        "type": "string",
226                        "description": "Optional version identifier (e.g., 'v1', 'draft'). Defaults to 'v1'"
227                    },
228                    "content": {
229                        "type": "string",
230                        "description": "Markdown content with task checkboxes. Each task must be: '- [ ] Task description'"
231                    }
232                },
233                "required": ["plan_name", "content"]
234            }),
235        }
236    }
237
238    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
239        // Validate plan name (kebab-case)
240        let plan_name = args.plan_name.trim().to_lowercase().replace(' ', "-");
241        if plan_name.is_empty() {
242            return Err(PlanCreateError("Plan name cannot be empty".to_string()));
243        }
244
245        // Validate content has at least one task
246        let tasks = parse_plan_tasks(&args.content);
247        if tasks.is_empty() {
248            return Err(PlanCreateError(
249                "Plan must contain at least one task with format: '- [ ] Task description'"
250                    .to_string(),
251            ));
252        }
253
254        // Build filename: {date}-{plan_name}-{version}.md
255        let version = args.version.unwrap_or_else(|| "v1".to_string());
256        let date = Local::now().format("%Y-%m-%d");
257        let filename = format!("{}-{}-{}.md", date, plan_name, version);
258
259        // Create plans directory if it doesn't exist
260        let plans_dir = self.project_path.join("plans");
261        if !plans_dir.exists() {
262            fs::create_dir_all(&plans_dir)
263                .map_err(|e| PlanCreateError(format!("Failed to create plans directory: {}", e)))?;
264        }
265
266        // Check if file already exists
267        let file_path = plans_dir.join(&filename);
268        if file_path.exists() {
269            return Err(PlanCreateError(format!(
270                "Plan file already exists: {}. Use a different name or version.",
271                filename
272            )));
273        }
274
275        // Write the plan file
276        fs::write(&file_path, &args.content)
277            .map_err(|e| PlanCreateError(format!("Failed to write plan file: {}", e)))?;
278
279        // Get relative path for display
280        let rel_path = file_path
281            .strip_prefix(&self.project_path)
282            .map(|p| p.display().to_string())
283            .unwrap_or_else(|_| file_path.display().to_string());
284
285        let result = json!({
286            "success": true,
287            "plan_path": rel_path,
288            "filename": filename,
289            "task_count": tasks.len(),
290            "tasks": tasks.iter().map(|t| json!({
291                "index": t.index,
292                "description": t.description,
293                "status": "pending"
294            })).collect::<Vec<_>>(),
295            "next_steps": "Plan created successfully. Choose an execution option from the menu."
296        });
297
298        serde_json::to_string_pretty(&result)
299            .map_err(|e| PlanCreateError(format!("Failed to serialize: {}", e)))
300    }
301}
302
303// ============================================================================
304// Plan Next Tool - Get next pending task
305// ============================================================================
306
307#[derive(Debug, Deserialize)]
308pub struct PlanNextArgs {
309    /// Path to the plan file (relative or absolute)
310    pub plan_path: String,
311}
312
313#[derive(Debug, thiserror::Error)]
314#[error("Plan next error: {0}")]
315pub struct PlanNextError(String);
316
317#[derive(Debug, Clone)]
318pub struct PlanNextTool {
319    project_path: PathBuf,
320}
321
322impl PlanNextTool {
323    pub fn new(project_path: PathBuf) -> Self {
324        Self { project_path }
325    }
326
327    fn resolve_path(&self, path: &str) -> PathBuf {
328        let p = PathBuf::from(path);
329        if p.is_absolute() {
330            p
331        } else {
332            self.project_path.join(p)
333        }
334    }
335}
336
337impl Tool for PlanNextTool {
338    const NAME: &'static str = "plan_next";
339
340    type Error = PlanNextError;
341    type Args = PlanNextArgs;
342    type Output = String;
343
344    async fn definition(&self, _prompt: String) -> ToolDefinition {
345        ToolDefinition {
346            name: Self::NAME.to_string(),
347            description: r#"Get the next pending task from a plan file and mark it as in-progress.
348
349This tool:
3501. Reads the plan file
3512. Finds the first `[ ]` (PENDING) task
3523. Updates it to `[~]` (IN_PROGRESS) in the file
3534. Returns the task description for you to execute
354
355After executing the task, use `plan_update` to mark it as done or failed.
356
357Returns null task if all tasks are complete."#
358                .to_string(),
359            parameters: json!({
360                "type": "object",
361                "properties": {
362                    "plan_path": {
363                        "type": "string",
364                        "description": "Path to the plan file (e.g., 'plans/2025-01-15-auth-feature-v1.md')"
365                    }
366                },
367                "required": ["plan_path"]
368            }),
369        }
370    }
371
372    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
373        let file_path = self.resolve_path(&args.plan_path);
374
375        // Read plan file
376        let content = fs::read_to_string(&file_path)
377            .map_err(|e| PlanNextError(format!("Failed to read plan file: {}", e)))?;
378
379        // Parse tasks
380        let tasks = parse_plan_tasks(&content);
381        if tasks.is_empty() {
382            return Err(PlanNextError("No tasks found in plan file".to_string()));
383        }
384
385        // Find first pending task
386        let pending_task = tasks.iter().find(|t| t.status == TaskStatus::Pending);
387
388        match pending_task {
389            Some(task) => {
390                // Update task to in-progress
391                let updated_content =
392                    update_task_status(&content, task.index, TaskStatus::InProgress, None)
393                        .ok_or_else(|| PlanNextError("Failed to update task status".to_string()))?;
394
395                // Write updated content
396                fs::write(&file_path, &updated_content)
397                    .map_err(|e| PlanNextError(format!("Failed to write plan file: {}", e)))?;
398
399                // Count task states
400                let done_count = tasks
401                    .iter()
402                    .filter(|t| t.status == TaskStatus::Done)
403                    .count();
404                let pending_count = tasks
405                    .iter()
406                    .filter(|t| t.status == TaskStatus::Pending)
407                    .count()
408                    - 1; // -1 for current
409                let failed_count = tasks
410                    .iter()
411                    .filter(|t| t.status == TaskStatus::Failed)
412                    .count();
413
414                let result = json!({
415                    "has_task": true,
416                    "task_index": task.index,
417                    "task_description": task.description,
418                    "total_tasks": tasks.len(),
419                    "completed": done_count,
420                    "pending": pending_count,
421                    "failed": failed_count,
422                    "progress": format!("{}/{}", done_count, tasks.len()),
423                    "instructions": "Execute this task using appropriate tools, then call plan_update to mark it done."
424                });
425
426                serde_json::to_string_pretty(&result)
427                    .map_err(|e| PlanNextError(format!("Failed to serialize: {}", e)))
428            }
429            None => {
430                // No pending tasks - check if all done
431                let done_count = tasks
432                    .iter()
433                    .filter(|t| t.status == TaskStatus::Done)
434                    .count();
435                let failed_count = tasks
436                    .iter()
437                    .filter(|t| t.status == TaskStatus::Failed)
438                    .count();
439                let in_progress = tasks
440                    .iter()
441                    .filter(|t| t.status == TaskStatus::InProgress)
442                    .count();
443
444                let result = json!({
445                    "has_task": false,
446                    "total_tasks": tasks.len(),
447                    "completed": done_count,
448                    "failed": failed_count,
449                    "in_progress": in_progress,
450                    "status": if in_progress > 0 {
451                        "Task in progress - complete it before getting next"
452                    } else if failed_count > 0 {
453                        "Plan completed with failures"
454                    } else {
455                        "All tasks completed successfully!"
456                    }
457                });
458
459                serde_json::to_string_pretty(&result)
460                    .map_err(|e| PlanNextError(format!("Failed to serialize: {}", e)))
461            }
462        }
463    }
464}
465
466// ============================================================================
467// Plan Update Tool - Update task status
468// ============================================================================
469
470#[derive(Debug, Deserialize)]
471pub struct PlanUpdateArgs {
472    /// Path to the plan file
473    pub plan_path: String,
474    /// 1-based task index to update
475    pub task_index: usize,
476    /// New status: "done", "failed", or "pending"
477    pub status: String,
478    /// Optional note for failed tasks
479    pub note: Option<String>,
480}
481
482#[derive(Debug, thiserror::Error)]
483#[error("Plan update error: {0}")]
484pub struct PlanUpdateError(String);
485
486#[derive(Debug, Clone)]
487pub struct PlanUpdateTool {
488    project_path: PathBuf,
489}
490
491impl PlanUpdateTool {
492    pub fn new(project_path: PathBuf) -> Self {
493        Self { project_path }
494    }
495
496    fn resolve_path(&self, path: &str) -> PathBuf {
497        let p = PathBuf::from(path);
498        if p.is_absolute() {
499            p
500        } else {
501            self.project_path.join(p)
502        }
503    }
504}
505
506impl Tool for PlanUpdateTool {
507    const NAME: &'static str = "plan_update";
508
509    type Error = PlanUpdateError;
510    type Args = PlanUpdateArgs;
511    type Output = String;
512
513    async fn definition(&self, _prompt: String) -> ToolDefinition {
514        ToolDefinition {
515            name: Self::NAME.to_string(),
516            description: r#"Update the status of a task in a plan file.
517
518Use this after completing or failing a task to update its status:
519- "done" - Mark task as completed `[x]`
520- "failed" - Mark task as failed `[!]` (include a note explaining why)
521- "pending" - Reset task to pending `[ ]`
522
523After marking a task done, call `plan_next` to get the next task."#
524                .to_string(),
525            parameters: json!({
526                "type": "object",
527                "properties": {
528                    "plan_path": {
529                        "type": "string",
530                        "description": "Path to the plan file"
531                    },
532                    "task_index": {
533                        "type": "integer",
534                        "description": "1-based index of the task to update"
535                    },
536                    "status": {
537                        "type": "string",
538                        "enum": ["done", "failed", "pending"],
539                        "description": "New status for the task"
540                    },
541                    "note": {
542                        "type": "string",
543                        "description": "Optional note explaining failure (required for 'failed' status)"
544                    }
545                },
546                "required": ["plan_path", "task_index", "status"]
547            }),
548        }
549    }
550
551    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
552        let file_path = self.resolve_path(&args.plan_path);
553
554        // Read plan file
555        let content = fs::read_to_string(&file_path)
556            .map_err(|e| PlanUpdateError(format!("Failed to read plan file: {}", e)))?;
557
558        // Parse status
559        let new_status = match args.status.to_lowercase().as_str() {
560            "done" => TaskStatus::Done,
561            "failed" => TaskStatus::Failed,
562            "pending" => TaskStatus::Pending,
563            _ => {
564                return Err(PlanUpdateError(format!(
565                    "Invalid status '{}'. Use: done, failed, or pending",
566                    args.status
567                )));
568            }
569        };
570
571        // Require note for failed status
572        if new_status == TaskStatus::Failed && args.note.is_none() {
573            return Err(PlanUpdateError(
574                "A note is required when marking a task as failed".to_string(),
575            ));
576        }
577
578        // Update task status
579        let updated_content =
580            update_task_status(&content, args.task_index, new_status, args.note.as_deref())
581                .ok_or_else(|| {
582                    PlanUpdateError(format!("Task {} not found in plan", args.task_index))
583                })?;
584
585        // Write updated content
586        fs::write(&file_path, &updated_content)
587            .map_err(|e| PlanUpdateError(format!("Failed to write plan file: {}", e)))?;
588
589        // Parse updated tasks for summary
590        let tasks = parse_plan_tasks(&updated_content);
591        let done_count = tasks
592            .iter()
593            .filter(|t| t.status == TaskStatus::Done)
594            .count();
595        let pending_count = tasks
596            .iter()
597            .filter(|t| t.status == TaskStatus::Pending)
598            .count();
599        let failed_count = tasks
600            .iter()
601            .filter(|t| t.status == TaskStatus::Failed)
602            .count();
603
604        let result = json!({
605            "success": true,
606            "task_index": args.task_index,
607            "new_status": args.status,
608            "progress": format!("{}/{}", done_count, tasks.len()),
609            "summary": {
610                "total": tasks.len(),
611                "done": done_count,
612                "pending": pending_count,
613                "failed": failed_count
614            },
615            "next_action": if pending_count > 0 {
616                "Call plan_next to get the next pending task"
617            } else if failed_count > 0 {
618                "Plan complete with failures. Review failed tasks."
619            } else {
620                "All tasks completed! Plan execution finished."
621            }
622        });
623
624        serde_json::to_string_pretty(&result)
625            .map_err(|e| PlanUpdateError(format!("Failed to serialize: {}", e)))
626    }
627}
628
629// ============================================================================
630// Plan List Tool - List available plans
631// ============================================================================
632
633#[derive(Debug, Deserialize)]
634pub struct PlanListArgs {
635    /// Optional filter by status (e.g., "incomplete" to show plans with pending tasks)
636    pub filter: Option<String>,
637}
638
639#[derive(Debug, thiserror::Error)]
640#[error("Plan list error: {0}")]
641pub struct PlanListError(String);
642
643#[derive(Debug, Clone)]
644pub struct PlanListTool {
645    project_path: PathBuf,
646}
647
648impl PlanListTool {
649    pub fn new(project_path: PathBuf) -> Self {
650        Self { project_path }
651    }
652}
653
654impl Tool for PlanListTool {
655    const NAME: &'static str = "plan_list";
656
657    type Error = PlanListError;
658    type Args = PlanListArgs;
659    type Output = String;
660
661    async fn definition(&self, _prompt: String) -> ToolDefinition {
662        ToolDefinition {
663            name: Self::NAME.to_string(),
664            description: r#"List all plan files in the plans/ directory with their status summary.
665
666Shows each plan with:
667- Filename and path
668- Task counts (done/pending/failed)
669- Overall status"#
670                .to_string(),
671            parameters: json!({
672                "type": "object",
673                "properties": {
674                    "filter": {
675                        "type": "string",
676                        "enum": ["all", "incomplete", "complete"],
677                        "description": "Filter plans: 'all' (default), 'incomplete' (has pending), 'complete' (no pending)"
678                    }
679                }
680            }),
681        }
682    }
683
684    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
685        let plans_dir = self.project_path.join("plans");
686
687        if !plans_dir.exists() {
688            let result = json!({
689                "plans": [],
690                "message": "No plans directory found. Create a plan first with plan_create."
691            });
692            return serde_json::to_string_pretty(&result)
693                .map_err(|e| PlanListError(format!("Failed to serialize: {}", e)));
694        }
695
696        let filter = args.filter.as_deref().unwrap_or("all");
697        let mut plans = Vec::new();
698
699        let entries = fs::read_dir(&plans_dir)
700            .map_err(|e| PlanListError(format!("Failed to read plans directory: {}", e)))?;
701
702        for entry in entries.flatten() {
703            let path = entry.path();
704            if path.extension().map(|e| e == "md").unwrap_or(false)
705                && let Ok(content) = fs::read_to_string(&path)
706            {
707                let tasks = parse_plan_tasks(&content);
708                let done = tasks
709                    .iter()
710                    .filter(|t| t.status == TaskStatus::Done)
711                    .count();
712                let pending = tasks
713                    .iter()
714                    .filter(|t| t.status == TaskStatus::Pending)
715                    .count();
716                let in_progress = tasks
717                    .iter()
718                    .filter(|t| t.status == TaskStatus::InProgress)
719                    .count();
720                let failed = tasks
721                    .iter()
722                    .filter(|t| t.status == TaskStatus::Failed)
723                    .count();
724
725                // Apply filter
726                let include = match filter {
727                    "incomplete" => pending > 0 || in_progress > 0,
728                    "complete" => pending == 0 && in_progress == 0,
729                    _ => true,
730                };
731
732                if include {
733                    let rel_path = path
734                        .strip_prefix(&self.project_path)
735                        .map(|p| p.display().to_string())
736                        .unwrap_or_else(|_| path.display().to_string());
737
738                    plans.push(json!({
739                        "path": rel_path,
740                        "filename": path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(),
741                        "tasks": {
742                            "total": tasks.len(),
743                            "done": done,
744                            "pending": pending,
745                            "in_progress": in_progress,
746                            "failed": failed
747                        },
748                        "progress": format!("{}/{}", done, tasks.len()),
749                        "status": if pending == 0 && in_progress == 0 {
750                            if failed > 0 { "completed_with_failures" } else { "complete" }
751                        } else if in_progress > 0 {
752                            "in_progress"
753                        } else {
754                            "pending"
755                        }
756                    }));
757                }
758            }
759        }
760
761        // Sort by filename (most recent first due to date prefix)
762        plans.sort_by(|a, b| {
763            let a_name = a.get("filename").and_then(|v| v.as_str()).unwrap_or("");
764            let b_name = b.get("filename").and_then(|v| v.as_str()).unwrap_or("");
765            b_name.cmp(a_name)
766        });
767
768        let result = json!({
769            "plans": plans,
770            "total": plans.len(),
771            "filter": filter
772        });
773
774        serde_json::to_string_pretty(&result)
775            .map_err(|e| PlanListError(format!("Failed to serialize: {}", e)))
776    }
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782    use tempfile::tempdir;
783
784    #[tokio::test]
785    async fn test_list_plans_empty_directory() {
786        let dir = tempdir().unwrap();
787        let tool = PlanListTool::new(dir.path().to_path_buf());
788        let args = PlanListArgs { filter: None };
789
790        let result = tool.call(args).await.unwrap();
791        // Should return valid JSON
792        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
793        assert!(parsed.is_object());
794        // No plans should mean total is 0 or plans is empty array
795        if let Some(total) = parsed.get("total") {
796            assert!(total.as_u64().unwrap_or(0) == 0);
797        }
798    }
799
800    #[tokio::test]
801    async fn test_list_plans_with_plans() {
802        let dir = tempdir().unwrap();
803        let plans_dir = dir.path().join(".plans");
804        std::fs::create_dir(&plans_dir).unwrap();
805        std::fs::write(
806            plans_dir.join("2026-01-15-test.md"),
807            "# Test Plan\n\nSome content",
808        )
809        .unwrap();
810
811        let tool = PlanListTool::new(dir.path().to_path_buf());
812        let args = PlanListArgs { filter: None };
813
814        let result = tool.call(args).await.unwrap();
815        // Should return valid JSON
816        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
817        assert!(parsed.is_object());
818    }
819}