1use 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#[derive(Debug)]
26pub struct PlanOutput {
27 pub design_spec: PathBuf,
29
30 pub verification: PathBuf,
32
33 pub state: PathBuf,
35
36 pub worktree: PathBuf,
38}
39
40const QUALITY_PHASES: &[&str] = &["review", "verify"];
42
43pub struct PlanSession {
53 client: ClaudeClient,
54 feature_slug: String,
55 project_root: PathBuf,
56 pm: PromptManager,
57 config: CodaConfig,
58 connected: bool,
59 approved_design: Option<String>,
61 approved_verification: Option<String>,
63 git: Arc<dyn GitOps>,
65 planning_cost_usd: f64,
67 planning_turns: u32,
69}
70
71impl PlanSession {
72 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 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 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 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 pub async fn approve(&mut self) -> Result<(String, String), CoreError> {
212 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 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 return Err(e);
238 }
239 };
240 info!("Verification plan generated");
241
242 self.approved_design = Some(design.clone());
244 self.approved_verification = Some(verification.clone());
245
246 Ok((design, verification))
247 }
248
249 pub fn is_approved(&self) -> bool {
252 self.approved_design.is_some() && self.approved_verification.is_some()
253 }
254
255 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 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 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 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 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 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 self.approved_design = None;
355 self.approved_verification = None;
356
357 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 pub fn feature_dir_name(&self) -> &str {
372 &self.feature_slug
373 }
374
375 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
391pub 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 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 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
440fn 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
453fn 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 let mut result = String::with_capacity(slug.len());
476 let mut prev_was_hyphen = true; 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 if result.ends_with('-') {
491 result.pop();
492 }
493
494 result
495}
496
497pub(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 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 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}