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