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