Skip to main content

codetether_agent/cli/
go_ralph.rs

1//! Bridge between `/go` OKR approval gate and Ralph PRD execution loop.
2//!
3//! Flow:
4//! 1. OKR is approved (caller responsibility)
5//! 2. LLM generates a PRD from the task + key results
6//! 3. PRD is saved to disk and audit-logged
7//! 4. Ralph loop executes the PRD with quality gates
8//! 5. Story pass/fail maps back to KR outcomes on the OkrRun
9
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::path::PathBuf;
13use std::sync::Arc;
14
15use crate::bus::AgentBus;
16use crate::okr::{KrOutcome, KrOutcomeType, Okr, OkrRun, OkrRunStatus};
17use crate::provider::{CompletionRequest, ContentPart, Message, Provider, ProviderRegistry, Role};
18use crate::ralph::store_http::HttpStore;
19use crate::ralph::{Prd, QualityChecks, RalphConfig, RalphLoop, RalphStatus};
20
21/// Result of a `/go` execution via Ralph.
22#[derive(Debug, Clone)]
23pub struct GoRalphResult {
24    pub prd_path: PathBuf,
25    pub feature_branch: String,
26    pub passed: usize,
27    pub total: usize,
28    pub all_passed: bool,
29    pub iterations: usize,
30    pub max_iterations: usize,
31    pub status: RalphStatus,
32    pub stories: Vec<StoryResult>,
33}
34
35#[derive(Debug, Clone)]
36pub struct StoryResult {
37    pub id: String,
38    pub title: String,
39    pub passed: bool,
40}
41
42/// Generate a PRD from a task description and OKR key results using an LLM.
43pub async fn generate_prd_from_task(
44    task: &str,
45    okr: &Okr,
46    provider: &dyn Provider,
47    model: &str,
48) -> Result<Prd> {
49    let kr_descriptions: Vec<String> = okr
50        .key_results
51        .iter()
52        .enumerate()
53        .map(|(i, kr)| {
54            format!(
55                "KR-{}: {} (target: {} {})",
56                i + 1,
57                kr.title,
58                kr.target_value,
59                kr.unit
60            )
61        })
62        .collect();
63
64    let prompt = format!(
65        r#"You are a PRD generator. Given a task and key results, produce a JSON PRD with concrete user stories.
66
67Task: {task}
68
69Key Results:
70{krs}
71
72Generate a PRD JSON with this exact structure (no markdown, no commentary, ONLY valid JSON):
73{{
74  "project": "<short project name>",
75  "feature": "<feature name>",
76  "branch_name": "feature/<kebab-case-name>",
77  "version": "1.0",
78  "user_stories": [
79    {{
80      "id": "US-001",
81      "title": "<concise title>",
82      "description": "<what to implement>",
83      "acceptance_criteria": ["<criterion 1>", "<criterion 2>"],
84      "passes": false,
85      "priority": 1,
86      "depends_on": [],
87      "complexity": 3
88    }}
89  ],
90  "technical_requirements": ["<requirement>"],
91  "quality_checks": {{
92        "typecheck": null,
93        "test": null,
94        "lint": null,
95        "build": null
96  }}
97}}
98
99Rules:
100- Each key result should map to at least one user story
101- Stories should be concrete, implementable, and testable
102- Use priority 1 for critical stories, 2 for important, 3 for nice-to-have
103- Set depends_on when stories have real dependencies
104- Complexity: 1=trivial, 2=simple, 3=moderate, 4=complex, 5=very complex
105- quality_checks should match the project's toolchain.
106    - If you can confidently infer the toolchain, fill in commands.
107    - If unsure, set fields to null (do NOT guess) and we will auto-detect.
108- Output ONLY the JSON object, nothing else"#,
109        krs = kr_descriptions.join("\n"),
110    );
111
112    let request = CompletionRequest {
113        messages: vec![Message {
114            role: Role::User,
115            content: vec![ContentPart::Text {
116                text: prompt.clone(),
117            }],
118        }],
119        tools: vec![],
120        model: model.to_string(),
121        temperature: Some(0.3),
122        top_p: None,
123        max_tokens: Some(4096),
124        stop: vec![],
125    };
126
127    // Attempt up to 3 times: initial request + 2 repair attempts
128    let mut last_text = String::new();
129    let mut last_error = String::new();
130
131    for attempt in 0..3 {
132        let req = if attempt == 0 {
133            request.clone()
134        } else {
135            // Repair prompt: show the LLM its broken output and ask for clean JSON
136            tracing::warn!(
137                attempt,
138                error = %last_error,
139                "PRD JSON extraction failed, retrying with repair prompt"
140            );
141            let repair = format!(
142                "Your previous response was not valid JSON. Here is the error:\n{err}\n\n\
143                 Here is what you returned:\n```\n{text}\n```\n\n\
144                 Please output ONLY the corrected JSON object — no markdown fences, \
145                 no commentary, no trailing commas, no comments. Start with {{ and end with }}.",
146                err = last_error,
147                text = if last_text.len() > 2000 {
148                    &last_text[..2000]
149                } else {
150                    &last_text
151                },
152            );
153            CompletionRequest {
154                messages: vec![
155                    Message {
156                        role: Role::User,
157                        content: vec![ContentPart::Text {
158                            text: prompt.clone(),
159                        }],
160                    },
161                    Message {
162                        role: Role::Assistant,
163                        content: vec![ContentPart::Text {
164                            text: last_text.clone(),
165                        }],
166                    },
167                    Message {
168                        role: Role::User,
169                        content: vec![ContentPart::Text { text: repair }],
170                    },
171                ],
172                tools: vec![],
173                model: model.to_string(),
174                temperature: Some(0.1),
175                top_p: None,
176                max_tokens: Some(4096),
177                stop: vec![],
178            }
179        };
180
181        let response = provider
182            .complete(req)
183            .await
184            .context("Failed to generate PRD from LLM")?;
185
186        last_text = response
187            .message
188            .content
189            .iter()
190            .filter_map(|part| match part {
191                ContentPart::Text { text } => Some(text.as_str()),
192                _ => None,
193            })
194            .collect::<Vec<_>>()
195            .join("");
196
197        // Extract and parse JSON
198        match extract_json(&last_text) {
199            Some(json_str) => match serde_json::from_str::<Prd>(&json_str) {
200                Ok(prd) => {
201                    if attempt > 0 {
202                        tracing::info!(attempt, "PRD JSON repair succeeded");
203                    }
204                    // Jump to the timestamp/quality-check block below
205                    let mut prd = prd;
206                    let now = chrono::Utc::now().to_rfc3339();
207                    prd.created_at = now.clone();
208                    prd.updated_at = now;
209
210                    // Normalize quality checks:
211                    // - If the model left them null, detect from the current directory.
212                    // - If the model guessed a toolchain that doesn't match the repo,
213                    //   override with detected checks (prevents cargo in Node/Go/Python repos).
214                    let cwd = std::env::current_dir().unwrap_or_default();
215                    let detected = detect_quality_checks();
216                    let looks_like_cargo = prd
217                        .quality_checks
218                        .typecheck
219                        .as_deref()
220                        .map(|c| c.to_ascii_lowercase().contains("cargo"))
221                        .unwrap_or(false);
222                    let looks_like_npm = prd
223                        .quality_checks
224                        .typecheck
225                        .as_deref()
226                        .map(|c| {
227                            let c = c.to_ascii_lowercase();
228                            c.contains("npm")
229                                || c.contains("pnpm")
230                                || c.contains("yarn")
231                                || c.contains("npx")
232                        })
233                        .unwrap_or(false);
234                    let looks_like_go = prd
235                        .quality_checks
236                        .typecheck
237                        .as_deref()
238                        .map(|c| c.to_ascii_lowercase().contains("go vet"))
239                        .unwrap_or(false);
240
241                    if prd.quality_checks.typecheck.is_none() {
242                        prd.quality_checks = detected;
243                    } else if looks_like_cargo && !cwd.join("Cargo.toml").exists() {
244                        prd.quality_checks = detected;
245                    } else if looks_like_npm && !cwd.join("package.json").exists() {
246                        prd.quality_checks = detected;
247                    } else if looks_like_go && !cwd.join("go.mod").exists() {
248                        prd.quality_checks = detected;
249                    }
250
251                    return Ok(prd);
252                }
253                Err(e) => {
254                    last_error = format!("JSON parses but doesn't match PRD schema: {e}");
255                }
256            },
257            None => {
258                last_error = "Response contains no valid JSON object".to_string();
259            }
260        }
261    }
262
263    anyhow::bail!("Failed to extract valid PRD JSON after 3 attempts. Last error: {last_error}");
264}
265
266/// Run Ralph loop for a `/go` task, mapping results back to OKR.
267pub async fn execute_go_ralph(
268    task: &str,
269    okr: &mut Okr,
270    okr_run: &mut OkrRun,
271    provider: Arc<dyn Provider>,
272    model: &str,
273    max_iterations: usize,
274    bus: Option<Arc<AgentBus>>,
275    max_concurrent_stories: usize,
276    registry: Option<Arc<ProviderRegistry>>,
277) -> Result<GoRalphResult> {
278    // Step 1: Generate PRD from task + KRs
279    tracing::info!(task = %task, okr_id = %okr.id, "Generating PRD from task and key results");
280    let prd = generate_prd_from_task(task, okr, provider.as_ref(), model).await?;
281
282    // Step 2: Save PRD to disk
283    let prd_filename = format!("prd_{}.json", okr_run.id.to_string().replace('-', "_"));
284    let prd_path = PathBuf::from(&prd_filename);
285    prd.save(&prd_path)
286        .await
287        .context("Failed to save generated PRD")?;
288
289    tracing::info!(
290        prd_path = %prd_path.display(),
291        stories = prd.user_stories.len(),
292        feature = %prd.feature,
293        "PRD generated and saved"
294    );
295
296    // Step 3: Audit-log the PRD generation
297    if let Some(audit) = crate::audit::try_audit_log() {
298        audit
299            .log_with_correlation(
300                crate::audit::AuditCategory::Cognition,
301                "go_ralph_prd_generated",
302                crate::audit::AuditOutcome::Success,
303                Some("codetether-agent".to_string()),
304                Some(json!({
305                    "task": task,
306                    "prd_path": prd_path.display().to_string(),
307                    "stories": prd.user_stories.len(),
308                    "feature": prd.feature,
309                    "project": prd.project,
310                })),
311                Some(okr.id.to_string()),
312                Some(okr_run.id.to_string()),
313                None,
314                okr_run.session_id.clone(),
315            )
316            .await;
317    }
318
319    // Step 4: Update OKR run status
320    if let Err(e) = okr_run.start() {
321        tracing::warn!(error = %e, "OKR run start transition failed, forcing Running status");
322        okr_run.status = OkrRunStatus::Running;
323    }
324    okr_run.relay_checkpoint_id = Some(prd_filename.clone());
325
326    // Step 5: Run Ralph loop
327    let config = RalphConfig {
328        prd_path: prd_path.to_string_lossy().to_string(),
329        max_iterations,
330        progress_path: format!("progress_{}.txt", okr_run.id.to_string().replace('-', "_")),
331        quality_checks_enabled: true,
332        auto_commit: true,
333        model: Some(model.to_string()),
334        use_rlm: false,
335        parallel_enabled: true,
336        max_concurrent_stories,
337        worktree_enabled: true,
338        story_timeout_secs: 300,
339        conflict_timeout_secs: 120,
340        relay_enabled: false,
341        relay_max_agents: 8,
342        relay_max_rounds: 3,
343        max_steps_per_story: 30,
344    };
345
346    let mut ralph = RalphLoop::new(
347        prd_path.clone(),
348        Arc::clone(&provider),
349        model.to_string(),
350        config,
351    )
352    .await
353    .context("Failed to initialize Ralph loop")?;
354
355    // Attach bus for inter-iteration learning sharing
356    if let Some(bus) = bus {
357        ralph = ralph.with_bus(bus);
358    }
359
360    // Attach registry for relay team planning
361    if let Some(registry) = registry {
362        ralph = ralph.with_registry(registry);
363    }
364
365    // Attach state store for persistent run tracking
366    ralph = ralph.with_store(Arc::new(HttpStore::from_env()));
367
368    let state = ralph.run().await.context("Ralph loop execution failed")?;
369
370    // Step 6: Map story results → KR outcomes
371    let stories: Vec<StoryResult> = state
372        .prd
373        .user_stories
374        .iter()
375        .map(|s| StoryResult {
376            id: s.id.clone(),
377            title: s.title.clone(),
378            passed: s.passes,
379        })
380        .collect();
381
382    let passed = state.prd.passed_count();
383    let total = state.prd.user_stories.len();
384
385    map_stories_to_kr_outcomes(okr, okr_run, &state.prd, &state);
386    let all_passed = okr.is_complete() || passed == total;
387
388    // Step 7: Update run status
389    if all_passed {
390        okr_run.complete();
391    } else if state.status == RalphStatus::Stopped || state.status == RalphStatus::QualityFailed {
392        okr_run.status = OkrRunStatus::Failed;
393    } else {
394        okr_run.status = OkrRunStatus::Completed;
395    }
396    okr_run.iterations = state.current_iteration as u32;
397    okr_run.relay_checkpoint_id = None; // lifecycle complete
398
399    // Step 8: Audit-log the result
400    if let Some(audit) = crate::audit::try_audit_log() {
401        let outcome = if all_passed {
402            crate::audit::AuditOutcome::Success
403        } else {
404            crate::audit::AuditOutcome::Failure
405        };
406        audit
407            .log_with_correlation(
408                crate::audit::AuditCategory::Cognition,
409                "go_ralph_completed",
410                outcome,
411                Some("codetether-agent".to_string()),
412                Some(json!({
413                    "prd_path": prd_path.display().to_string(),
414                    "passed": passed,
415                    "total": total,
416                    "status": format!("{:?}", state.status),
417                    "iterations": state.current_iteration,
418                    "feature_branch": state.prd.branch_name,
419                })),
420                Some(okr.id.to_string()),
421                Some(okr_run.id.to_string()),
422                None,
423                okr_run.session_id.clone(),
424            )
425            .await;
426    }
427
428    Ok(GoRalphResult {
429        prd_path,
430        feature_branch: state.prd.branch_name.clone(),
431        passed,
432        total,
433        all_passed,
434        iterations: state.current_iteration,
435        max_iterations: state.max_iterations,
436        status: state.status,
437        stories,
438    })
439}
440
441/// Map Ralph story pass/fail to OKR KR outcomes.
442fn map_stories_to_kr_outcomes(
443    okr: &mut Okr,
444    run: &mut OkrRun,
445    prd: &Prd,
446    state: &crate::ralph::RalphState,
447) {
448    let passed = prd.passed_count();
449    let total = prd.user_stories.len();
450    let ratio = if total > 0 {
451        passed as f64 / total as f64
452    } else {
453        0.0
454    };
455
456    // Build evidence from story results
457    let story_evidence: Vec<String> = prd
458        .user_stories
459        .iter()
460        .map(|s| {
461            format!(
462                "{}:{} ({})",
463                s.id,
464                s.title,
465                if s.passes { "PASSED" } else { "FAILED" }
466            )
467        })
468        .collect();
469
470    let outcome_type = if ratio >= 1.0 {
471        KrOutcomeType::FeatureDelivered
472    } else {
473        KrOutcomeType::Evidence
474    };
475
476    // For each KR, create an outcome with story-mapped evidence
477    for kr in &mut okr.key_results {
478        // Map KR progress based on story completion ratio
479        let kr_value = ratio * kr.target_value;
480        kr.update_progress(kr_value);
481        run.update_kr_progress(&kr.id.to_string(), kr_value);
482
483        let mut evidence = story_evidence.clone();
484        evidence.push(format!("prd:{}", prd.feature));
485        evidence.push(format!("iterations:{}", state.current_iteration));
486        evidence.push(format!("status:{:?}", state.status));
487        if !prd.branch_name.is_empty() {
488            evidence.push(format!("branch:{}", prd.branch_name));
489        }
490
491        let mut outcome = KrOutcome::new(
492            kr.id,
493            format!(
494                "Ralph PRD execution: {}/{} stories passed for '{}'",
495                passed, total, prd.feature
496            ),
497        )
498        .with_value(kr_value);
499        outcome.run_id = Some(run.id);
500        outcome.outcome_type = outcome_type;
501        outcome.evidence = evidence;
502        outcome.source = "go_ralph".to_string();
503
504        kr.add_outcome(outcome.clone());
505        run.outcomes.push(outcome);
506    }
507}
508
509/// Format a GoRalphResult for display.
510pub fn format_go_ralph_result(result: &GoRalphResult, task: &str) -> String {
511    let status_icon = if result.all_passed { "✅" } else { "❌" };
512    let status_label = format!("{:?}", result.status);
513
514    let story_lines: Vec<String> = result
515        .stories
516        .iter()
517        .map(|s| {
518            format!(
519                "  {} {}: {}",
520                if s.passed { "✓" } else { "✗" },
521                s.id,
522                s.title
523            )
524        })
525        .collect();
526
527    let next_steps = if result.all_passed {
528        format!(
529            "\nNext steps:\n  1. Review changes on branch `{}`\n  2. Merge: git checkout main && git merge {} --no-ff\n  3. Push: git push",
530            result.feature_branch, result.feature_branch
531        )
532    } else {
533        let failed: Vec<String> = result
534            .stories
535            .iter()
536            .filter(|s| !s.passed)
537            .map(|s| format!("  - {}: {}", s.id, s.title))
538            .collect();
539        format!(
540            "\nIncomplete stories:\n{}\n\nNext steps:\n  1. Review progress file for learnings\n  2. Re-run with a clean objective: codetether run -- '/go <concise-task>'\n  3. Or fix manually on branch `{}`",
541            failed.join("\n"),
542            result.feature_branch
543        )
544    };
545
546    format!(
547        "{status_icon} /go Ralph {status_label}\n\n\
548         Task: {task}\n\
549         Progress: {passed}/{total} stories | Iterations: {iters}/{max}\n\
550         Feature branch: {branch}\n\
551         PRD: {prd}\n\n\
552         Stories:\n{stories}\n{next}",
553        task = task,
554        passed = result.passed,
555        total = result.total,
556        iters = result.iterations,
557        max = result.max_iterations,
558        branch = result.feature_branch,
559        prd = result.prd_path.display(),
560        stories = story_lines.join("\n"),
561        next = next_steps,
562    )
563}
564
565/// Extract JSON object from text that may be wrapped in markdown code blocks.
566fn extract_json(text: &str) -> Option<String> {
567    // Try each extraction strategy, applying sanitization if raw parse fails
568    let candidates = gather_json_candidates(text);
569    for candidate in candidates {
570        // Try raw first
571        if serde_json::from_str::<serde_json::Value>(&candidate).is_ok() {
572            return Some(candidate);
573        }
574        // Try after sanitizing common LLM quirks
575        let sanitized = sanitize_json(&candidate);
576        if serde_json::from_str::<serde_json::Value>(&sanitized).is_ok() {
577            return Some(sanitized);
578        }
579    }
580    None
581}
582
583/// Gather candidate JSON strings from LLM output, ordered by likelihood.
584fn gather_json_candidates(text: &str) -> Vec<String> {
585    let mut candidates = Vec::new();
586    let trimmed = text.trim();
587
588    // 1. Direct: entire response is JSON
589    candidates.push(trimmed.to_string());
590
591    // 2. Inside ```json ... ``` fences
592    let mut search = text;
593    while let Some(start) = search.find("```json") {
594        let after = &search[start + 7..];
595        if let Some(end) = after.find("```") {
596            candidates.push(after[..end].trim().to_string());
597        }
598        search = &search[start + 7..];
599    }
600
601    // 3. Inside ``` ... ``` fences (any language tag)
602    search = text;
603    while let Some(start) = search.find("```") {
604        let after = &search[start + 3..];
605        let content_start = after.find('\n').unwrap_or(0);
606        let after_tag = &after[content_start..];
607        if let Some(end) = after_tag.find("```") {
608            candidates.push(after_tag[..end].trim().to_string());
609        }
610        // Advance past this fence pair
611        let skip = start + 3 + content_start + after_tag.find("```").unwrap_or(after_tag.len()) + 3;
612        if skip >= search.len() {
613            break;
614        }
615        search = &search[skip..];
616    }
617
618    // 4. First `{` to last `}` (greedy brace match)
619    if let (Some(start), Some(end)) = (text.find('{'), text.rfind('}')) {
620        if start < end {
621            candidates.push(text[start..=end].to_string());
622        }
623    }
624
625    // 5. Balanced brace extraction starting from first `{`
626    if let Some(balanced) = extract_balanced_braces(text) {
627        candidates.push(balanced);
628    }
629
630    candidates
631}
632
633/// Extract the first balanced `{...}` block from text.
634fn extract_balanced_braces(text: &str) -> Option<String> {
635    let start = text.find('{')?;
636    let mut depth = 0i32;
637    let mut in_string = false;
638    let mut escape_next = false;
639    let bytes = text.as_bytes();
640
641    for i in start..bytes.len() {
642        let ch = bytes[i] as char;
643        if escape_next {
644            escape_next = false;
645            continue;
646        }
647        if ch == '\\' && in_string {
648            escape_next = true;
649            continue;
650        }
651        if ch == '"' {
652            in_string = !in_string;
653            continue;
654        }
655        if in_string {
656            continue;
657        }
658        match ch {
659            '{' => depth += 1,
660            '}' => {
661                depth -= 1;
662                if depth == 0 {
663                    return Some(text[start..=i].to_string());
664                }
665            }
666            _ => {}
667        }
668    }
669    None
670}
671
672/// Sanitize common LLM JSON mistakes.
673fn sanitize_json(text: &str) -> String {
674    let mut s = text.to_string();
675
676    // Replace unicode curly quotes with straight quotes
677    s = s
678        .replace('\u{201c}', "\"") // left double
679        .replace('\u{201d}', "\"") // right double
680        .replace('\u{2018}', "'") // left single
681        .replace('\u{2019}', "'"); // right single
682
683    // Remove single-line // comments (outside strings)
684    s = remove_line_comments(&s);
685
686    // Remove trailing commas before } or ]
687    s = remove_trailing_commas(&s);
688
689    s
690}
691
692/// Remove `//` line comments that aren't inside JSON strings.
693fn remove_line_comments(text: &str) -> String {
694    let mut result = String::with_capacity(text.len());
695    let mut in_string = false;
696    let mut escape_next = false;
697    let chars: Vec<char> = text.chars().collect();
698    let mut i = 0;
699
700    while i < chars.len() {
701        if escape_next {
702            result.push(chars[i]);
703            escape_next = false;
704            i += 1;
705            continue;
706        }
707        if chars[i] == '\\' && in_string {
708            result.push(chars[i]);
709            escape_next = true;
710            i += 1;
711            continue;
712        }
713        if chars[i] == '"' {
714            in_string = !in_string;
715            result.push(chars[i]);
716            i += 1;
717            continue;
718        }
719        if !in_string && i + 1 < chars.len() && chars[i] == '/' && chars[i + 1] == '/' {
720            // Skip to end of line
721            while i < chars.len() && chars[i] != '\n' {
722                i += 1;
723            }
724            continue;
725        }
726        result.push(chars[i]);
727        i += 1;
728    }
729    result
730}
731
732/// Remove trailing commas before `}` or `]`.
733fn remove_trailing_commas(text: &str) -> String {
734    let mut result = String::with_capacity(text.len());
735    let mut in_string = false;
736    let mut escape_next = false;
737    let chars: Vec<char> = text.chars().collect();
738    let mut i = 0;
739
740    while i < chars.len() {
741        if escape_next {
742            result.push(chars[i]);
743            escape_next = false;
744            i += 1;
745            continue;
746        }
747        if chars[i] == '\\' && in_string {
748            result.push(chars[i]);
749            escape_next = true;
750            i += 1;
751            continue;
752        }
753        if chars[i] == '"' {
754            in_string = !in_string;
755            result.push(chars[i]);
756            i += 1;
757            continue;
758        }
759        if !in_string && chars[i] == ',' {
760            // Look ahead past whitespace for } or ]
761            let mut j = i + 1;
762            while j < chars.len() && chars[j].is_whitespace() {
763                j += 1;
764            }
765            if j < chars.len() && (chars[j] == '}' || chars[j] == ']') {
766                // Skip the trailing comma
767                i += 1;
768                continue;
769            }
770        }
771        result.push(chars[i]);
772        i += 1;
773    }
774    result
775}
776
777/// Auto-detect quality checks from the working directory.
778fn detect_quality_checks() -> QualityChecks {
779    let cwd = std::env::current_dir().unwrap_or_default();
780
781    if cwd.join("Cargo.toml").exists() {
782        QualityChecks {
783            typecheck: Some("cargo check".to_string()),
784            test: Some("cargo test".to_string()),
785            lint: Some("cargo clippy --all-features".to_string()),
786            build: Some("cargo build".to_string()),
787        }
788    } else if cwd.join("package.json").exists() {
789        QualityChecks {
790            typecheck: Some("npx tsc --noEmit".to_string()),
791            test: Some("npm test".to_string()),
792            lint: Some("npm run lint".to_string()),
793            build: Some("npm run build".to_string()),
794        }
795    } else if cwd.join("go.mod").exists() {
796        QualityChecks {
797            typecheck: Some("go vet ./...".to_string()),
798            test: Some("go test ./...".to_string()),
799            lint: Some("golangci-lint run".to_string()),
800            build: Some("go build ./...".to_string()),
801        }
802    } else if cwd.join("requirements.txt").exists() || cwd.join("pyproject.toml").exists() {
803        QualityChecks {
804            typecheck: Some("mypy .".to_string()),
805            test: Some("pytest".to_string()),
806            lint: Some("ruff check .".to_string()),
807            build: None,
808        }
809    } else {
810        QualityChecks::default()
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817    use crate::okr::KeyResult;
818    use crate::ralph::UserStory;
819    use uuid::Uuid;
820
821    #[test]
822    fn extract_json_handles_raw_json() {
823        let raw = r#"{"project": "test", "feature": "foo"}"#;
824        assert!(extract_json(raw).is_some());
825    }
826
827    #[test]
828    fn extract_json_handles_markdown_wrapped() {
829        let wrapped = "Here is the PRD:\n```json\n{\"project\": \"test\"}\n```\nDone.";
830        let result = extract_json(wrapped).unwrap();
831        assert!(result.contains("test"));
832    }
833
834    #[test]
835    fn extract_json_handles_bare_braces() {
836        let text = "The result is: {\"project\": \"test\"} and that's it.";
837        let result = extract_json(text).unwrap();
838        assert!(result.contains("test"));
839    }
840
841    #[test]
842    fn extract_json_returns_none_for_no_json() {
843        assert!(extract_json("no json here").is_none());
844    }
845
846    #[test]
847    fn extract_json_handles_trailing_commas() {
848        let text = r#"{"project": "test", "feature": "foo",}"#;
849        let result = extract_json(text).unwrap();
850        assert!(result.contains("test"));
851        // Verify it actually parses
852        serde_json::from_str::<serde_json::Value>(&result).unwrap();
853    }
854
855    #[test]
856    fn extract_json_handles_line_comments() {
857        let text = "{\n  \"project\": \"test\", // this is the project\n  \"feature\": \"foo\"\n}";
858        let result = extract_json(text).unwrap();
859        serde_json::from_str::<serde_json::Value>(&result).unwrap();
860    }
861
862    #[test]
863    fn extract_json_handles_curly_quotes() {
864        let text = "\u{201c}project\u{201d}: \u{201c}test\u{201d}";
865        let full = format!("{{{text}}}");
866        let result = extract_json(&full).unwrap();
867        serde_json::from_str::<serde_json::Value>(&result).unwrap();
868    }
869
870    #[test]
871    fn extract_json_handles_prose_wrapper() {
872        let text = "Sure! Here is the PRD:\n\n{\"project\": \"x\", \"feature\": \"y\"}\n\nLet me know if you need changes.";
873        let result = extract_json(text).unwrap();
874        assert!(result.contains("\"project\""));
875    }
876
877    #[test]
878    fn detect_quality_checks_returns_defaults_for_unknown() {
879        // This tests that default is returned when no project file is found
880        // (may detect Cargo.toml in workspace — that's fine, we just test no panic)
881        let _checks = detect_quality_checks();
882    }
883
884    #[test]
885    fn map_stories_creates_outcomes_for_each_kr() {
886        let okr_id = Uuid::new_v4();
887        let mut okr = Okr::new("Test OKR", "Test description");
888        okr.id = okr_id;
889
890        let kr1 = KeyResult::new(okr_id, "Stories complete", 100.0, "%");
891        let kr2 = KeyResult::new(okr_id, "No errors", 0.0, "count");
892        okr.add_key_result(kr1);
893        okr.add_key_result(kr2);
894
895        let mut run = OkrRun::new(okr_id, "Test Run");
896
897        let prd = Prd {
898            project: "test".to_string(),
899            feature: "test-feature".to_string(),
900            branch_name: "feature/test".to_string(),
901            version: "1.0".to_string(),
902            user_stories: vec![
903                UserStory {
904                    id: "US-001".to_string(),
905                    title: "Story one".to_string(),
906                    description: "First story".to_string(),
907                    acceptance_criteria: vec![],
908                    verification_steps: vec![],
909                    passes: true,
910                    priority: 1,
911                    depends_on: vec![],
912                    complexity: 2,
913                },
914                UserStory {
915                    id: "US-002".to_string(),
916                    title: "Story two".to_string(),
917                    description: "Second story".to_string(),
918                    acceptance_criteria: vec![],
919                    verification_steps: vec![],
920                    passes: false,
921                    priority: 2,
922                    depends_on: vec![],
923                    complexity: 3,
924                },
925            ],
926            technical_requirements: vec![],
927            quality_checks: QualityChecks::default(),
928            created_at: String::new(),
929            updated_at: String::new(),
930        };
931
932        let state = crate::ralph::RalphState {
933            prd: prd.clone(),
934            current_iteration: 3,
935            max_iterations: 10,
936            status: RalphStatus::MaxIterations,
937            progress_log: vec![],
938            prd_path: PathBuf::from("test.json"),
939            working_dir: PathBuf::from("."),
940        };
941
942        map_stories_to_kr_outcomes(&mut okr, &mut run, &prd, &state);
943
944        // Should have 2 outcomes (one per KR)
945        assert_eq!(run.outcomes.len(), 2);
946        // Each outcome should reference the correct KR
947        assert_eq!(run.outcomes[0].kr_id, okr.key_results[0].id);
948        assert_eq!(run.outcomes[1].kr_id, okr.key_results[1].id);
949        // Progress should be 50% (1/2 stories passed)
950        assert_eq!(run.outcomes[0].value, Some(50.0)); // 0.5 * 100.0
951        // Evidence should include story results
952        assert!(
953            run.outcomes[0]
954                .evidence
955                .iter()
956                .any(|e| e.contains("US-001"))
957        );
958        assert!(
959            run.outcomes[0]
960                .evidence
961                .iter()
962                .any(|e| e.contains("PASSED"))
963        );
964        assert!(
965            run.outcomes[0]
966                .evidence
967                .iter()
968                .any(|e| e.contains("FAILED"))
969        );
970    }
971
972    #[test]
973    fn format_result_shows_status() {
974        let result = GoRalphResult {
975            prd_path: PathBuf::from("prd_test.json"),
976            feature_branch: "feature/test".to_string(),
977            passed: 2,
978            total: 3,
979            all_passed: false,
980            iterations: 5,
981            max_iterations: 10,
982            status: RalphStatus::MaxIterations,
983            stories: vec![
984                StoryResult {
985                    id: "US-001".to_string(),
986                    title: "Story one".to_string(),
987                    passed: true,
988                },
989                StoryResult {
990                    id: "US-002".to_string(),
991                    title: "Story two".to_string(),
992                    passed: true,
993                },
994                StoryResult {
995                    id: "US-003".to_string(),
996                    title: "Story three".to_string(),
997                    passed: false,
998                },
999            ],
1000        };
1001
1002        let output = format_go_ralph_result(&result, "test task");
1003        assert!(output.contains("2/3 stories"));
1004        assert!(output.contains("US-003"));
1005        assert!(output.contains("Incomplete"));
1006    }
1007}