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