Skip to main content

codetether_agent/ralph/
types.rs

1//! Ralph types - PRD and state structures
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6/// A concrete verification step that must pass for a story to be marked complete.
7///
8/// This complements `quality_checks` (which are repo/toolchain-level gates).
9/// Verification steps are story-level and can validate user-visible outcomes
10/// like E2E flows, artifacts (e.g. Cypress videos), or deployment endpoints.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(tag = "type", rename_all = "snake_case")]
13pub enum VerificationStep {
14    /// Run a shell command and optionally assert on output and/or produced files.
15    Shell {
16        /// Optional label for UI/logging.
17        #[serde(default)]
18        name: Option<String>,
19
20        /// Shell command to run.
21        command: String,
22
23        /// Optional working directory (relative to repo root / check root).
24        #[serde(default)]
25        cwd: Option<String>,
26
27        /// Substrings that must be present in stdout+stderr.
28        #[serde(default)]
29        expect_output_contains: Vec<String>,
30
31        /// File globs that must match at least one file after the command runs.
32        /// Example: "cypress/videos/**/*.mp4"
33        #[serde(default)]
34        expect_files_glob: Vec<String>,
35    },
36
37    /// Ensure a file (or glob pattern) exists.
38    FileExists {
39        #[serde(default)]
40        name: Option<String>,
41        /// Path or glob pattern.
42        path: String,
43        /// If true, treat `path` as a glob.
44        #[serde(default)]
45        glob: bool,
46    },
47
48    /// Validate an HTTP endpoint is reachable (useful for “deployment is live”).
49    Url {
50        #[serde(default)]
51        name: Option<String>,
52        url: String,
53        /// HTTP method (default: GET).
54        #[serde(default = "default_http_method")]
55        method: String,
56        /// Expected status (default: 200).
57        #[serde(default = "default_http_status")]
58        expect_status: u16,
59        /// Substrings that must appear in the response body.
60        #[serde(default)]
61        expect_body_contains: Vec<String>,
62        /// Timeout in seconds (default: 30).
63        #[serde(default = "default_http_timeout_secs")]
64        timeout_secs: u64,
65    },
66}
67
68fn default_http_method() -> String {
69    "GET".to_string()
70}
71fn default_http_status() -> u16 {
72    200
73}
74fn default_http_timeout_secs() -> u64 {
75    30
76}
77
78/// A user story in the PRD
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct UserStory {
81    /// Unique identifier (e.g., "US-001")
82    pub id: String,
83
84    /// Short title
85    pub title: String,
86
87    /// Full description
88    pub description: String,
89
90    /// Acceptance criteria
91    #[serde(default)]
92    pub acceptance_criteria: Vec<String>,
93
94    /// Explicit verification steps that must pass for this story to be marked complete.
95    ///
96    /// Example: run Cypress E2E and assert that a video artifact exists.
97    #[serde(default)]
98    pub verification_steps: Vec<VerificationStep>,
99
100    /// Whether this story passes all tests
101    #[serde(default)]
102    pub passes: bool,
103
104    /// Story priority (1=highest)
105    #[serde(default = "default_priority")]
106    pub priority: u8,
107
108    /// Dependencies on other story IDs
109    #[serde(default, alias = "dependencies")]
110    pub depends_on: Vec<String>,
111
112    /// Estimated complexity (1-5)
113    #[serde(default = "default_complexity")]
114    pub complexity: u8,
115}
116
117fn default_priority() -> u8 {
118    1
119}
120fn default_complexity() -> u8 {
121    3
122}
123
124/// The full PRD structure
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct Prd {
127    /// Project name
128    pub project: String,
129
130    /// Feature being implemented
131    pub feature: String,
132
133    /// Git branch name for this PRD
134    #[serde(default)]
135    pub branch_name: String,
136
137    /// Version of the PRD format
138    #[serde(default = "default_version")]
139    pub version: String,
140
141    /// User stories to implement
142    #[serde(default)]
143    pub user_stories: Vec<UserStory>,
144
145    /// Technical requirements
146    #[serde(default)]
147    pub technical_requirements: Vec<String>,
148
149    /// Quality checks to run
150    #[serde(default)]
151    pub quality_checks: QualityChecks,
152
153    /// Created timestamp
154    #[serde(default)]
155    pub created_at: String,
156
157    /// Last updated timestamp
158    #[serde(default)]
159    pub updated_at: String,
160}
161
162fn default_version() -> String {
163    "1.0".to_string()
164}
165
166/// Quality checks configuration
167#[derive(Debug, Clone, Default, Serialize, Deserialize)]
168pub struct QualityChecks {
169    /// Command to run type checking
170    #[serde(default)]
171    pub typecheck: Option<String>,
172
173    /// Command to run tests
174    #[serde(default)]
175    pub test: Option<String>,
176
177    /// Command to run linting
178    #[serde(default)]
179    pub lint: Option<String>,
180
181    /// Command to run build
182    #[serde(default)]
183    pub build: Option<String>,
184}
185
186impl Prd {
187    /// Load a PRD from a JSON file
188    pub async fn load(path: &PathBuf) -> anyhow::Result<Self> {
189        let content = tokio::fs::read_to_string(path).await?;
190        let prd: Prd = serde_json::from_str(&content)?;
191        Ok(prd)
192    }
193
194    /// Save the PRD to a JSON file
195    pub async fn save(&self, path: &PathBuf) -> anyhow::Result<()> {
196        let content = serde_json::to_string_pretty(self)?;
197        tokio::fs::write(path, content).await?;
198        Ok(())
199    }
200
201    /// Get the next story to work on (not passed, dependencies met)
202    pub fn next_story(&self) -> Option<&UserStory> {
203        self.user_stories
204            .iter()
205            .filter(|s| !s.passes)
206            .filter(|s| self.dependencies_met(&s.depends_on))
207            .min_by_key(|s| (s.priority, s.complexity))
208    }
209
210    /// Check if all dependencies are met (all passed)
211    fn dependencies_met(&self, deps: &[String]) -> bool {
212        deps.iter().all(|dep_id| {
213            self.user_stories
214                .iter()
215                .find(|s| s.id == *dep_id)
216                .map(|s| s.passes)
217                .unwrap_or(true)
218        })
219    }
220
221    /// Get count of passed stories
222    pub fn passed_count(&self) -> usize {
223        self.user_stories.iter().filter(|s| s.passes).count()
224    }
225
226    /// Check if all stories are complete
227    pub fn is_complete(&self) -> bool {
228        self.user_stories.iter().all(|s| s.passes)
229    }
230
231    /// Mark a story as passed
232    pub fn mark_passed(&mut self, story_id: &str) {
233        if let Some(story) = self.user_stories.iter_mut().find(|s| s.id == *story_id) {
234            story.passes = true;
235        }
236    }
237
238    /// Get all stories ready to be worked on (not passed, dependencies met)
239    pub fn ready_stories(&self) -> Vec<&UserStory> {
240        self.user_stories
241            .iter()
242            .filter(|s| !s.passes)
243            .filter(|s| self.dependencies_met(&s.depends_on))
244            .collect()
245    }
246
247    /// Group stories into parallel execution stages based on dependencies
248    /// Returns a Vec of stages, where each stage is a Vec of stories that can run in parallel
249    pub fn stages(&self) -> Vec<Vec<&UserStory>> {
250        let mut stages: Vec<Vec<&UserStory>> = Vec::new();
251        let mut completed: std::collections::HashSet<String> = self
252            .user_stories
253            .iter()
254            .filter(|s| s.passes)
255            .map(|s| s.id.clone())
256            .collect();
257
258        let mut remaining: Vec<&UserStory> =
259            self.user_stories.iter().filter(|s| !s.passes).collect();
260
261        while !remaining.is_empty() {
262            // Find all stories whose dependencies are met
263            let (ready, not_ready): (Vec<_>, Vec<_>) = remaining
264                .into_iter()
265                .partition(|s| s.depends_on.iter().all(|dep| completed.contains(dep)));
266
267            if ready.is_empty() {
268                // Circular dependency or missing deps - just take remaining
269                if !not_ready.is_empty() {
270                    stages.push(not_ready);
271                }
272                break;
273            }
274
275            // Mark these as "will be completed" for next iteration
276            for story in &ready {
277                completed.insert(story.id.clone());
278            }
279
280            stages.push(ready);
281            remaining = not_ready;
282        }
283
284        stages
285    }
286}
287
288/// Ralph execution state
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct RalphState {
291    /// The PRD being worked on
292    pub prd: Prd,
293
294    /// Current iteration number
295    pub current_iteration: usize,
296
297    /// Maximum allowed iterations
298    pub max_iterations: usize,
299
300    /// Current status
301    pub status: RalphStatus,
302
303    /// Progress log entries
304    #[serde(default)]
305    pub progress_log: Vec<ProgressEntry>,
306
307    /// Path to the PRD file
308    pub prd_path: PathBuf,
309
310    /// Working directory
311    pub working_dir: PathBuf,
312}
313
314/// Ralph execution status
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(rename_all = "snake_case")]
317pub enum RalphStatus {
318    Pending,
319    Running,
320    Completed,
321    MaxIterations,
322    Stopped,
323    QualityFailed,
324}
325
326/// A progress log entry
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct ProgressEntry {
329    /// Story ID being worked on
330    pub story_id: String,
331
332    /// Iteration number
333    pub iteration: usize,
334
335    /// Status of this attempt
336    pub status: String,
337
338    /// What was learned
339    #[serde(default)]
340    pub learnings: Vec<String>,
341
342    /// Files changed
343    #[serde(default)]
344    pub files_changed: Vec<String>,
345
346    /// Timestamp
347    pub timestamp: String,
348}
349
350/// Ralph configuration
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct RalphConfig {
353    /// Path to prd.json
354    #[serde(default = "default_prd_path")]
355    pub prd_path: String,
356
357    /// Maximum iterations
358    #[serde(default = "default_max_iterations")]
359    pub max_iterations: usize,
360
361    /// Path to progress.txt
362    #[serde(default = "default_progress_path")]
363    pub progress_path: String,
364
365    /// Whether to auto-commit changes
366    #[serde(default = "default_auto_commit")]
367    pub auto_commit: bool,
368
369    /// Whether to run quality checks
370    #[serde(default = "default_quality_checks_enabled")]
371    pub quality_checks_enabled: bool,
372
373    /// Model to use for iterations
374    #[serde(default)]
375    pub model: Option<String>,
376
377    /// Whether to use RLM for progress compression
378    #[serde(default)]
379    pub use_rlm: bool,
380
381    /// Enable parallel story execution
382    #[serde(default = "default_parallel_enabled")]
383    pub parallel_enabled: bool,
384
385    /// Maximum concurrent stories to execute
386    #[serde(default = "default_max_concurrent_stories")]
387    pub max_concurrent_stories: usize,
388
389    /// Use worktree isolation for parallel execution
390    #[serde(default = "default_worktree_enabled")]
391    pub worktree_enabled: bool,
392
393    /// Timeout in seconds per step for story sub-agents (resets on each step)
394    #[serde(default = "default_story_timeout_secs")]
395    pub story_timeout_secs: u64,
396
397    /// Maximum tool call steps per story sub-agent
398    /// Increase this for complex stories that need more iterations
399    #[serde(default = "default_max_steps_per_story")]
400    pub max_steps_per_story: usize,
401
402    /// Timeout in seconds per step for conflict resolution sub-agents
403    #[serde(default = "default_conflict_timeout_secs")]
404    pub conflict_timeout_secs: u64,
405
406    /// Enable per-story relay teams (multi-agent collaboration per story)
407    #[serde(default)]
408    pub relay_enabled: bool,
409
410    /// Maximum agents per relay team (2-8)
411    #[serde(default = "default_relay_max_agents")]
412    pub relay_max_agents: usize,
413
414    /// Maximum relay rounds per story
415    #[serde(default = "default_relay_max_rounds")]
416    pub relay_max_rounds: usize,
417}
418
419fn default_prd_path() -> String {
420    "prd.json".to_string()
421}
422fn default_max_iterations() -> usize {
423    10
424}
425fn default_progress_path() -> String {
426    "progress.txt".to_string()
427}
428fn default_auto_commit() -> bool {
429    false
430}
431fn default_quality_checks_enabled() -> bool {
432    true
433}
434fn default_parallel_enabled() -> bool {
435    true
436}
437fn default_max_concurrent_stories() -> usize {
438    100
439}
440fn default_worktree_enabled() -> bool {
441    true
442}
443fn default_story_timeout_secs() -> u64 {
444    300
445}
446fn default_max_steps_per_story() -> usize {
447    50
448}
449fn default_conflict_timeout_secs() -> u64 {
450    120
451}
452fn default_relay_max_agents() -> usize {
453    8
454}
455fn default_relay_max_rounds() -> usize {
456    3
457}
458
459impl Default for RalphConfig {
460    fn default() -> Self {
461        Self {
462            prd_path: default_prd_path(),
463            max_iterations: default_max_iterations(),
464            progress_path: default_progress_path(),
465            auto_commit: default_auto_commit(),
466            quality_checks_enabled: default_quality_checks_enabled(),
467            model: None,
468            use_rlm: true,
469            parallel_enabled: default_parallel_enabled(),
470            max_concurrent_stories: default_max_concurrent_stories(),
471            worktree_enabled: default_worktree_enabled(),
472            story_timeout_secs: default_story_timeout_secs(),
473            max_steps_per_story: default_max_steps_per_story(),
474            conflict_timeout_secs: default_conflict_timeout_secs(),
475            relay_enabled: true,
476            relay_max_agents: default_relay_max_agents(),
477            relay_max_rounds: default_relay_max_rounds(),
478        }
479    }
480}