Skip to main content

coda_core/
planner.rs

1//! Planning session for interactive feature planning.
2//!
3//! Provides `PlanSession` which wraps a `ClaudeClient` with the Planner
4//! profile for multi-turn feature planning conversations, and `PlanOutput`
5//! describing the artifacts produced by a successful planning session.
6
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use claude_agent_sdk_rs::{ClaudeClient, ContentBlock, Message};
11use coda_pm::PromptManager;
12use futures::StreamExt;
13use tracing::{debug, info, warn};
14
15use crate::CoreError;
16use crate::config::CodaConfig;
17use crate::git::GitOps;
18use crate::profile::AgentProfile;
19use crate::state::{
20    FeatureInfo, FeatureState, FeatureStatus, GitInfo, PhaseKind, PhaseRecord, PhaseStatus,
21    TokenCost, TotalStats,
22};
23
24/// Output produced by finalizing a planning session.
25#[derive(Debug)]
26pub struct PlanOutput {
27    /// Path to the generated design spec (`<worktree>/.coda/<slug>/specs/design.md`).
28    pub design_spec: PathBuf,
29
30    /// Path to the generated verification plan (`<worktree>/.coda/<slug>/specs/verification.md`).
31    pub verification: PathBuf,
32
33    /// Path to the feature state file (`<worktree>/.coda/<slug>/state.yml`).
34    pub state: PathBuf,
35
36    /// Path to the git worktree (`.trees/<slug>/`).
37    pub worktree: PathBuf,
38}
39
40/// Fixed quality-assurance phase names appended after dynamic dev phases.
41const QUALITY_PHASES: &[&str] = &["review", "verify"];
42
43/// An interactive planning session wrapping a `ClaudeClient` with the
44/// Planner profile for multi-turn feature planning conversations.
45///
46/// # Usage
47///
48/// 1. Create via [`PlanSession::new`]
49/// 2. Call [`send`](Self::send) repeatedly for conversation turns
50/// 3. Call [`approve`](Self::approve) to formalize the approved design
51/// 4. Call [`finalize`](Self::finalize) to write specs and create a worktree
52pub struct PlanSession {
53    client: ClaudeClient,
54    feature_slug: String,
55    project_root: PathBuf,
56    pm: PromptManager,
57    config: CodaConfig,
58    connected: bool,
59    /// The formalized design spec produced by [`approve`](Self::approve).
60    approved_design: Option<String>,
61    /// The verification plan produced by [`approve`](Self::approve).
62    approved_verification: Option<String>,
63    /// Git operations implementation.
64    git: Arc<dyn GitOps>,
65    /// Accumulated API cost in USD across all planning turns.
66    planning_cost_usd: f64,
67    /// Accumulated conversation turns across the entire planning session.
68    planning_turns: u32,
69}
70
71impl PlanSession {
72    /// Creates a new planning session for the given feature.
73    ///
74    /// Initializes a `ClaudeClient` with the Planner profile and renders
75    /// the planning system prompt with repository context.
76    ///
77    /// # Errors
78    ///
79    /// Returns `CoreError` if the system prompt cannot be rendered.
80    pub fn new(
81        feature_slug: String,
82        project_root: PathBuf,
83        pm: &PromptManager,
84        config: &CodaConfig,
85        git: Arc<dyn GitOps>,
86    ) -> Result<Self, CoreError> {
87        let coda_md_path = project_root.join(".coda.md");
88        let coda_md = match std::fs::read_to_string(&coda_md_path) {
89            Ok(content) => content,
90            Err(_) => {
91                warn!(
92                    path = %coda_md_path.display(),
93                    "Missing .coda.md — planning agent will lack repository context. \
94                     Run `coda init` to generate it."
95                );
96                String::new()
97            }
98        };
99
100        let system_prompt = pm.render("plan/system", minijinja::context!(coda_md => coda_md))?;
101
102        let options = AgentProfile::Planner.to_options(
103            &system_prompt,
104            project_root.clone(),
105            config.agent.max_turns,
106            config.agent.max_budget_usd,
107            &config.agent.model,
108        );
109
110        let client = ClaudeClient::new(options);
111
112        Ok(Self {
113            client,
114            feature_slug,
115            project_root,
116            pm: pm.clone(),
117            config: config.clone(),
118            connected: false,
119            approved_design: None,
120            approved_verification: None,
121            git,
122            planning_cost_usd: 0.0,
123            planning_turns: 0,
124        })
125    }
126
127    /// Connects the underlying `ClaudeClient` to the Claude process.
128    ///
129    /// # Errors
130    ///
131    /// Returns `CoreError::AgentError` if the connection fails.
132    pub async fn connect(&mut self) -> Result<(), CoreError> {
133        self.client
134            .connect()
135            .await
136            .map_err(|e| CoreError::AgentError(e.to_string()))?;
137        self.connected = true;
138        debug!("PlanSession connected to Claude");
139        Ok(())
140    }
141
142    /// Disconnects the underlying `ClaudeClient`.
143    ///
144    /// Safe to call multiple times or when not connected.
145    pub async fn disconnect(&mut self) {
146        if self.connected {
147            let _ = self.client.disconnect().await;
148            self.connected = false;
149            debug!("PlanSession disconnected from Claude");
150        }
151    }
152
153    /// Sends a user message and collects the agent's response.
154    ///
155    /// Automatically connects on the first call.
156    ///
157    /// # Errors
158    ///
159    /// Returns `CoreError::AgentError` if the query or response
160    /// streaming fails.
161    pub async fn send(&mut self, message: &str) -> Result<String, CoreError> {
162        if !self.connected {
163            self.connect().await?;
164        }
165
166        self.client
167            .query(message)
168            .await
169            .map_err(|e| CoreError::AgentError(e.to_string()))?;
170
171        let mut response = String::new();
172        {
173            let mut stream = self.client.receive_response();
174            while let Some(result) = stream.next().await {
175                let msg = result.map_err(|e| CoreError::AgentError(e.to_string()))?;
176                match msg {
177                    Message::Assistant(assistant) => {
178                        for block in &assistant.message.content {
179                            if let ContentBlock::Text(text) = block {
180                                response.push_str(&text.text);
181                            }
182                        }
183                    }
184                    Message::Result(result_msg) => {
185                        if let Some(cost) = result_msg.total_cost_usd {
186                            self.planning_cost_usd += cost;
187                        }
188                        self.planning_turns += result_msg.num_turns;
189                        break;
190                    }
191                    _ => {}
192                }
193            }
194        }
195
196        Ok(response)
197    }
198
199    /// Formalizes the approved design and generates a verification plan.
200    ///
201    /// First asks the agent to produce a structured design specification
202    /// document, then generates a verification plan based on the design.
203    /// Both are stored so [`finalize`](Self::finalize) can write them
204    /// directly without re-generating.
205    ///
206    /// Returns `(design, verification)` so the UI can display both.
207    ///
208    /// # Errors
209    ///
210    /// Returns `CoreError` if template rendering or agent communication fails.
211    pub async fn approve(&mut self) -> Result<(String, String), CoreError> {
212        // Step 1: Generate formal design spec
213        let approve_prompt = self.pm.render(
214            "plan/approve",
215            minijinja::context!(
216                feature_slug => &self.feature_slug,
217            ),
218        )?;
219
220        let design = self.send(&approve_prompt).await?;
221        info!("Design approved and formalized");
222
223        // Step 2: Generate verification plan
224        let verification_prompt = self.pm.render(
225            "plan/verification",
226            minijinja::context!(
227                design_spec => &design,
228                checks => &self.config.checks,
229                feature_slug => &self.feature_slug,
230            ),
231        )?;
232
233        let verification = match self.send(&verification_prompt).await {
234            Ok(v) => v,
235            Err(e) => {
236                // Both must succeed atomically; don't leave partial state
237                return Err(e);
238            }
239        };
240        info!("Verification plan generated");
241
242        // Store both only after both succeed
243        self.approved_design = Some(design.clone());
244        self.approved_verification = Some(verification.clone());
245
246        Ok((design, verification))
247    }
248
249    /// Returns `true` if both design and verification have been approved
250    /// via [`approve`](Self::approve).
251    pub fn is_approved(&self) -> bool {
252        self.approved_design.is_some() && self.approved_verification.is_some()
253    }
254
255    /// Finalizes the planning session by creating a worktree and writing specs.
256    ///
257    /// This method:
258    /// 1. Creates a git worktree from the base branch
259    /// 2. Writes the approved design spec into the worktree
260    /// 3. Writes the approved verification plan into the worktree
261    /// 4. Writes the initial `state.yml` into the worktree
262    ///
263    /// All feature artifacts are written under `<worktree>/.coda/<slug>/`
264    /// so they travel with the feature branch and merge cleanly into main.
265    ///
266    /// # Errors
267    ///
268    /// Returns `CoreError` if git operations, directory creation, or
269    /// file writes fail.
270    pub async fn finalize(&mut self) -> Result<PlanOutput, CoreError> {
271        let slug = self.feature_slug.clone();
272        let worktree_abs = self.project_root.join(".trees").join(&slug);
273        let worktree_rel = PathBuf::from(".trees").join(&slug);
274
275        // Guard: design and verification must be approved before finalizing.
276        // Clone instead of take so a retry is possible if a later step fails.
277        let design_content = self.approved_design.clone().ok_or_else(|| {
278            CoreError::PlanError(
279                "Cannot finalize: design has not been approved. Use /approve first.".to_string(),
280            )
281        })?;
282        let verification_content = self.approved_verification.clone().ok_or_else(|| {
283            CoreError::PlanError("Cannot finalize: verification plan is missing.".to_string())
284        })?;
285
286        // 1. Create git worktree
287        let base_branch = if self.config.git.base_branch == "auto" {
288            self.git.detect_default_branch()
289        } else {
290            self.config.git.base_branch.clone()
291        };
292        let branch_name = format!("{}/{}", self.config.git.branch_prefix, slug);
293        if let Some(parent) = worktree_abs.parent() {
294            tokio::fs::create_dir_all(parent)
295                .await
296                .map_err(CoreError::IoError)?;
297        }
298        self.git
299            .worktree_add(&worktree_abs, &branch_name, &base_branch)?;
300        info!(
301            branch = %branch_name,
302            worktree = %worktree_abs.display(),
303            "Created git worktree"
304        );
305
306        // 2. Write artifacts into worktree/.coda/<slug>/
307        let coda_feature_dir = worktree_abs.join(".coda").join(&slug);
308        let specs_dir = coda_feature_dir.join("specs");
309        tokio::fs::create_dir_all(&specs_dir)
310            .await
311            .map_err(CoreError::IoError)?;
312
313        let design_spec_path = specs_dir.join("design.md");
314        tokio::fs::write(&design_spec_path, &design_content)
315            .await
316            .map_err(CoreError::IoError)?;
317        debug!(path = %design_spec_path.display(), "Wrote design spec");
318
319        let verification_path = specs_dir.join("verification.md");
320        tokio::fs::write(&verification_path, &verification_content)
321            .await
322            .map_err(CoreError::IoError)?;
323        debug!(path = %verification_path.display(), "Wrote verification plan");
324
325        // 3. Write initial state.yml (store relative worktree path for portability)
326        let dev_phases = extract_dev_phases(&design_content);
327        let state = build_initial_state(
328            &self.feature_slug,
329            &worktree_rel,
330            &branch_name,
331            &base_branch,
332            &dev_phases,
333            self.planning_turns,
334            self.planning_cost_usd,
335        );
336        let state_path = coda_feature_dir.join("state.yml");
337        let state_yaml = serde_yaml::to_string(&state)?;
338        tokio::fs::write(&state_path, state_yaml)
339            .await
340            .map_err(CoreError::IoError)?;
341        debug!(path = %state_path.display(), "Wrote state.yml");
342
343        // 4. Initial commit so planning artifacts are version-controlled
344        self.git.add(&worktree_abs, &[".coda/"])?;
345        if self.git.has_staged_changes(&worktree_abs) {
346            self.git.commit(
347                &worktree_abs,
348                &format!("feat({slug}): initialize planning artifacts"),
349            )?;
350            info!("Committed initial planning artifacts");
351        }
352
353        // 5. Clear approved state only after everything succeeded
354        self.approved_design = None;
355        self.approved_verification = None;
356
357        // 6. Disconnect client
358        self.disconnect().await;
359
360        info!("Planning session finalized successfully");
361
362        Ok(PlanOutput {
363            design_spec: design_spec_path,
364            verification: verification_path,
365            state: state_path,
366            worktree: worktree_abs,
367        })
368    }
369
370    /// Returns the directory name for this feature (the slug itself).
371    pub fn feature_dir_name(&self) -> &str {
372        &self.feature_slug
373    }
374
375    /// Returns the feature slug.
376    pub fn feature_slug(&self) -> &str {
377        &self.feature_slug
378    }
379}
380
381impl std::fmt::Debug for PlanSession {
382    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383        f.debug_struct("PlanSession")
384            .field("feature_slug", &self.feature_slug)
385            .field("project_root", &self.project_root)
386            .field("connected", &self.connected)
387            .finish_non_exhaustive()
388    }
389}
390
391/// Extracts development phase names from a design specification.
392///
393/// Matches headings like `## Phase 1: <name>`, `### Phase 1: <name>`, or
394/// `#### Phase 1: <name>` (2–4 `#` levels). Parenthetical annotations
395/// such as `(Day 1)` or `(Day 1-2)` are stripped before slugifying.
396/// Falls back to a single `"default"` phase if no phase headings are found.
397///
398/// # Examples
399///
400/// ```
401/// # use coda_core::planner::extract_dev_phases;
402/// let design = "### Phase 1: Type Definitions\n### Phase 2: Transport Layer\n";
403/// let phases = extract_dev_phases(design);
404/// assert_eq!(phases, vec!["type-definitions", "transport-layer"]);
405/// ```
406pub fn extract_dev_phases(design_content: &str) -> Vec<String> {
407    let mut phases = Vec::new();
408
409    for line in design_content.lines() {
410        let trimmed = line.trim();
411        // Match 2–4 leading '#' followed by " Phase "
412        let rest = if let Some(r) = trimmed.strip_prefix("#### Phase ") {
413            Some(r)
414        } else if let Some(r) = trimmed.strip_prefix("### Phase ") {
415            Some(r)
416        } else {
417            trimmed.strip_prefix("## Phase ")
418        };
419        let Some(rest) = rest else { continue };
420
421        // Skip the number and colon: "1: <name>" → "<name>"
422        if let Some((_num, name)) = rest.split_once(':') {
423            let name = strip_parenthetical(name.trim());
424            if !name.is_empty() {
425                let slug = slugify_phase_name(&name);
426                if !slug.is_empty() {
427                    phases.push(slug);
428                }
429            }
430        }
431    }
432
433    if phases.is_empty() {
434        phases.push("default".to_string());
435    }
436
437    phases
438}
439
440/// Strips trailing parenthetical annotations from a phase name.
441///
442/// For example, `"Type Definitions (Day 1)"` becomes `"Type Definitions"`.
443fn strip_parenthetical(name: &str) -> String {
444    if let Some(idx) = name.rfind('(') {
445        let before = name[..idx].trim_end();
446        if !before.is_empty() {
447            return before.to_string();
448        }
449    }
450    name.to_string()
451}
452
453/// Converts a phase name into a slug suitable for use as a phase identifier.
454///
455/// Lowercases ASCII characters, preserves non-ASCII (e.g., CJK) as-is,
456/// replaces spaces/underscores/punctuation with hyphens, and collapses
457/// consecutive hyphens.
458fn slugify_phase_name(name: &str) -> String {
459    let slug: String = name
460        .chars()
461        .map(|c| {
462            if c.is_alphanumeric() {
463                if c.is_ascii() {
464                    c.to_ascii_lowercase()
465                } else {
466                    c
467                }
468            } else {
469                '-'
470            }
471        })
472        .collect();
473
474    // Collapse consecutive hyphens and trim leading/trailing hyphens
475    let mut result = String::with_capacity(slug.len());
476    let mut prev_was_hyphen = true; // Start true to trim leading
477    for c in slug.chars() {
478        if c == '-' {
479            if !prev_was_hyphen {
480                result.push('-');
481            }
482            prev_was_hyphen = true;
483        } else {
484            result.push(c);
485            prev_was_hyphen = false;
486        }
487    }
488
489    // Trim trailing hyphen
490    if result.ends_with('-') {
491        result.pop();
492    }
493
494    result
495}
496
497/// Builds an initial `FeatureState` for a newly planned feature.
498///
499/// Creates dev phases from `dev_phase_names`, appends the fixed
500/// review + verify quality phases, and initialises everything to `Pending`
501/// with zeroed cost/duration statistics. Planning session costs are
502/// recorded in `total` so they appear in status reports.
503pub(crate) fn build_initial_state(
504    feature_slug: &str,
505    worktree_path: &Path,
506    branch: &str,
507    base_branch: &str,
508    dev_phase_names: &[String],
509    planning_turns: u32,
510    planning_cost_usd: f64,
511) -> FeatureState {
512    let now = chrono::Utc::now();
513
514    let make_record = |name: &str, kind: PhaseKind| PhaseRecord {
515        name: name.to_string(),
516        kind,
517        status: PhaseStatus::Pending,
518        started_at: None,
519        completed_at: None,
520        turns: 0,
521        cost_usd: 0.0,
522        cost: TokenCost::default(),
523        duration_secs: 0,
524        details: serde_json::json!({}),
525    };
526
527    let mut phases: Vec<PhaseRecord> = dev_phase_names
528        .iter()
529        .map(|name| make_record(name, PhaseKind::Dev))
530        .collect();
531
532    for &qp in QUALITY_PHASES {
533        phases.push(make_record(qp, PhaseKind::Quality));
534    }
535
536    FeatureState {
537        feature: FeatureInfo {
538            slug: feature_slug.to_string(),
539            created_at: now,
540            updated_at: now,
541        },
542        status: FeatureStatus::Planned,
543        current_phase: 0,
544        git: GitInfo {
545            worktree_path: worktree_path.to_path_buf(),
546            branch: branch.to_string(),
547            base_branch: base_branch.to_string(),
548        },
549        phases,
550        pr: None,
551        total: TotalStats {
552            turns: planning_turns,
553            cost_usd: planning_cost_usd,
554            ..TotalStats::default()
555        },
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn test_should_build_initial_state_with_dynamic_dev_phases() {
565        let worktree = PathBuf::from(".trees/add-auth");
566        let dev_phases = vec![
567            "type-definitions".to_string(),
568            "transport-layer".to_string(),
569            "client-methods".to_string(),
570        ];
571        let state = build_initial_state(
572            "add-auth",
573            &worktree,
574            "feature/add-auth",
575            "main",
576            &dev_phases,
577            5,
578            0.42,
579        );
580
581        assert_eq!(state.feature.slug, "add-auth");
582        assert!(state.feature.created_at <= chrono::Utc::now());
583        assert!(state.feature.updated_at <= chrono::Utc::now());
584
585        assert_eq!(state.status, FeatureStatus::Planned);
586        assert_eq!(state.current_phase, 0);
587
588        assert_eq!(state.git.worktree_path, PathBuf::from(".trees/add-auth"));
589        assert_eq!(state.git.branch, "feature/add-auth");
590        assert_eq!(state.git.base_branch, "main");
591
592        // Phases: 3 dev + 2 quality = 5
593        assert_eq!(state.phases.len(), 5);
594        let names: Vec<&str> = state.phases.iter().map(|p| p.name.as_str()).collect();
595        assert_eq!(
596            names,
597            vec![
598                "type-definitions",
599                "transport-layer",
600                "client-methods",
601                "review",
602                "verify"
603            ]
604        );
605
606        assert_eq!(state.phases[0].kind, PhaseKind::Dev);
607        assert_eq!(state.phases[3].kind, PhaseKind::Quality);
608        assert_eq!(state.phases[4].kind, PhaseKind::Quality);
609
610        for phase in &state.phases {
611            assert_eq!(phase.status, PhaseStatus::Pending);
612            assert!(phase.started_at.is_none());
613            assert_eq!(phase.turns, 0);
614        }
615
616        assert!(state.pr.is_none());
617        // Planning cost is preserved in total stats
618        assert_eq!(state.total.turns, 5);
619        assert!((state.total.cost_usd - 0.42).abs() < f64::EPSILON);
620    }
621
622    #[test]
623    fn test_should_build_initial_state_serializable_to_yaml() {
624        let worktree = PathBuf::from(".trees/new-feature");
625        let dev_phases = vec!["phase-one".to_string(), "phase-two".to_string()];
626        let state = build_initial_state(
627            "new-feature",
628            &worktree,
629            "feature/new-feature",
630            "main",
631            &dev_phases,
632            0,
633            0.0,
634        );
635
636        let yaml = serde_yaml::to_string(&state).unwrap();
637        assert!(yaml.contains("planned"));
638        assert!(yaml.contains("new-feature"));
639        assert!(yaml.contains("phase-one"));
640        assert!(yaml.contains("review"));
641        assert!(yaml.contains("verify"));
642
643        let deserialized: FeatureState = serde_yaml::from_str(&yaml).unwrap();
644        assert_eq!(deserialized.phases.len(), 4);
645        assert_eq!(deserialized.status, FeatureStatus::Planned);
646    }
647
648    #[test]
649    fn test_should_extract_dev_phases_from_design_spec() {
650        let design = r#"
651# Feature: support-image-input
652
653## Development Phases
654
655### Phase 1: Type Definitions (Day 1)
656- **Goal**: Define types
657- **Tasks**:
658  - Create models
659
660### Phase 2: Transport Layer (Day 1-2)
661- **Goal**: Implement transport
662- **Tasks**:
663  - Wire up transport
664
665### Phase 3: Testing & Documentation (Day 3)
666- **Goal**: Complete testing
667"#;
668
669        let phases = extract_dev_phases(design);
670        assert_eq!(phases.len(), 3);
671        assert_eq!(phases[0], "type-definitions");
672        assert_eq!(phases[1], "transport-layer");
673        assert_eq!(phases[2], "testing-documentation");
674    }
675
676    #[test]
677    fn test_should_extract_phases_with_varied_heading_levels() {
678        let design = "## Phase 1: Setup\n#### Phase 2: Refactor\n";
679        let phases = extract_dev_phases(design);
680        assert_eq!(phases, vec!["setup", "refactor"]);
681    }
682
683    #[test]
684    fn test_should_extract_chinese_dev_phases() {
685        let design = r#"
686### Phase 1: 基础设施
687### Phase 2: Init 命令
688### Phase 3: Plan 命令
689"#;
690
691        let phases = extract_dev_phases(design);
692        assert_eq!(phases.len(), 3);
693        assert_eq!(phases[0], "基础设施");
694        assert_eq!(phases[1], "init-命令");
695        assert_eq!(phases[2], "plan-命令");
696    }
697
698    #[test]
699    fn test_should_fallback_to_default_phase() {
700        let design = "# Feature without phases\nJust some text.";
701        let phases = extract_dev_phases(design);
702        assert_eq!(phases, vec!["default"]);
703    }
704
705    #[test]
706    fn test_should_slugify_phase_names_correctly() {
707        assert_eq!(slugify_phase_name("Type Definitions"), "type-definitions");
708        assert_eq!(
709            slugify_phase_name("Testing & Documentation"),
710            "testing-documentation"
711        );
712        assert_eq!(slugify_phase_name("  spaced  "), "spaced");
713    }
714
715    #[test]
716    fn test_should_strip_parenthetical_annotations() {
717        assert_eq!(
718            strip_parenthetical("Type Definitions (Day 1)"),
719            "Type Definitions"
720        );
721        assert_eq!(
722            strip_parenthetical("Transport Layer (Day 1-2)"),
723            "Transport Layer"
724        );
725        assert_eq!(strip_parenthetical("No parens"), "No parens");
726        assert_eq!(strip_parenthetical("(all parens)"), "(all parens)");
727    }
728}