Skip to main content

oxi/skills/
planner.rs

1//! Planner skill for oxi
2//!
3//! Deep analysis and architecture planning skill that produces structured
4//! implementation plans from requirements. The planner:
5//!
6//! 1. **Gathers context** — reads project structure, specs, and existing code
7//! 2. **Decomposes** into vertical slices with clear dependencies
8//! 3. **Batches** independent tasks for parallel execution
9//! 4. **Tracks** acceptance criteria and verification methods per task
10//! 5. **Outputs** a structured plan document (`docs/plan/...`)
11//!
12//! The module provides:
13//! - [`PlannerSession`] — state machine tracking the planning lifecycle
14//! - [`PlanTask`] / [`TaskBatch`] — structured task and batch definitions
15//! - [`PlanDocument`] — the complete plan that can be rendered to Markdown
16//! - [`PlannerSkill`] — skill prompt generator for LLM-driven planning
17
18use anyhow::{bail, Context, Result};
19use chrono::Utc;
20use serde::{Deserialize, Serialize};
21use std::fmt;
22use std::fs;
23use std::path::{Path, PathBuf};
24
25// ── Phase ──────────────────────────────────────────────────────────────
26
27/// The phase a planner session is currently in.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum PlannerPhase {
31    /// Reading project context and requirements.
32    Gather,
33    /// Decomposing into tasks with dependencies.
34    Decompose,
35    /// Batching tasks for parallel execution.
36    Batch,
37    /// Reviewing the plan for completeness and correctness.
38    Review,
39    /// Writing the plan document to disk.
40    Document,
41    /// Planning complete.
42    Done,
43}
44
45impl Default for PlannerPhase {
46    fn default() -> Self {
47        PlannerPhase::Gather
48    }
49}
50
51impl fmt::Display for PlannerPhase {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            PlannerPhase::Gather => write!(f, "Gather"),
55            PlannerPhase::Decompose => write!(f, "Decompose"),
56            PlannerPhase::Batch => write!(f, "Batch"),
57            PlannerPhase::Review => write!(f, "Review"),
58            PlannerPhase::Document => write!(f, "Document"),
59            PlannerPhase::Done => write!(f, "Done"),
60        }
61    }
62}
63
64// ── Task types ─────────────────────────────────────────────────────────
65
66/// A single implementation task within a plan.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct PlanTask {
69    /// Task identifier (e.g., "T1", "T2").
70    pub id: String,
71    /// Short description of what this task accomplishes.
72    pub title: String,
73    /// Detailed approach and key changes.
74    pub description: String,
75    /// Files to create or modify.
76    pub touches_files: Vec<String>,
77    /// IDs of tasks this task depends on.
78    pub depends_on: Vec<String>,
79    /// Concrete acceptance criteria.
80    pub acceptance_criteria: Vec<String>,
81    /// How to verify this task works.
82    pub verification: String,
83    /// Estimated complexity.
84    pub complexity: TaskComplexity,
85    /// Whether this task needs TDD (test-first).
86    pub tdd: bool,
87    /// Which batch this task belongs to (set during batching).
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub batch: Option<usize>,
90}
91
92/// Task complexity rating.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum TaskComplexity {
96    /// < 30 min, single file, straightforward change.
97    Trivial,
98    /// 1–2 hours, 1–2 files, well-understood approach.
99    Simple,
100    /// Half a day, 2–4 files, some design decisions.
101    Moderate,
102    /// Full day, 3–5 files, significant design decisions.
103    Complex,
104    /// Multi-day, 5+ files, architectural changes.
105    Large,
106}
107
108impl fmt::Display for TaskComplexity {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        match self {
111            TaskComplexity::Trivial => write!(f, "trivial"),
112            TaskComplexity::Simple => write!(f, "simple"),
113            TaskComplexity::Moderate => write!(f, "moderate"),
114            TaskComplexity::Complex => write!(f, "complex"),
115            TaskComplexity::Large => write!(f, "large"),
116        }
117    }
118}
119
120/// A batch of tasks that can be executed in parallel.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct TaskBatch {
123    /// Batch index (0-based).
124    pub index: usize,
125    /// Tasks in this batch.
126    pub tasks: Vec<String>,
127    /// Whether any tasks in this batch have file conflicts.
128    pub has_conflicts: bool,
129    /// Execution strategy for this batch.
130    pub strategy: BatchStrategy,
131}
132
133/// How to execute a batch.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum BatchStrategy {
137    /// All tasks are independent and can run in parallel.
138    Parallel,
139    /// Tasks must run sequentially (dependencies or file conflicts).
140    Sequential,
141    /// Tasks form a pipeline — each builds on the previous.
142    Chain,
143}
144
145impl fmt::Display for BatchStrategy {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        match self {
148            BatchStrategy::Parallel => write!(f, "parallel"),
149            BatchStrategy::Sequential => write!(f, "sequential"),
150            BatchStrategy::Chain => write!(f, "chain"),
151        }
152    }
153}
154
155// ── Risk and dependency ────────────────────────────────────────────────
156
157/// A risk identified during planning.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct PlanRisk {
160    /// Short description of the risk.
161    pub description: String,
162    /// Likelihood of the risk materializing.
163    pub likelihood: RiskLikelihood,
164    /// Impact if the risk materializes.
165    pub impact: RiskImpact,
166    /// Mitigation strategy.
167    pub mitigation: String,
168}
169
170/// Risk likelihood level.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "snake_case")]
173pub enum RiskLikelihood {
174    Low,
175    Medium,
176    High,
177}
178
179impl fmt::Display for RiskLikelihood {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        match self {
182            RiskLikelihood::Low => write!(f, "low"),
183            RiskLikelihood::Medium => write!(f, "medium"),
184            RiskLikelihood::High => write!(f, "high"),
185        }
186    }
187}
188
189/// Risk impact level.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum RiskImpact {
193    Low,
194    Medium,
195    High,
196}
197
198impl fmt::Display for RiskImpact {
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        match self {
201            RiskImpact::Low => write!(f, "low"),
202            RiskImpact::Medium => write!(f, "medium"),
203            RiskImpact::High => write!(f, "high"),
204        }
205    }
206}
207
208// ── Plan document ──────────────────────────────────────────────────────
209
210/// Project context gathered during the Gather phase.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct PlanContext {
213    /// Project root directory.
214    pub project_root: String,
215    /// Key files read during context gathering.
216    pub key_files: Vec<(String, String)>,
217    /// Existing patterns and conventions detected.
218    pub conventions: Vec<String>,
219    /// Dependencies relevant to the plan.
220    pub dependencies: Vec<String>,
221    /// Summary of project understanding.
222    pub summary: String,
223}
224
225/// The complete plan document.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct PlanDocument {
228    /// Plan title.
229    pub title: String,
230    /// Creation timestamp.
231    pub created_at: String,
232    /// Version (incremented on updates).
233    pub version: u32,
234    /// Objective statement.
235    pub objective: String,
236    /// Project context.
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub context: Option<PlanContext>,
239    /// All tasks.
240    pub tasks: Vec<PlanTask>,
241    /// Execution batches.
242    pub batches: Vec<TaskBatch>,
243    /// Risks identified.
244    pub risks: Vec<PlanRisk>,
245    /// Assumptions made.
246    pub assumptions: Vec<String>,
247    /// Out-of-scope items explicitly excluded.
248    pub out_of_scope: Vec<String>,
249    /// Open questions that need resolution.
250    pub open_questions: Vec<String>,
251}
252
253impl PlanDocument {
254    /// Render the plan as Markdown.
255    pub fn render_markdown(&self) -> String {
256        let mut md = String::with_capacity(4096);
257
258        md.push_str(&format!("# {}\n\n", self.title));
259        md.push_str(&format!("> Created: {} | Version: {}\n\n", self.created_at, self.version));
260
261        // Objective
262        md.push_str("## Objective\n\n");
263        md.push_str(&self.objective);
264        md.push_str("\n\n");
265
266        // Context
267        if let Some(ref ctx) = self.context {
268            md.push_str("## Context\n\n");
269            md.push_str(&ctx.summary);
270            md.push_str("\n\n");
271
272            if !ctx.conventions.is_empty() {
273                md.push_str("**Conventions:**\n");
274                for conv in &ctx.conventions {
275                    md.push_str(&format!("- {}\n", conv));
276                }
277                md.push('\n');
278            }
279
280            if !ctx.dependencies.is_empty() {
281                md.push_str("**Relevant Dependencies:**\n");
282                for dep in &ctx.dependencies {
283                    md.push_str(&format!("- {}\n", dep));
284                }
285                md.push('\n');
286            }
287        }
288
289        // Tasks
290        if !self.tasks.is_empty() {
291            md.push_str("## Tasks\n\n");
292            for task in &self.tasks {
293                let batch_label = task
294                    .batch
295                    .map(|b| format!(" [Batch {}]", b))
296                    .unwrap_or_default();
297                md.push_str(&format!(
298                    "### {}{}: {}\n\n",
299                    task.id, batch_label, task.title
300                ));
301                md.push_str(&task.description);
302                md.push_str("\n\n");
303
304                if !task.touches_files.is_empty() {
305                    md.push_str("**Files:**\n");
306                    for file in &task.touches_files {
307                        md.push_str(&format!("- `{}`\n", file));
308                    }
309                    md.push('\n');
310                }
311
312                if !task.depends_on.is_empty() {
313                    md.push_str(&format!("**Depends on:** {}\n\n", task.depends_on.join(", ")));
314                }
315
316                if !task.acceptance_criteria.is_empty() {
317                    md.push_str("**Acceptance Criteria:**\n");
318                    for (i, criterion) in task.acceptance_criteria.iter().enumerate() {
319                        md.push_str(&format!("{}. [ ] {}\n", i + 1, criterion));
320                    }
321                    md.push('\n');
322                }
323
324                md.push_str(&format!(
325                    "**Verification:** {} | **Complexity:** {}{}{}\n\n",
326                    task.verification,
327                    task.complexity,
328                    if task.tdd { " | **TDD**" } else { "" },
329                    "",
330                ));
331            }
332        }
333
334        // Batches
335        if !self.batches.is_empty() {
336            md.push_str("## Execution Batches\n\n");
337            for batch in &self.batches {
338                md.push_str(&format!(
339                    "### Batch {} ({}){}\n\n",
340                    batch.index,
341                    batch.strategy,
342                    if batch.has_conflicts {
343                        " ⚠️ has file conflicts"
344                    } else {
345                        ""
346                    },
347                ));
348                for task_id in &batch.tasks {
349                    md.push_str(&format!("- {}\n", task_id));
350                }
351                md.push('\n');
352            }
353        }
354
355        // Risks
356        if !self.risks.is_empty() {
357            md.push_str("## Risks\n\n");
358            md.push_str("| Risk | Likelihood | Impact | Mitigation |\n");
359            md.push_str("|------|-----------|--------|------------|\n");
360            for risk in &self.risks {
361                md.push_str(&format!(
362                    "| {} | {} | {} | {} |\n",
363                    risk.description, risk.likelihood, risk.impact, risk.mitigation
364                ));
365            }
366            md.push('\n');
367        }
368
369        // Assumptions
370        if !self.assumptions.is_empty() {
371            md.push_str("## Assumptions\n\n");
372            for assumption in &self.assumptions {
373                md.push_str(&format!("- {}\n", assumption));
374            }
375            md.push('\n');
376        }
377
378        // Out of scope
379        if !self.out_of_scope.is_empty() {
380            md.push_str("## Out of Scope\n\n");
381            for item in &self.out_of_scope {
382                md.push_str(&format!("- {}\n", item));
383            }
384            md.push('\n');
385        }
386
387        // Open questions
388        if !self.open_questions.is_empty() {
389            md.push_str("## Open Questions\n\n");
390            for question in &self.open_questions {
391                md.push_str(&format!("- [ ] {}\n", question));
392            }
393            md.push('\n');
394        }
395
396        md
397    }
398
399    /// Write the plan to a Markdown file.
400    pub fn write_to_file(&self, dir: &Path) -> Result<PathBuf> {
401        fs::create_dir_all(dir)
402            .with_context(|| format!("Failed to create directory: {}", dir.display()))?;
403
404        let slug = slugify(&self.title);
405        let date = &self.created_at[..10];
406        let filename = format!("{}-{}.md", date, slug);
407        let path = dir.join(&filename);
408
409        let content = self.render_markdown();
410        fs::write(&path, &content)
411            .with_context(|| format!("Failed to write plan to {}", path.display()))?;
412
413        Ok(path)
414    }
415}
416
417// ── Planner session ────────────────────────────────────────────────────
418
419/// A planner session that tracks the planning lifecycle.
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct PlannerSession {
422    /// Current phase.
423    pub phase: PlannerPhase,
424    /// Plan title / topic.
425    pub title: String,
426    /// Optional project root directory.
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub project_root: Option<PathBuf>,
429    /// Gathered context.
430    #[serde(skip_serializing_if = "Option::is_none")]
431    pub context: Option<PlanContext>,
432    /// Tasks defined during decomposition.
433    pub tasks: Vec<PlanTask>,
434    /// Batches built during the batch phase.
435    pub batches: Vec<TaskBatch>,
436    /// Risks identified.
437    pub risks: Vec<PlanRisk>,
438    /// Assumptions.
439    pub assumptions: Vec<String>,
440    /// Out-of-scope items.
441    pub out_of_scope: Vec<String>,
442    /// Open questions.
443    pub open_questions: Vec<String>,
444    /// The finalized plan document.
445    #[serde(skip_serializing_if = "Option::is_none")]
446    pub plan: Option<PlanDocument>,
447}
448
449impl PlannerSession {
450    /// Create a new planner session.
451    pub fn new(title: impl Into<String>) -> Self {
452        Self {
453            phase: PlannerPhase::Gather,
454            title: title.into(),
455            project_root: None,
456            context: None,
457            tasks: Vec::new(),
458            batches: Vec::new(),
459            risks: Vec::new(),
460            assumptions: Vec::new(),
461            out_of_scope: Vec::new(),
462            open_questions: Vec::new(),
463            plan: None,
464        }
465    }
466
467    /// Set the project root.
468    pub fn with_project_root(mut self, root: impl Into<PathBuf>) -> Self {
469        self.project_root = Some(root.into());
470        self
471    }
472
473    /// Advance to the next phase.
474    pub fn advance(&mut self) -> Result<()> {
475        let next = match self.phase {
476            PlannerPhase::Gather => PlannerPhase::Decompose,
477            PlannerPhase::Decompose => PlannerPhase::Batch,
478            PlannerPhase::Batch => PlannerPhase::Review,
479            PlannerPhase::Review => PlannerPhase::Document,
480            PlannerPhase::Document => PlannerPhase::Done,
481            PlannerPhase::Done => bail!("Cannot advance past Done"),
482        };
483        self.phase = next;
484        Ok(())
485    }
486
487    /// Set phase directly.
488    pub fn set_phase(&mut self, phase: PlannerPhase) {
489        self.phase = phase;
490    }
491
492    /// Set the project context.
493    pub fn set_context(&mut self, ctx: PlanContext) {
494        self.context = Some(ctx);
495    }
496
497    /// Add a task.
498    pub fn add_task(&mut self, task: PlanTask) {
499        self.tasks.push(task);
500    }
501
502    /// Get a task by ID.
503    pub fn get_task(&self, id: &str) -> Option<&PlanTask> {
504        self.tasks.iter().find(|t| t.id == id)
505    }
506
507    /// Get a mutable task by ID.
508    pub fn get_task_mut(&mut self, id: &str) -> Option<&mut PlanTask> {
509        self.tasks.iter_mut().find(|t| t.id == id)
510    }
511
512    /// Number of tasks.
513    pub fn task_count(&self) -> usize {
514        self.tasks.len()
515    }
516
517    /// Add a risk.
518    pub fn add_risk(&mut self, risk: PlanRisk) {
519        self.risks.push(risk);
520    }
521
522    /// Add an assumption.
523    pub fn add_assumption(&mut self, assumption: impl Into<String>) {
524        self.assumptions.push(assumption.into());
525    }
526
527    /// Add an out-of-scope item.
528    pub fn add_out_of_scope(&mut self, item: impl Into<String>) {
529        self.out_of_scope.push(item.into());
530    }
531
532    /// Add an open question.
533    pub fn add_open_question(&mut self, question: impl Into<String>) {
534        self.open_questions.push(question.into());
535    }
536
537    /// Build execution batches from tasks using dependency analysis.
538    ///
539    /// Groups tasks into batches where each batch contains tasks whose
540    /// dependencies are all in earlier batches. Detects file conflicts
541    /// between tasks in the same batch.
542    pub fn build_batches(&mut self) -> Result<()> {
543        if self.tasks.is_empty() {
544            bail!("No tasks to batch");
545        }
546
547        let mut batches: Vec<TaskBatch> = Vec::new();
548        let mut assigned: std::collections::HashSet<String> = std::collections::HashSet::new();
549
550        // Iteratively assign tasks to batches
551        loop {
552            let mut ready: Vec<String> = Vec::new();
553
554            for task in &self.tasks {
555                if assigned.contains(&task.id) {
556                    continue;
557                }
558                // Check if all dependencies are assigned
559                let deps_met = task
560                    .depends_on
561                    .iter()
562                    .all(|dep| assigned.contains(dep));
563                if deps_met {
564                    ready.push(task.id.clone());
565                }
566            }
567
568            if ready.is_empty() {
569                break;
570            }
571
572            let batch_index = batches.len();
573
574            // Check for file conflicts within this batch
575            let mut file_owners: std::collections::HashMap<String, String> =
576                std::collections::HashMap::new();
577            let mut has_conflicts = false;
578
579            for task_id in &ready {
580                if let Some(task) = self.get_task(task_id) {
581                    for file in &task.touches_files {
582                        if let Some(existing) = file_owners.get(file) {
583                            tracing::debug!(
584                                "File conflict: {} touched by {} and {}",
585                                file,
586                                existing,
587                                task_id
588                            );
589                            has_conflicts = true;
590                        } else {
591                            file_owners.insert(file.clone(), task_id.clone());
592                        }
593                    }
594                }
595            }
596
597            let strategy = if has_conflicts {
598                BatchStrategy::Sequential
599            } else if batch_index == 0 {
600                BatchStrategy::Parallel
601            } else {
602                BatchStrategy::Parallel
603            };
604
605            // Mark tasks as assigned
606            for task_id in &ready {
607                assigned.insert(task_id.clone());
608                if let Some(task) = self.get_task_mut(task_id) {
609                    task.batch = Some(batch_index);
610                }
611            }
612
613            batches.push(TaskBatch {
614                index: batch_index,
615                tasks: ready,
616                has_conflicts,
617                strategy,
618            });
619        }
620
621        // Check for unassigned tasks (circular dependencies)
622        let unassigned: Vec<&str> = self
623            .tasks
624            .iter()
625            .filter(|t| !assigned.contains(&t.id))
626            .map(|t| t.id.as_str())
627            .collect();
628
629        if !unassigned.is_empty() {
630            bail!(
631                "Circular dependency detected — these tasks could not be batched: {}",
632                unassigned.join(", ")
633            );
634        }
635
636        self.batches = batches;
637        Ok(())
638    }
639
640    /// Validate the plan: check for missing dependencies, empty tasks, etc.
641    pub fn validate(&self) -> Vec<ValidationIssue> {
642        let mut issues = Vec::new();
643        let task_ids: std::collections::HashSet<&str> =
644            self.tasks.iter().map(|t| t.id.as_str()).collect();
645
646        for task in &self.tasks {
647            // Check dependencies exist
648            for dep in &task.depends_on {
649                if !task_ids.contains(dep.as_str()) {
650                    issues.push(ValidationIssue {
651                        severity: ValidationSeverity::Error,
652                        task_id: Some(task.id.clone()),
653                        message: format!(
654                            "Task {} depends on non-existent task '{}'",
655                            task.id, dep
656                        ),
657                    });
658                }
659            }
660
661            // Check self-dependency
662            if task.depends_on.contains(&task.id) {
663                issues.push(ValidationIssue {
664                    severity: ValidationSeverity::Error,
665                    task_id: Some(task.id.clone()),
666                    message: format!("Task {} depends on itself", task.id),
667                });
668            }
669
670            // Check for empty acceptance criteria
671            if task.acceptance_criteria.is_empty() {
672                issues.push(ValidationIssue {
673                    severity: ValidationSeverity::Warning,
674                    task_id: Some(task.id.clone()),
675                    message: format!("Task {} has no acceptance criteria", task.id),
676                });
677            }
678
679            // Check for missing verification
680            if task.verification.is_empty() {
681                issues.push(ValidationIssue {
682                    severity: ValidationSeverity::Warning,
683                    task_id: Some(task.id.clone()),
684                    message: format!("Task {} has no verification method", task.id),
685                });
686            }
687
688            // Check for missing files
689            if task.touches_files.is_empty() {
690                issues.push(ValidationIssue {
691                    severity: ValidationSeverity::Warning,
692                    task_id: Some(task.id.clone()),
693                    message: format!("Task {} has no files specified", task.id),
694                });
695            }
696        }
697
698        issues
699    }
700
701    /// Finalize the plan document.
702    pub fn finalize(&mut self) -> Result<()> {
703        let doc = PlanDocument {
704            title: self.title.clone(),
705            created_at: Utc::now().to_rfc3339(),
706            version: 1,
707            objective: self.title.clone(),
708            context: self.context.clone(),
709            tasks: self.tasks.clone(),
710            batches: self.batches.clone(),
711            risks: self.risks.clone(),
712            assumptions: self.assumptions.clone(),
713            out_of_scope: self.out_of_scope.clone(),
714            open_questions: self.open_questions.clone(),
715        };
716        self.plan = Some(doc);
717        Ok(())
718    }
719
720    /// Write the plan document to disk.
721    pub fn write_plan(&self, explicit_path: Option<&Path>) -> Result<PathBuf> {
722        let doc = self
723            .plan
724            .as_ref()
725            .context("Plan has not been finalized — call finalize() first")?;
726
727        if let Some(path) = explicit_path {
728            if let Some(parent) = path.parent() {
729                fs::create_dir_all(parent)
730                    .with_context(|| format!("Failed to create {}", parent.display()))?;
731            }
732            let content = doc.render_markdown();
733            fs::write(path, &content)
734                .with_context(|| format!("Failed to write plan to {}", path.display()))?;
735            Ok(path.to_path_buf())
736        } else {
737            let root = self
738                .project_root
739                .as_deref()
740                .context("No project root set and no explicit path provided")?;
741            let plan_dir = root.join("docs").join("plan");
742            doc.write_to_file(&plan_dir)
743        }
744    }
745}
746
747// ── Validation ─────────────────────────────────────────────────────────
748
749/// Severity of a validation issue.
750#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
751#[serde(rename_all = "snake_case")]
752pub enum ValidationSeverity {
753    /// Must be fixed before proceeding.
754    Error,
755    /// Should be fixed but doesn't block progress.
756    Warning,
757}
758
759impl fmt::Display for ValidationSeverity {
760    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
761        match self {
762            ValidationSeverity::Error => write!(f, "error"),
763            ValidationSeverity::Warning => write!(f, "warning"),
764        }
765    }
766}
767
768/// A validation issue found in the plan.
769#[derive(Debug, Clone, Serialize, Deserialize)]
770pub struct ValidationIssue {
771    /// Severity of the issue.
772    pub severity: ValidationSeverity,
773    /// Task ID this issue relates to (if applicable).
774    pub task_id: Option<String>,
775    /// Description of the issue.
776    pub message: String,
777}
778
779// ── Skill prompt ───────────────────────────────────────────────────────
780
781/// The planner skill struct for prompt generation.
782pub struct PlannerSkill;
783
784impl PlannerSkill {
785    /// Create a new planner skill instance.
786    pub fn new() -> Self {
787        Self
788    }
789
790    /// Generate the system-prompt fragment for the planner skill.
791    pub fn skill_prompt() -> String {
792        r#"# Planner Skill
793
794You are running the **planner** skill. Your job is to produce a structured
795implementation plan from requirements or a design document.
796
797## Workflow
798
799### Phase 1: Gather Context
800
8011. Read the project structure (directory tree, key config files).
8022. Read any existing specs, designs, or requirements documents.
8033. Identify conventions (coding style, testing patterns, module layout).
8044. Summarize your understanding and confirm with the user.
805
806### Phase 2: Decompose into Tasks
807
8081. Break the objective into vertical slices — each delivers a working, testable increment.
8092. For each task, define:
810   - **ID** (T1, T2, ...)
811   - **Title** — one-line description
812   - **Description** — detailed approach and key changes
813   - **Files** — exact files to create or modify
814   - **Depends on** — task IDs this depends on
815   - **Acceptance criteria** — concrete, testable conditions
816   - **Verification** — how to confirm it works (test command, build, manual check)
817   - **Complexity** — trivial / simple / moderate / complex / large
818   - **TDD** — whether to write tests first
819
8203. Rules:
821   - Every task must have acceptance criteria and a verification method.
822   - No task should exceed ~5 files.
823   - No vague tasks — each must have a clear approach.
824   - Mark logic tasks (parsers, algorithms, data transforms) for TDD.
825
826### Phase 3: Build Batches
827
8281. Group tasks into execution batches based on dependencies:
829   - Batch 1: tasks with no dependencies (max parallelism)
830   - Batch N: tasks whose dependencies are all in earlier batches
8312. Detect file conflicts between tasks in the same batch.
8323. Mark conflicting batches as sequential; non-conflicting as parallel.
8334. Present the batch plan for review.
834
835### Phase 4: Review
836
8371. Validate the plan:
838   - All dependencies exist
839   - No circular dependencies
840   - Every task has acceptance criteria and verification
841   - Every requirement is covered by at least one task
8422. Identify risks and mitigations
8433. List assumptions and open questions
8444. Iterate until the user approves
845
846### Phase 5: Document
847
8481. Write the plan to `docs/plan/YYYY-MM-DD-<slug>.md`.
8492. Confirm the file was written.
8503. The plan is now ready for hand-off to implementation.
851
852## Rules
853
854- Simplicity first: fewer tasks is better than more.
855- Every task must be independently verifiable.
856- Dependencies must form a DAG (no cycles).
857- If a task is too complex to describe in a paragraph, split it.
858- Prefer vertical slices over horizontal layers.
859"#
860        .to_string()
861    }
862}
863
864impl Default for PlannerSkill {
865    fn default() -> Self {
866        Self::new()
867    }
868}
869
870impl fmt::Debug for PlannerSkill {
871    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
872        f.debug_struct("PlannerSkill").finish()
873    }
874}
875
876// ── Helpers ────────────────────────────────────────────────────────────
877
878/// Convert a title into a filesystem-safe slug.
879fn slugify(s: &str) -> String {
880    s.to_lowercase()
881        .chars()
882        .map(|c| {
883            if c.is_ascii_alphanumeric() {
884                c
885            } else if c == ' ' || c == '_' || c == '-' {
886                '-'
887            } else {
888                '\0'
889            }
890        })
891        .filter(|c| *c != '\0')
892        .collect::<String>()
893        .trim_matches('-')
894        .to_string()
895}
896
897// ── Tests ──────────────────────────────────────────────────────────────
898
899#[cfg(test)]
900mod tests {
901    use super::*;
902    use std::fs;
903
904    fn sample_task(id: &str, title: &str, depends_on: Vec<&str>) -> PlanTask {
905        PlanTask {
906            id: id.to_string(),
907            title: title.to_string(),
908            description: format!("Implement {}", title),
909            touches_files: vec![format!("src/{}.rs", id.to_lowercase())],
910            depends_on: depends_on.into_iter().map(|s| s.to_string()).collect(),
911            acceptance_criteria: vec![format!("{} works correctly", title)],
912            verification: format!("cargo test {}", id.to_lowercase()),
913            complexity: TaskComplexity::Moderate,
914            tdd: false,
915            batch: None,
916        }
917    }
918
919    #[test]
920    fn test_session_new() {
921        let session = PlannerSession::new("Build auth system");
922        assert_eq!(session.phase, PlannerPhase::Gather);
923        assert_eq!(session.title, "Build auth system");
924        assert!(session.tasks.is_empty());
925        assert!(session.batches.is_empty());
926    }
927
928    #[test]
929    fn test_phase_advance() {
930        let mut session = PlannerSession::new("test");
931        assert_eq!(session.phase, PlannerPhase::Gather);
932
933        session.advance().unwrap();
934        assert_eq!(session.phase, PlannerPhase::Decompose);
935
936        session.advance().unwrap();
937        assert_eq!(session.phase, PlannerPhase::Batch);
938
939        session.advance().unwrap();
940        assert_eq!(session.phase, PlannerPhase::Review);
941
942        session.advance().unwrap();
943        assert_eq!(session.phase, PlannerPhase::Document);
944
945        session.advance().unwrap();
946        assert_eq!(session.phase, PlannerPhase::Done);
947
948        assert!(session.advance().is_err());
949    }
950
951    #[test]
952    fn test_set_phase() {
953        let mut session = PlannerSession::new("test");
954        session.set_phase(PlannerPhase::Review);
955        assert_eq!(session.phase, PlannerPhase::Review);
956    }
957
958    #[test]
959    fn test_add_and_get_tasks() {
960        let mut session = PlannerSession::new("test");
961        session.add_task(sample_task("T1", "Core module", vec![]));
962        session.add_task(sample_task("T2", "API layer", vec!["T1"]));
963
964        assert_eq!(session.task_count(), 2);
965        assert_eq!(session.get_task("T1").unwrap().title, "Core module");
966        assert_eq!(
967            session.get_task("T2").unwrap().depends_on,
968            vec!["T1".to_string()]
969        );
970        assert!(session.get_task("T99").is_none());
971    }
972
973    #[test]
974    fn test_risks_and_assumptions() {
975        let mut session = PlannerSession::new("test");
976        session.add_risk(PlanRisk {
977            description: "Third-party API may change".to_string(),
978            likelihood: RiskLikelihood::Medium,
979            impact: RiskImpact::High,
980            mitigation: "Abstract behind interface".to_string(),
981        });
982        session.add_assumption("Node.js >= 18");
983        session.add_out_of_scope("Mobile app");
984        session.add_open_question("Which DB to use?");
985
986        assert_eq!(session.risks.len(), 1);
987        assert_eq!(session.assumptions, vec!["Node.js >= 18"]);
988        assert_eq!(session.out_of_scope, vec!["Mobile app"]);
989        assert_eq!(session.open_questions, vec!["Which DB to use?"]);
990    }
991
992    #[test]
993    fn test_build_batches_linear() {
994        let mut session = PlannerSession::new("test");
995        session.add_task(sample_task("T1", "Base", vec![]));
996        session.add_task(sample_task("T2", "Mid", vec!["T1"]));
997        session.add_task(sample_task("T3", "Top", vec!["T2"]));
998
999        session.build_batches().unwrap();
1000
1001        assert_eq!(session.batches.len(), 3);
1002        assert_eq!(session.batches[0].tasks, vec!["T1"]);
1003        assert_eq!(session.batches[1].tasks, vec!["T2"]);
1004        assert_eq!(session.batches[2].tasks, vec!["T3"]);
1005    }
1006
1007    #[test]
1008    fn test_build_batches_parallel() {
1009        let mut session = PlannerSession::new("test");
1010        session.add_task(sample_task("T1", "A", vec![]));
1011        session.add_task(sample_task("T2", "B", vec![]));
1012        session.add_task(sample_task("T3", "C", vec!["T1", "T2"]));
1013
1014        session.build_batches().unwrap();
1015
1016        assert_eq!(session.batches.len(), 2);
1017        // T1 and T2 in batch 0 (parallel)
1018        assert_eq!(session.batches[0].tasks.len(), 2);
1019        assert!(session.batches[0].tasks.contains(&"T1".to_string()));
1020        assert!(session.batches[0].tasks.contains(&"T2".to_string()));
1021        assert_eq!(session.batches[0].strategy, BatchStrategy::Parallel);
1022        // T3 in batch 1
1023        assert_eq!(session.batches[1].tasks, vec!["T3"]);
1024    }
1025
1026    #[test]
1027    fn test_build_batches_file_conflicts() {
1028        let mut session = PlannerSession::new("test");
1029        let mut t1 = sample_task("T1", "A", vec![]);
1030        let mut t2 = sample_task("T2", "B", vec![]);
1031        // Both touch the same file
1032        t1.touches_files = vec!["src/lib.rs".to_string()];
1033        t2.touches_files = vec!["src/lib.rs".to_string()];
1034
1035        session.add_task(t1);
1036        session.add_task(t2);
1037
1038        session.build_batches().unwrap();
1039
1040        assert_eq!(session.batches.len(), 1);
1041        assert!(session.batches[0].has_conflicts);
1042        assert_eq!(session.batches[0].strategy, BatchStrategy::Sequential);
1043    }
1044
1045    #[test]
1046    fn test_build_batches_circular_dependency() {
1047        let mut session = PlannerSession::new("test");
1048        session.add_task(sample_task("T1", "A", vec!["T2"]));
1049        session.add_task(sample_task("T2", "B", vec!["T1"]));
1050
1051        let result = session.build_batches();
1052        assert!(result.is_err());
1053        assert!(result.unwrap_err().to_string().contains("Circular"));
1054    }
1055
1056    #[test]
1057    fn test_build_batches_empty_tasks() {
1058        let mut session = PlannerSession::new("test");
1059        assert!(session.build_batches().is_err());
1060    }
1061
1062    #[test]
1063    fn test_validate_clean() {
1064        let mut session = PlannerSession::new("test");
1065        session.add_task(sample_task("T1", "Good task", vec![]));
1066
1067        let issues = session.validate();
1068        assert!(issues.is_empty());
1069    }
1070
1071    #[test]
1072    fn test_validate_missing_dependency() {
1073        let mut session = PlannerSession::new("test");
1074        session.add_task(sample_task("T1", "Task", vec!["NONEXISTENT"]));
1075
1076        let issues = session.validate();
1077        assert!(issues.iter().any(|i| i.severity == ValidationSeverity::Error
1078            && i.message.contains("non-existent")));
1079    }
1080
1081    #[test]
1082    fn test_validate_self_dependency() {
1083        let mut session = PlannerSession::new("test");
1084        session.add_task(sample_task("T1", "Task", vec!["T1"]));
1085
1086        let issues = session.validate();
1087        assert!(issues.iter().any(|i| i.message.contains("depends on itself")));
1088    }
1089
1090    #[test]
1091    fn test_validate_warnings() {
1092        let mut session = PlannerSession::new("test");
1093        session.add_task(PlanTask {
1094            id: "T1".to_string(),
1095            title: "Vague".to_string(),
1096            description: "Do something".to_string(),
1097            touches_files: vec![],
1098            depends_on: vec![],
1099            acceptance_criteria: vec![],
1100            verification: String::new(),
1101            complexity: TaskComplexity::Simple,
1102            tdd: false,
1103            batch: None,
1104        });
1105
1106        let issues = session.validate();
1107        assert!(issues.iter().any(|i| i.message.contains("no acceptance criteria")));
1108        assert!(issues.iter().any(|i| i.message.contains("no verification method")));
1109        assert!(issues.iter().any(|i| i.message.contains("no files specified")));
1110    }
1111
1112    #[test]
1113    fn test_finalize_and_write() {
1114        let tmp = tempfile::tempdir().unwrap();
1115        let mut session = PlannerSession::new("Auth System Plan")
1116            .with_project_root(tmp.path());
1117        session.add_task(sample_task("T1", "Core auth", vec![]));
1118        session.build_batches().unwrap();
1119        session.finalize().unwrap();
1120
1121        let path = session.write_plan(None).unwrap();
1122        assert!(path.exists());
1123        assert!(path.to_string_lossy().contains("docs/plan"));
1124
1125        let content = fs::read_to_string(&path).unwrap();
1126        assert!(content.contains("# Auth System Plan"));
1127        assert!(content.contains("## Tasks"));
1128        assert!(content.contains("T1"));
1129    }
1130
1131    #[test]
1132    fn test_write_plan_explicit_path() {
1133        let tmp = tempfile::tempdir().unwrap();
1134        let mut session = PlannerSession::new("test");
1135        session.add_task(sample_task("T1", "Task", vec![]));
1136        session.build_batches().unwrap();
1137        session.finalize().unwrap();
1138
1139        let explicit = tmp.path().join("custom-plan.md");
1140        let path = session.write_plan(Some(&explicit)).unwrap();
1141        assert_eq!(path, explicit);
1142        assert!(path.exists());
1143    }
1144
1145    #[test]
1146    fn test_write_plan_not_finalized() {
1147        let session = PlannerSession::new("test");
1148        assert!(session.write_plan(None).is_err());
1149    }
1150
1151    #[test]
1152    fn test_render_markdown() {
1153        let mut session = PlannerSession::new("Test Plan");
1154        session.add_assumption("Rust stable");
1155        session.add_out_of_scope("Benchmarking");
1156        session.add_open_question("DB choice?");
1157        session.add_risk(PlanRisk {
1158            description: "API unstable".to_string(),
1159            likelihood: RiskLikelihood::Medium,
1160            impact: RiskImpact::High,
1161            mitigation: "Pin version".to_string(),
1162        });
1163        session.add_task(PlanTask {
1164            id: "T1".to_string(),
1165            title: "Setup project".to_string(),
1166            description: "Initialize the project structure".to_string(),
1167            touches_files: vec!["Cargo.toml".to_string(), "src/lib.rs".to_string()],
1168            depends_on: vec![],
1169            acceptance_criteria: vec!["Project compiles".to_string()],
1170            verification: "cargo build".to_string(),
1171            complexity: TaskComplexity::Trivial,
1172            tdd: false,
1173            batch: Some(0),
1174        });
1175        session.finalize().unwrap();
1176
1177        let md = session.plan.unwrap().render_markdown();
1178        assert!(md.contains("# Test Plan"));
1179        assert!(md.contains("## Objective"));
1180        assert!(md.contains("## Tasks"));
1181        assert!(md.contains("### T1 [Batch 0]: Setup project"));
1182        assert!(md.contains("`Cargo.toml`"));
1183        assert!(md.contains("## Risks"));
1184        assert!(md.contains("API unstable"));
1185        assert!(md.contains("## Assumptions"));
1186        assert!(md.contains("Rust stable"));
1187        assert!(md.contains("## Out of Scope"));
1188        assert!(md.contains("Benchmarking"));
1189        assert!(md.contains("## Open Questions"));
1190        assert!(md.contains("DB choice?"));
1191    }
1192
1193    #[test]
1194    fn test_session_serialization_roundtrip() {
1195        let mut session = PlannerSession::new("Test");
1196        session.add_task(sample_task("T1", "Task", vec![]));
1197        session.set_phase(PlannerPhase::Batch);
1198
1199        let json = serde_json::to_string(&session).unwrap();
1200        let parsed: PlannerSession = serde_json::from_str(&json).unwrap();
1201        assert_eq!(parsed.title, "Test");
1202        assert_eq!(parsed.phase, PlannerPhase::Batch);
1203        assert_eq!(parsed.tasks.len(), 1);
1204    }
1205
1206    #[test]
1207    fn test_task_complexity_display() {
1208        assert_eq!(format!("{}", TaskComplexity::Trivial), "trivial");
1209        assert_eq!(format!("{}", TaskComplexity::Simple), "simple");
1210        assert_eq!(format!("{}", TaskComplexity::Moderate), "moderate");
1211        assert_eq!(format!("{}", TaskComplexity::Complex), "complex");
1212        assert_eq!(format!("{}", TaskComplexity::Large), "large");
1213    }
1214
1215    #[test]
1216    fn test_batch_strategy_display() {
1217        assert_eq!(format!("{}", BatchStrategy::Parallel), "parallel");
1218        assert_eq!(format!("{}", BatchStrategy::Sequential), "sequential");
1219        assert_eq!(format!("{}", BatchStrategy::Chain), "chain");
1220    }
1221
1222    #[test]
1223    fn test_phase_display() {
1224        assert_eq!(format!("{}", PlannerPhase::Gather), "Gather");
1225        assert_eq!(format!("{}", PlannerPhase::Decompose), "Decompose");
1226        assert_eq!(format!("{}", PlannerPhase::Batch), "Batch");
1227        assert_eq!(format!("{}", PlannerPhase::Review), "Review");
1228        assert_eq!(format!("{}", PlannerPhase::Document), "Document");
1229        assert_eq!(format!("{}", PlannerPhase::Done), "Done");
1230    }
1231
1232    #[test]
1233    fn test_risk_likelihood_display() {
1234        assert_eq!(format!("{}", RiskLikelihood::Low), "low");
1235        assert_eq!(format!("{}", RiskLikelihood::Medium), "medium");
1236        assert_eq!(format!("{}", RiskLikelihood::High), "high");
1237    }
1238
1239    #[test]
1240    fn test_risk_impact_display() {
1241        assert_eq!(format!("{}", RiskImpact::Low), "low");
1242        assert_eq!(format!("{}", RiskImpact::Medium), "medium");
1243        assert_eq!(format!("{}", RiskImpact::High), "high");
1244    }
1245
1246    #[test]
1247    fn test_skill_prompt_not_empty() {
1248        let prompt = PlannerSkill::skill_prompt();
1249        assert!(prompt.contains("Planner Skill"));
1250        assert!(prompt.contains("Phase 1: Gather"));
1251        assert!(prompt.contains("Phase 5: Document"));
1252    }
1253
1254    #[test]
1255    fn test_slugify() {
1256        assert_eq!(slugify("Build Auth System"), "build-auth-system");
1257        assert_eq!(slugify("hello_world"), "hello-world");
1258        assert_eq!(slugify("  spaces  "), "spaces");
1259    }
1260
1261    #[test]
1262    fn test_validation_issue_severity_display() {
1263        assert_eq!(format!("{}", ValidationSeverity::Error), "error");
1264        assert_eq!(format!("{}", ValidationSeverity::Warning), "warning");
1265    }
1266}