ralph_core/
hatless_ralph.rs

1//! Hatless Ralph - the constant coordinator.
2//!
3//! Ralph is always present, cannot be configured away, and acts as a universal fallback.
4
5use crate::config::CoreConfig;
6use crate::hat_registry::HatRegistry;
7use ralph_proto::Topic;
8use std::path::Path;
9
10/// Hatless Ralph - the constant coordinator.
11pub struct HatlessRalph {
12    completion_promise: String,
13    core: CoreConfig,
14    hat_topology: Option<HatTopology>,
15    /// Event to publish after coordination to start the hat workflow.
16    starting_event: Option<String>,
17}
18
19/// Hat topology for multi-hat mode prompt generation.
20pub struct HatTopology {
21    hats: Vec<HatInfo>,
22}
23
24/// Information about a hat for prompt generation.
25pub struct HatInfo {
26    pub name: String,
27    pub description: String,
28    pub subscribes_to: Vec<String>,
29    pub publishes: Vec<String>,
30    pub instructions: String,
31}
32
33impl HatTopology {
34    /// Creates topology from registry.
35    pub fn from_registry(registry: &HatRegistry) -> Self {
36        let hats = registry
37            .all()
38            .map(|hat| HatInfo {
39                name: hat.name.clone(),
40                description: hat.description.clone(),
41                subscribes_to: hat.subscriptions.iter().map(|t| t.as_str().to_string()).collect(),
42                publishes: hat.publishes.iter().map(|t| t.as_str().to_string()).collect(),
43                instructions: hat.instructions.clone(),
44            })
45            .collect();
46
47        Self { hats }
48    }
49}
50
51impl HatlessRalph {
52    /// Creates a new HatlessRalph.
53    ///
54    /// # Arguments
55    /// * `completion_promise` - String that signals loop completion
56    /// * `core` - Core configuration (scratchpad, specs_dir, guardrails)
57    /// * `registry` - Hat registry for topology generation
58    /// * `starting_event` - Optional event to publish after coordination to start hat workflow
59    pub fn new(
60        completion_promise: impl Into<String>,
61        core: CoreConfig,
62        registry: &HatRegistry,
63        starting_event: Option<String>,
64    ) -> Self {
65        let hat_topology = if registry.is_empty() {
66            None
67        } else {
68            Some(HatTopology::from_registry(registry))
69        };
70
71        Self {
72            completion_promise: completion_promise.into(),
73            core,
74            hat_topology,
75            starting_event,
76        }
77    }
78
79    /// Builds Ralph's prompt with filtered instructions for only active hats.
80    ///
81    /// This method reduces token usage by including instructions only for hats
82    /// that are currently triggered by pending events, while still showing the
83    /// full hat topology table for context.
84    ///
85    /// For solo mode (no hats), pass an empty slice: `&[]`
86    pub fn build_prompt(
87        &self,
88        context: &str,
89        active_hats: &[&ralph_proto::Hat],
90    ) -> String {
91        let mut prompt = self.core_prompt();
92
93        // Include pending events BEFORE workflow so Ralph sees the task first
94        if !context.trim().is_empty() {
95            prompt.push_str("## PENDING EVENTS\n\n");
96            prompt.push_str(context);
97            prompt.push_str("\n\n");
98        }
99
100        // Check if any active hat has custom instructions
101        // If so, skip the generic workflow - the hat's instructions ARE the workflow
102        let has_custom_workflow = active_hats
103            .iter()
104            .any(|h| !h.instructions.trim().is_empty());
105
106        if !has_custom_workflow {
107            prompt.push_str(&self.workflow_section());
108        }
109
110        if let Some(topology) = &self.hat_topology {
111            prompt.push_str(&self.hats_section(topology, active_hats));
112        }
113
114        prompt.push_str(&self.event_writing_section());
115        prompt.push_str(&self.done_section());
116
117        prompt
118    }
119
120    /// Always returns true - Ralph handles all events as fallback.
121    pub fn should_handle(&self, _topic: &Topic) -> bool {
122        true
123    }
124
125    /// Checks if this is a fresh start (starting_event set, no scratchpad).
126    ///
127    /// Used to enable fast path delegation that skips the PLAN step
128    /// when immediate delegation to specialized hats is appropriate.
129    fn is_fresh_start(&self) -> bool {
130        // Fast path only applies when starting_event is configured
131        if self.starting_event.is_none() {
132            return false;
133        }
134
135        // Check if scratchpad exists
136        let path = Path::new(&self.core.scratchpad);
137        !path.exists()
138    }
139
140    fn core_prompt(&self) -> String {
141        let guardrails = self
142            .core
143            .guardrails
144            .iter()
145            .enumerate()
146            .map(|(i, g)| format!("{}. {g}", 999 + i))
147            .collect::<Vec<_>>()
148            .join("\n");
149
150        format!(
151            r"I'm Ralph. Fresh context each iteration.
152
153### 0a. ORIENTATION
154Study `{specs_dir}` to understand requirements.
155Don't assume features aren't implemented—search first.
156
157### 0b. SCRATCHPAD
158Study `{scratchpad}`. It's shared state. It's memory.
159
160Task markers:
161- `[ ]` pending
162- `[x]` done
163- `[~]` cancelled (with reason)
164
165### GUARDRAILS
166{guardrails}
167
168",
169            scratchpad = self.core.scratchpad,
170            specs_dir = self.core.specs_dir,
171            guardrails = guardrails,
172        )
173    }
174
175    fn workflow_section(&self) -> String {
176        // Different workflow for solo mode vs multi-hat mode
177        if self.hat_topology.is_some() {
178            // Check for fast path: starting_event set AND no scratchpad
179            if self.is_fresh_start() {
180                // Fast path: immediate delegation without planning
181                return format!(
182                    r"## WORKFLOW
183
184**FAST PATH**: Publish `{}` immediately to start the hat workflow.
185Do not plan or analyze — delegate now.
186
187",
188                    self.starting_event.as_ref().unwrap()
189                );
190            }
191
192            // Multi-hat mode: Ralph coordinates and delegates
193            format!(
194                r"## WORKFLOW
195
196### 1. PLAN
197Update `{scratchpad}` with prioritized tasks.
198
199### 2. DELEGATE
200You have one job. Publish ONE event to hand off to specialized hats. Do
201NOT do any work.
202
203",
204                scratchpad = self.core.scratchpad
205            )
206        } else {
207            // Solo mode: Ralph does everything
208            format!(
209                r"## WORKFLOW
210
211### 1. Study the prompt. 
212Study, explore, and research what needs to be done. Use parallel subagents (up to 10) for searches.
213
214### 2. PLAN
215Update `{scratchpad}` with prioritized tasks.
216
217### 3. IMPLEMENT
218Pick ONE task. Only 1 subagent for build/tests.
219
220### 4. COMMIT
221Capture the why, not just the what. Mark `[x]` in scratchpad.
222
223### 5. REPEAT
224Until all tasks `[x]` or `[~]`.
225
226",
227                scratchpad = self.core.scratchpad
228            )
229        }
230    }
231
232    fn hats_section(&self, topology: &HatTopology, active_hats: &[&ralph_proto::Hat]) -> String {
233        let mut section = String::from("## HATS\n\nDelegate via events.\n\n");
234
235        // Include starting_event instruction if configured
236        if let Some(ref starting_event) = self.starting_event {
237            section.push_str(&format!(
238                "**After coordination, publish `{}` to start the workflow.**\n\n",
239                starting_event
240            ));
241        }
242
243        // Derive Ralph's triggers and publishes from topology
244        // Ralph triggers on: task.start + all hats' publishes (results Ralph handles)
245        // Ralph publishes: all hats' subscribes_to (events Ralph can emit to delegate)
246        let mut ralph_triggers: Vec<&str> = vec!["task.start"];
247        let mut ralph_publishes: Vec<&str> = Vec::new();
248
249        for hat in &topology.hats {
250            for pub_event in &hat.publishes {
251                if !ralph_triggers.contains(&pub_event.as_str()) {
252                    ralph_triggers.push(pub_event.as_str());
253                }
254            }
255            for sub_event in &hat.subscribes_to {
256                if !ralph_publishes.contains(&sub_event.as_str()) {
257                    ralph_publishes.push(sub_event.as_str());
258                }
259            }
260        }
261
262        // Build hat table with Description column - ALWAYS shows ALL hats for context
263        section.push_str("| Hat | Triggers On | Publishes | Description |\n");
264        section.push_str("|-----|-------------|----------|-------------|\n");
265
266        // Add Ralph coordinator row first
267        section.push_str(&format!(
268            "| Ralph | {} | {} | Coordinates workflow, delegates to specialized hats |\n",
269            ralph_triggers.join(", "),
270            ralph_publishes.join(", ")
271        ));
272
273        // Add all other hats
274        for hat in &topology.hats {
275            let subscribes = hat.subscribes_to.join(", ");
276            let publishes = hat.publishes.join(", ");
277            section.push_str(&format!(
278                "| {} | {} | {} | {} |\n",
279                hat.name, subscribes, publishes, hat.description
280            ));
281        }
282
283        section.push('\n');
284
285        // Generate Mermaid topology diagram
286        section.push_str(&self.generate_mermaid_diagram(topology, &ralph_publishes));
287        section.push('\n');
288
289        // Validate topology and log warnings for unreachable hats
290        self.validate_topology_reachability(topology);
291
292        // Add instructions sections ONLY for active hats
293        // If the slice is empty, no instructions are added (no active hats)
294        for active_hat in active_hats {
295            if !active_hat.instructions.trim().is_empty() {
296                section.push_str(&format!("### {} Instructions\n\n", active_hat.name));
297                section.push_str(&active_hat.instructions);
298                if !active_hat.instructions.ends_with('\n') {
299                    section.push('\n');
300                }
301                section.push('\n');
302            }
303        }
304
305        section
306    }
307
308    /// Generates a Mermaid flowchart showing event flow between hats.
309    fn generate_mermaid_diagram(&self, topology: &HatTopology, ralph_publishes: &[&str]) -> String {
310        let mut diagram = String::from("```mermaid\nflowchart LR\n");
311
312        // Entry point: task.start -> Ralph
313        diagram.push_str("    task.start((task.start)) --> Ralph\n");
314
315        // Ralph -> hats (via ralph_publishes which are hat triggers)
316        for hat in &topology.hats {
317            for trigger in &hat.subscribes_to {
318                if ralph_publishes.contains(&trigger.as_str()) {
319                    // Sanitize hat name for Mermaid (remove emojis and special chars for node ID)
320                    let node_id = hat.name.chars().filter(|c| c.is_alphanumeric()).collect::<String>();
321                    if node_id == hat.name {
322                        diagram.push_str(&format!("    Ralph -->|{}| {}\n", trigger, hat.name));
323                    } else {
324                        // If name has special chars, use label syntax
325                        diagram.push_str(&format!(
326                            "    Ralph -->|{}| {}[{}]\n",
327                            trigger, node_id, hat.name
328                        ));
329                    }
330                }
331            }
332        }
333
334        // Hats -> Ralph (via hat publishes)
335        for hat in &topology.hats {
336            let node_id = hat.name.chars().filter(|c| c.is_alphanumeric()).collect::<String>();
337            for pub_event in &hat.publishes {
338                diagram.push_str(&format!("    {} -->|{}| Ralph\n", node_id, pub_event));
339            }
340        }
341
342        // Hat -> Hat connections (when one hat publishes what another triggers on)
343        for source_hat in &topology.hats {
344            let source_id = source_hat.name.chars().filter(|c| c.is_alphanumeric()).collect::<String>();
345            for pub_event in &source_hat.publishes {
346                for target_hat in &topology.hats {
347                    if target_hat.name != source_hat.name && target_hat.subscribes_to.contains(pub_event) {
348                        let target_id = target_hat.name.chars().filter(|c| c.is_alphanumeric()).collect::<String>();
349                        diagram.push_str(&format!(
350                            "    {} -->|{}| {}\n",
351                            source_id, pub_event, target_id
352                        ));
353                    }
354                }
355            }
356        }
357
358        diagram.push_str("```\n");
359        diagram
360    }
361
362    /// Validates that all hats are reachable from task.start.
363    /// Logs warnings for unreachable hats but doesn't fail.
364    fn validate_topology_reachability(&self, topology: &HatTopology) {
365        use std::collections::HashSet;
366        use tracing::warn;
367
368        // Collect all events that are published (reachable)
369        let mut reachable_events: HashSet<&str> = HashSet::new();
370        reachable_events.insert("task.start");
371
372        // Ralph publishes all hat triggers, so add those
373        for hat in &topology.hats {
374            for trigger in &hat.subscribes_to {
375                reachable_events.insert(trigger.as_str());
376            }
377        }
378
379        // Now add all events published by hats (they become reachable after hat runs)
380        for hat in &topology.hats {
381            for pub_event in &hat.publishes {
382                reachable_events.insert(pub_event.as_str());
383            }
384        }
385
386        // Check each hat's triggers - warn if none of them are reachable
387        for hat in &topology.hats {
388            let hat_reachable = hat.subscribes_to.iter().any(|t| reachable_events.contains(t.as_str()));
389            if !hat_reachable {
390                warn!(
391                    hat = %hat.name,
392                    triggers = ?hat.subscribes_to,
393                    "Hat has triggers that are never published - it may be unreachable"
394                );
395            }
396        }
397    }
398
399    fn event_writing_section(&self) -> String {
400        format!(
401            r#"## EVENT WRITING
402
403Events are **routing signals**, not data transport. Keep payloads brief.
404
405**Use `ralph emit` to write events** (handles JSON escaping correctly):
406```bash
407ralph emit "build.done" "tests: pass, lint: pass"
408ralph emit "review.done" --json '{{"status": "approved", "issues": 0}}'
409```
410
411⚠️ **NEVER use echo/cat to write events** — shell escaping breaks JSON.
412
413For detailed output, write to `{scratchpad}` and emit a brief event.
414
415**CRITICAL: STOP after publishing the event.** A new iteration will start
416with fresh context to handle the work. Do NOT continue working in this
417iteration — let the next iteration handle the event with the appropriate
418hat persona. By doing the work now, you won't be wearing the correct hat 
419the specialty to do an even better job.
420"#,
421            scratchpad = self.core.scratchpad
422        )
423    }
424
425    fn done_section(&self) -> String {
426        format!(
427            r"## DONE
428
429Output {} when all tasks complete.
430",
431            self.completion_promise
432        )
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::config::RalphConfig;
440
441    #[test]
442    fn test_prompt_without_hats() {
443        let config = RalphConfig::default();
444        let registry = HatRegistry::new(); // Empty registry
445        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
446
447        let prompt = ralph.build_prompt("", &[]);
448
449        // Identity with ghuntley style
450        assert!(prompt.contains("I'm Ralph. Fresh context each iteration."));
451
452        // Numbered orientation phases
453        assert!(prompt.contains("### 0a. ORIENTATION"));
454        assert!(prompt.contains("Study"));
455        assert!(prompt.contains("Don't assume features aren't implemented"));
456
457        // Scratchpad section with task markers
458        assert!(prompt.contains("### 0b. SCRATCHPAD"));
459        assert!(prompt.contains("Task markers:"));
460        assert!(prompt.contains("- `[ ]` pending"));
461        assert!(prompt.contains("- `[x]` done"));
462        assert!(prompt.contains("- `[~]` cancelled"));
463
464        // Workflow with numbered steps (solo mode)
465        assert!(prompt.contains("## WORKFLOW"));
466        assert!(prompt.contains("### 1. Study the prompt"));
467        assert!(prompt.contains("Use parallel subagents (up to 10)"));
468        assert!(prompt.contains("### 2. PLAN"));
469        assert!(prompt.contains("### 3. IMPLEMENT"));
470        assert!(prompt.contains("Only 1 subagent for build/tests"));
471        assert!(prompt.contains("### 4. COMMIT"));
472        assert!(prompt.contains("Capture the why"));
473        assert!(prompt.contains("### 5. REPEAT"));
474
475        // Should NOT have hats section when no hats
476        assert!(!prompt.contains("## HATS"));
477
478        // Event writing and completion
479        assert!(prompt.contains("## EVENT WRITING"));
480        assert!(prompt.contains("ralph emit"));
481        assert!(prompt.contains("NEVER use echo/cat"));
482        assert!(prompt.contains("LOOP_COMPLETE"));
483    }
484
485    #[test]
486    fn test_prompt_with_hats() {
487        // Test multi-hat mode WITHOUT starting_event (no fast path)
488        let yaml = r#"
489hats:
490  planner:
491    name: "Planner"
492    triggers: ["planning.start", "build.done", "build.blocked"]
493    publishes: ["build.task"]
494  builder:
495    name: "Builder"
496    triggers: ["build.task"]
497    publishes: ["build.done", "build.blocked"]
498"#;
499        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
500        let registry = HatRegistry::from_config(&config);
501        // Note: No starting_event - tests normal multi-hat workflow (not fast path)
502        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
503
504        let prompt = ralph.build_prompt("", &[]);
505
506        // Identity with ghuntley style
507        assert!(prompt.contains("I'm Ralph. Fresh context each iteration."));
508
509        // Orientation phases
510        assert!(prompt.contains("### 0a. ORIENTATION"));
511        assert!(prompt.contains("### 0b. SCRATCHPAD"));
512
513        // Multi-hat workflow: PLAN + DELEGATE, not IMPLEMENT
514        assert!(prompt.contains("## WORKFLOW"));
515        assert!(prompt.contains("### 1. PLAN"));
516        assert!(prompt.contains("### 2. DELEGATE"), "Multi-hat mode should have DELEGATE step");
517        assert!(
518            !prompt.contains("### 3. IMPLEMENT"),
519            "Multi-hat mode should NOT tell Ralph to implement"
520        );
521        assert!(
522            prompt.contains("CRITICAL: STOP after publishing"),
523            "Should explicitly tell Ralph to stop after publishing event"
524        );
525
526        // Hats section when hats are defined
527        assert!(prompt.contains("## HATS"));
528        assert!(prompt.contains("Delegate via events"));
529        assert!(prompt.contains("| Hat | Triggers On | Publishes |"));
530
531        // Event writing and completion
532        assert!(prompt.contains("## EVENT WRITING"));
533        assert!(prompt.contains("LOOP_COMPLETE"));
534    }
535
536    #[test]
537    fn test_should_handle_always_true() {
538        let config = RalphConfig::default();
539        let registry = HatRegistry::new();
540        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
541
542        assert!(ralph.should_handle(&Topic::new("any.topic")));
543        assert!(ralph.should_handle(&Topic::new("build.task")));
544        assert!(ralph.should_handle(&Topic::new("unknown.event")));
545    }
546
547    #[test]
548    fn test_ghuntley_patterns_present() {
549        let config = RalphConfig::default();
550        let registry = HatRegistry::new();
551        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
552
553        let prompt = ralph.build_prompt("", &[]);
554
555        // Key ghuntley language patterns
556        assert!(prompt.contains("Study"), "Should use 'study' verb");
557        assert!(
558            prompt.contains("Don't assume features aren't implemented"),
559            "Should have 'don't assume' guardrail"
560        );
561        assert!(
562            prompt.contains("parallel subagents"),
563            "Should mention parallel subagents for reads"
564        );
565        assert!(
566            prompt.contains("Only 1 subagent"),
567            "Should limit to 1 subagent for builds"
568        );
569        assert!(
570            prompt.contains("Capture the why"),
571            "Should emphasize 'why' in commits"
572        );
573
574        // Numbered guardrails (999+)
575        assert!(prompt.contains("### GUARDRAILS"), "Should have guardrails section");
576        assert!(prompt.contains("999."), "Guardrails should use high numbers");
577    }
578
579    #[test]
580    fn test_scratchpad_format_documented() {
581        let config = RalphConfig::default();
582        let registry = HatRegistry::new();
583        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
584
585        let prompt = ralph.build_prompt("", &[]);
586
587        // Task marker format is documented
588        assert!(prompt.contains("- `[ ]` pending"));
589        assert!(prompt.contains("- `[x]` done"));
590        assert!(prompt.contains("- `[~]` cancelled (with reason)"));
591    }
592
593    #[test]
594    fn test_starting_event_in_prompt() {
595        // When starting_event is configured, prompt should include delegation instruction
596        let yaml = r#"
597hats:
598  tdd_writer:
599    name: "TDD Writer"
600    triggers: ["tdd.start"]
601    publishes: ["test.written"]
602"#;
603        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
604        let registry = HatRegistry::from_config(&config);
605        let ralph = HatlessRalph::new(
606            "LOOP_COMPLETE",
607            config.core.clone(),
608            &registry,
609            Some("tdd.start".to_string()),
610        );
611
612        let prompt = ralph.build_prompt("", &[]);
613
614        // Should include delegation instruction
615        assert!(
616            prompt.contains("After coordination, publish `tdd.start` to start the workflow"),
617            "Prompt should include starting_event delegation instruction"
618        );
619    }
620
621    #[test]
622    fn test_no_starting_event_instruction_when_none() {
623        // When starting_event is None, no delegation instruction should appear
624        let yaml = r#"
625hats:
626  some_hat:
627    name: "Some Hat"
628    triggers: ["some.event"]
629"#;
630        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
631        let registry = HatRegistry::from_config(&config);
632        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
633
634        let prompt = ralph.build_prompt("", &[]);
635
636        // Should NOT include delegation instruction
637        assert!(
638            !prompt.contains("After coordination, publish"),
639            "Prompt should NOT include starting_event delegation when None"
640        );
641    }
642
643    #[test]
644    fn test_hat_instructions_propagated_to_prompt() {
645        // When a hat has instructions defined in config,
646        // those instructions should appear in the generated prompt
647        let yaml = r#"
648hats:
649  tdd_writer:
650    name: "TDD Writer"
651    triggers: ["tdd.start"]
652    publishes: ["test.written"]
653    instructions: |
654      You are a Test-Driven Development specialist.
655      Always write failing tests before implementation.
656      Focus on edge cases and error handling.
657"#;
658        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
659        let registry = HatRegistry::from_config(&config);
660        let ralph = HatlessRalph::new(
661            "LOOP_COMPLETE",
662            config.core.clone(),
663            &registry,
664            Some("tdd.start".to_string()),
665        );
666
667        // Get the tdd_writer hat as active to see its instructions
668        let tdd_writer = registry.get(&ralph_proto::HatId::new("tdd_writer")).unwrap();
669        let prompt = ralph.build_prompt("", &[tdd_writer]);
670
671        // Instructions should appear in the prompt
672        assert!(
673            prompt.contains("### TDD Writer Instructions"),
674            "Prompt should include hat instructions section header"
675        );
676        assert!(
677            prompt.contains("Test-Driven Development specialist"),
678            "Prompt should include actual instructions content"
679        );
680        assert!(
681            prompt.contains("Always write failing tests"),
682            "Prompt should include full instructions"
683        );
684    }
685
686    #[test]
687    fn test_empty_instructions_not_rendered() {
688        // When a hat has empty/no instructions, no instructions section should appear
689        let yaml = r#"
690hats:
691  builder:
692    name: "Builder"
693    triggers: ["build.task"]
694    publishes: ["build.done"]
695"#;
696        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
697        let registry = HatRegistry::from_config(&config);
698        let ralph = HatlessRalph::new(
699            "LOOP_COMPLETE",
700            config.core.clone(),
701            &registry,
702            None,
703        );
704
705        let prompt = ralph.build_prompt("", &[]);
706
707        // No instructions section should appear for hats without instructions
708        assert!(
709            !prompt.contains("### Builder Instructions"),
710            "Prompt should NOT include instructions section for hat with empty instructions"
711        );
712    }
713
714    #[test]
715    fn test_multiple_hats_with_instructions() {
716        // When multiple hats have instructions, each should have its own section
717        let yaml = r#"
718hats:
719  planner:
720    name: "Planner"
721    triggers: ["planning.start"]
722    publishes: ["build.task"]
723    instructions: "Plan carefully before implementation."
724  builder:
725    name: "Builder"
726    triggers: ["build.task"]
727    publishes: ["build.done"]
728    instructions: "Focus on clean, testable code."
729"#;
730        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
731        let registry = HatRegistry::from_config(&config);
732        let ralph = HatlessRalph::new(
733            "LOOP_COMPLETE",
734            config.core.clone(),
735            &registry,
736            None,
737        );
738
739        // Get both hats as active to see their instructions
740        let planner = registry.get(&ralph_proto::HatId::new("planner")).unwrap();
741        let builder = registry.get(&ralph_proto::HatId::new("builder")).unwrap();
742        let prompt = ralph.build_prompt("", &[planner, builder]);
743
744        // Both hats' instructions should appear
745        assert!(
746            prompt.contains("### Planner Instructions"),
747            "Prompt should include Planner instructions section"
748        );
749        assert!(
750            prompt.contains("Plan carefully before implementation"),
751            "Prompt should include Planner instructions content"
752        );
753        assert!(
754            prompt.contains("### Builder Instructions"),
755            "Prompt should include Builder instructions section"
756        );
757        assert!(
758            prompt.contains("Focus on clean, testable code"),
759            "Prompt should include Builder instructions content"
760        );
761    }
762
763    #[test]
764    fn test_fast_path_with_starting_event() {
765        // When starting_event is configured AND scratchpad doesn't exist,
766        // should use fast path (skip PLAN step)
767        let yaml = r#"
768core:
769  scratchpad: "/nonexistent/path/scratchpad.md"
770hats:
771  tdd_writer:
772    name: "TDD Writer"
773    triggers: ["tdd.start"]
774    publishes: ["test.written"]
775"#;
776        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
777        let registry = HatRegistry::from_config(&config);
778        let ralph = HatlessRalph::new(
779            "LOOP_COMPLETE",
780            config.core.clone(),
781            &registry,
782            Some("tdd.start".to_string()),
783        );
784
785        let prompt = ralph.build_prompt("", &[]);
786
787        // Should use fast path - immediate delegation
788        assert!(
789            prompt.contains("FAST PATH"),
790            "Prompt should indicate fast path when starting_event set and no scratchpad"
791        );
792        assert!(
793            prompt.contains("Publish `tdd.start` immediately"),
794            "Prompt should instruct immediate event publishing"
795        );
796        assert!(
797            !prompt.contains("### 1. PLAN"),
798            "Fast path should skip PLAN step"
799        );
800    }
801
802    #[test]
803    fn test_events_context_included_in_prompt() {
804        // Given a non-empty events context
805        // When build_prompt(context) is called
806        // Then the prompt contains ## PENDING EVENTS section with the context
807        let config = RalphConfig::default();
808        let registry = HatRegistry::new();
809        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
810
811        let events_context = r"[task.start] User's task: Review this code for security vulnerabilities
812[build.done] Build completed successfully";
813
814        let prompt = ralph.build_prompt(events_context, &[]);
815
816        assert!(
817            prompt.contains("## PENDING EVENTS"),
818            "Prompt should contain PENDING EVENTS section"
819        );
820        assert!(
821            prompt.contains("Review this code for security vulnerabilities"),
822            "Prompt should contain the user's task"
823        );
824        assert!(
825            prompt.contains("Build completed successfully"),
826            "Prompt should contain all events from context"
827        );
828    }
829
830    #[test]
831    fn test_empty_context_no_pending_events_section() {
832        // Given an empty events context
833        // When build_prompt("") is called
834        // Then no PENDING EVENTS section appears
835        let config = RalphConfig::default();
836        let registry = HatRegistry::new();
837        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
838
839        let prompt = ralph.build_prompt("", &[]);
840
841        assert!(
842            !prompt.contains("## PENDING EVENTS"),
843            "Empty context should not produce PENDING EVENTS section"
844        );
845    }
846
847    #[test]
848    fn test_whitespace_only_context_no_pending_events_section() {
849        // Given a whitespace-only events context
850        // When build_prompt is called
851        // Then no PENDING EVENTS section appears
852        let config = RalphConfig::default();
853        let registry = HatRegistry::new();
854        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
855
856        let prompt = ralph.build_prompt("   \n\t  ", &[]);
857
858        assert!(
859            !prompt.contains("## PENDING EVENTS"),
860            "Whitespace-only context should not produce PENDING EVENTS section"
861        );
862    }
863
864    #[test]
865    fn test_events_section_before_workflow() {
866        // Given events context with a task
867        // When prompt is built
868        // Then ## PENDING EVENTS appears BEFORE ## WORKFLOW
869        let config = RalphConfig::default();
870        let registry = HatRegistry::new();
871        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
872
873        let events_context = "[task.start] Implement feature X";
874        let prompt = ralph.build_prompt(events_context, &[]);
875
876        let events_pos = prompt.find("## PENDING EVENTS").expect("Should have PENDING EVENTS");
877        let workflow_pos = prompt.find("## WORKFLOW").expect("Should have WORKFLOW");
878
879        assert!(
880            events_pos < workflow_pos,
881            "PENDING EVENTS ({}) should come before WORKFLOW ({})",
882            events_pos,
883            workflow_pos
884        );
885    }
886
887    // === Phase 3: Filtered Hat Instructions Tests ===
888
889    #[test]
890    fn test_only_active_hat_instructions_included() {
891        // Scenario 4 from plan.md: Only active hat instructions included in prompt
892        let yaml = r#"
893hats:
894  security_reviewer:
895    name: "Security Reviewer"
896    triggers: ["review.security"]
897    instructions: "Review code for security vulnerabilities."
898  architecture_reviewer:
899    name: "Architecture Reviewer"
900    triggers: ["review.architecture"]
901    instructions: "Review system design and architecture."
902  correctness_reviewer:
903    name: "Correctness Reviewer"
904    triggers: ["review.correctness"]
905    instructions: "Review logic and correctness."
906"#;
907        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
908        let registry = HatRegistry::from_config(&config);
909        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
910
911        // Get active hats - only security_reviewer is active
912        let security_hat = registry.get(&ralph_proto::HatId::new("security_reviewer")).unwrap();
913        let active_hats = vec![security_hat];
914
915        let prompt = ralph.build_prompt("Event: review.security - Check auth", &active_hats);
916
917        // Should contain ONLY security_reviewer instructions
918        assert!(
919            prompt.contains("### Security Reviewer Instructions"),
920            "Should include Security Reviewer instructions section"
921        );
922        assert!(
923            prompt.contains("Review code for security vulnerabilities"),
924            "Should include Security Reviewer instructions content"
925        );
926
927        // Should NOT contain other hats' instructions
928        assert!(
929            !prompt.contains("### Architecture Reviewer Instructions"),
930            "Should NOT include Architecture Reviewer instructions"
931        );
932        assert!(
933            !prompt.contains("Review system design and architecture"),
934            "Should NOT include Architecture Reviewer instructions content"
935        );
936        assert!(
937            !prompt.contains("### Correctness Reviewer Instructions"),
938            "Should NOT include Correctness Reviewer instructions"
939        );
940    }
941
942    #[test]
943    fn test_multiple_active_hats_all_included() {
944        // Scenario 6 from plan.md: Multiple active hats includes all instructions
945        let yaml = r#"
946hats:
947  security_reviewer:
948    name: "Security Reviewer"
949    triggers: ["review.security"]
950    instructions: "Review code for security vulnerabilities."
951  architecture_reviewer:
952    name: "Architecture Reviewer"
953    triggers: ["review.architecture"]
954    instructions: "Review system design and architecture."
955  correctness_reviewer:
956    name: "Correctness Reviewer"
957    triggers: ["review.correctness"]
958    instructions: "Review logic and correctness."
959"#;
960        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
961        let registry = HatRegistry::from_config(&config);
962        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
963
964        // Get active hats - both security_reviewer and architecture_reviewer are active
965        let security_hat = registry.get(&ralph_proto::HatId::new("security_reviewer")).unwrap();
966        let arch_hat = registry.get(&ralph_proto::HatId::new("architecture_reviewer")).unwrap();
967        let active_hats = vec![security_hat, arch_hat];
968
969        let prompt = ralph.build_prompt("Events", &active_hats);
970
971        // Should contain BOTH active hats' instructions
972        assert!(
973            prompt.contains("### Security Reviewer Instructions"),
974            "Should include Security Reviewer instructions"
975        );
976        assert!(
977            prompt.contains("Review code for security vulnerabilities"),
978            "Should include Security Reviewer content"
979        );
980        assert!(
981            prompt.contains("### Architecture Reviewer Instructions"),
982            "Should include Architecture Reviewer instructions"
983        );
984        assert!(
985            prompt.contains("Review system design and architecture"),
986            "Should include Architecture Reviewer content"
987        );
988
989        // Should NOT contain inactive hat's instructions
990        assert!(
991            !prompt.contains("### Correctness Reviewer Instructions"),
992            "Should NOT include Correctness Reviewer instructions"
993        );
994    }
995
996    #[test]
997    fn test_no_active_hats_no_instructions() {
998        // No active hats = no instructions section (but topology table still present)
999        let yaml = r#"
1000hats:
1001  security_reviewer:
1002    name: "Security Reviewer"
1003    triggers: ["review.security"]
1004    instructions: "Review code for security vulnerabilities."
1005"#;
1006        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1007        let registry = HatRegistry::from_config(&config);
1008        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1009
1010        // No active hats
1011        let active_hats: Vec<&ralph_proto::Hat> = vec![];
1012
1013        let prompt = ralph.build_prompt("Events", &active_hats);
1014
1015        // Should NOT contain any instructions
1016        assert!(
1017            !prompt.contains("### Security Reviewer Instructions"),
1018            "Should NOT include instructions when no active hats"
1019        );
1020        assert!(
1021            !prompt.contains("Review code for security vulnerabilities"),
1022            "Should NOT include instructions content when no active hats"
1023        );
1024
1025        // But topology table should still be present
1026        assert!(
1027            prompt.contains("## HATS"),
1028            "Should still have HATS section"
1029        );
1030        assert!(
1031            prompt.contains("| Hat | Triggers On | Publishes |"),
1032            "Should still have topology table"
1033        );
1034    }
1035
1036    #[test]
1037    fn test_topology_table_always_present() {
1038        // Scenario 7 from plan.md: Full hat topology table always shown
1039        let yaml = r#"
1040hats:
1041  security_reviewer:
1042    name: "Security Reviewer"
1043    triggers: ["review.security"]
1044    instructions: "Security instructions."
1045  architecture_reviewer:
1046    name: "Architecture Reviewer"
1047    triggers: ["review.architecture"]
1048    instructions: "Architecture instructions."
1049"#;
1050        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1051        let registry = HatRegistry::from_config(&config);
1052        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1053
1054        // Only security_reviewer is active
1055        let security_hat = registry.get(&ralph_proto::HatId::new("security_reviewer")).unwrap();
1056        let active_hats = vec![security_hat];
1057
1058        let prompt = ralph.build_prompt("Events", &active_hats);
1059
1060        // Topology table should show ALL hats (not just active ones)
1061        assert!(
1062            prompt.contains("| Security Reviewer |"),
1063            "Topology table should include Security Reviewer"
1064        );
1065        assert!(
1066            prompt.contains("| Architecture Reviewer |"),
1067            "Topology table should include Architecture Reviewer even though inactive"
1068        );
1069        assert!(
1070            prompt.contains("review.security"),
1071            "Topology table should show triggers"
1072        );
1073        assert!(
1074            prompt.contains("review.architecture"),
1075            "Topology table should show all triggers"
1076        );
1077    }
1078}