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