Skip to main content

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::collections::HashMap;
9use std::path::Path;
10
11/// Hatless Ralph - the constant coordinator.
12pub struct HatlessRalph {
13    completion_promise: String,
14    core: CoreConfig,
15    hat_topology: Option<HatTopology>,
16    /// Event to publish after coordination to start the hat workflow.
17    starting_event: Option<String>,
18    /// Whether memories mode is enabled.
19    /// When enabled, adds tasks CLI instructions alongside scratchpad.
20    memories_enabled: bool,
21    /// The user's original objective, stored at initialization.
22    /// Injected into every prompt so hats always see the goal.
23    objective: Option<String>,
24}
25
26/// Hat topology for multi-hat mode prompt generation.
27pub struct HatTopology {
28    hats: Vec<HatInfo>,
29}
30
31/// Information about a hat that receives an event.
32#[derive(Debug, Clone)]
33pub struct EventReceiver {
34    pub name: String,
35    pub description: String,
36}
37
38/// Information about a hat for prompt generation.
39pub struct HatInfo {
40    pub name: String,
41    pub description: String,
42    pub subscribes_to: Vec<String>,
43    pub publishes: Vec<String>,
44    pub instructions: String,
45    /// Maps each published event to the hats that receive it.
46    pub event_receivers: HashMap<String, Vec<EventReceiver>>,
47}
48
49impl HatInfo {
50    /// Generates an Event Publishing Guide section showing what happens when this hat publishes events.
51    ///
52    /// Returns `None` if the hat doesn't publish any events.
53    pub fn event_publishing_guide(&self) -> Option<String> {
54        if self.publishes.is_empty() {
55            return None;
56        }
57
58        let mut guide = String::from(
59            "### Event Publishing Guide\n\n\
60             You MUST publish exactly ONE event when your work is complete.\n\
61             Publishing hands off to the next hat and starts a fresh iteration with clear context.\n\n\
62             When you publish:\n",
63        );
64
65        for pub_event in &self.publishes {
66            let receivers = self.event_receivers.get(pub_event);
67            let receiver_text = match receivers {
68                Some(r) if !r.is_empty() => r
69                    .iter()
70                    .map(|recv| {
71                        if recv.description.is_empty() {
72                            recv.name.clone()
73                        } else {
74                            format!("{} ({})", recv.name, recv.description)
75                        }
76                    })
77                    .collect::<Vec<_>>()
78                    .join(", "),
79                _ => "Ralph (coordinates next steps)".to_string(),
80            };
81            guide.push_str(&format!(
82                "- `{}` → Received by: {}\n",
83                pub_event, receiver_text
84            ));
85        }
86
87        Some(guide)
88    }
89}
90
91impl HatTopology {
92    /// Creates topology from registry.
93    pub fn from_registry(registry: &HatRegistry) -> Self {
94        let hats = registry
95            .all()
96            .map(|hat| {
97                // Compute who receives each event this hat publishes
98                let event_receivers: HashMap<String, Vec<EventReceiver>> = hat
99                    .publishes
100                    .iter()
101                    .map(|pub_topic| {
102                        let receivers: Vec<EventReceiver> = registry
103                            .subscribers(pub_topic)
104                            .into_iter()
105                            .filter(|h| h.id != hat.id) // Exclude self
106                            .map(|h| EventReceiver {
107                                name: h.name.clone(),
108                                description: h.description.clone(),
109                            })
110                            .collect();
111                        (pub_topic.as_str().to_string(), receivers)
112                    })
113                    .collect();
114
115                HatInfo {
116                    name: hat.name.clone(),
117                    description: hat.description.clone(),
118                    subscribes_to: hat
119                        .subscriptions
120                        .iter()
121                        .map(|t| t.as_str().to_string())
122                        .collect(),
123                    publishes: hat
124                        .publishes
125                        .iter()
126                        .map(|t| t.as_str().to_string())
127                        .collect(),
128                    instructions: hat.instructions.clone(),
129                    event_receivers,
130                }
131            })
132            .collect();
133
134        Self { hats }
135    }
136}
137
138impl HatlessRalph {
139    /// Creates a new HatlessRalph.
140    ///
141    /// # Arguments
142    /// * `completion_promise` - String that signals loop completion
143    /// * `core` - Core configuration (scratchpad, specs_dir, guardrails)
144    /// * `registry` - Hat registry for topology generation
145    /// * `starting_event` - Optional event to publish after coordination to start hat workflow
146    pub fn new(
147        completion_promise: impl Into<String>,
148        core: CoreConfig,
149        registry: &HatRegistry,
150        starting_event: Option<String>,
151    ) -> Self {
152        let hat_topology = if registry.is_empty() {
153            None
154        } else {
155            Some(HatTopology::from_registry(registry))
156        };
157
158        Self {
159            completion_promise: completion_promise.into(),
160            core,
161            hat_topology,
162            starting_event,
163            memories_enabled: false, // Default: scratchpad-only mode
164            objective: None,
165        }
166    }
167
168    /// Sets whether memories mode is enabled.
169    ///
170    /// When enabled, adds tasks CLI instructions alongside scratchpad.
171    /// Scratchpad is always included regardless of this setting.
172    pub fn with_memories_enabled(mut self, enabled: bool) -> Self {
173        self.memories_enabled = enabled;
174        self
175    }
176
177    /// Stores the user's original objective so it persists across all iterations.
178    ///
179    /// Called once during initialization. The objective is injected into every
180    /// prompt regardless of which hat is active, ensuring intermediate hats
181    /// (test_writer, implementer, refactorer) always see the goal.
182    pub fn set_objective(&mut self, objective: String) {
183        self.objective = Some(objective);
184    }
185
186    /// Builds Ralph's prompt with filtered instructions for only active hats.
187    ///
188    /// This method reduces token usage by including instructions only for hats
189    /// that are currently triggered by pending events, while still showing the
190    /// full hat topology table for context.
191    ///
192    /// For solo mode (no hats), pass an empty slice: `&[]`
193    pub fn build_prompt(&self, context: &str, active_hats: &[&ralph_proto::Hat]) -> String {
194        let mut prompt = self.core_prompt();
195
196        // Add prominent OBJECTIVE section first (stored at initialization, persists across all iterations)
197        if let Some(ref obj) = self.objective {
198            prompt.push_str(&self.objective_section(obj));
199        }
200
201        // Include pending events BEFORE workflow so Ralph sees the task first
202        if !context.trim().is_empty() {
203            prompt.push_str("## PENDING EVENTS\n\n");
204            prompt.push_str("You MUST handle these events in this iteration:\n\n");
205            prompt.push_str(context);
206            prompt.push_str("\n\n");
207        }
208
209        // Check if any active hat has custom instructions
210        // If so, skip the generic workflow - the hat's instructions ARE the workflow
211        let has_custom_workflow = active_hats
212            .iter()
213            .any(|h| !h.instructions.trim().is_empty());
214
215        if !has_custom_workflow {
216            prompt.push_str(&self.workflow_section());
217        }
218
219        if let Some(topology) = &self.hat_topology {
220            prompt.push_str(&self.hats_section(topology, active_hats));
221        }
222
223        prompt.push_str(&self.event_writing_section());
224
225        // Only show completion instructions when Ralph is coordinating (no active hat).
226        // Hats should publish events and stop — only Ralph decides when the loop is done.
227        if active_hats.is_empty() {
228            prompt.push_str(&self.done_section(self.objective.as_deref()));
229        }
230
231        prompt
232    }
233
234    /// Generates the OBJECTIVE section - the primary goal Ralph must achieve.
235    fn objective_section(&self, objective: &str) -> String {
236        format!(
237            r"## OBJECTIVE
238
239**This is your primary goal. All work must advance this objective.**
240
241> {objective}
242
243You MUST keep this objective in mind throughout the iteration.
244You MUST NOT get distracted by workflow mechanics — they serve this goal.
245
246",
247            objective = objective
248        )
249    }
250
251    /// Always returns true - Ralph handles all events as fallback.
252    pub fn should_handle(&self, _topic: &Topic) -> bool {
253        true
254    }
255
256    /// Checks if this is a fresh start (starting_event set, no scratchpad).
257    ///
258    /// Used to enable fast path delegation that skips the PLAN step
259    /// when immediate delegation to specialized hats is appropriate.
260    fn is_fresh_start(&self) -> bool {
261        // Fast path only applies when starting_event is configured
262        if self.starting_event.is_none() {
263            return false;
264        }
265
266        // Check if scratchpad exists
267        let path = Path::new(&self.core.scratchpad);
268        !path.exists()
269    }
270
271    fn core_prompt(&self) -> String {
272        // Adapt guardrails based on whether scratchpad or memories mode is active
273        let guardrails = self
274            .core
275            .guardrails
276            .iter()
277            .enumerate()
278            .map(|(i, g)| {
279                // Replace scratchpad reference with memories reference when memories are enabled
280                let guardrail = if self.memories_enabled && g.contains("scratchpad is memory") {
281                    g.replace(
282                        "scratchpad is memory",
283                        "save learnings to memories for next time",
284                    )
285                } else {
286                    g.clone()
287                };
288                format!("{}. {guardrail}", 999 + i)
289            })
290            .collect::<Vec<_>>()
291            .join("\n");
292
293        let mut prompt = r"
294### 0a. ORIENTATION
295You are Ralph. You are running in a loop. You have fresh context each iteration.
296You MUST complete only one atomic task for the overall objective. Leave work for future iterations.
297"
298        .to_string();
299
300        // SCRATCHPAD section - ALWAYS present
301        prompt.push_str(&format!(
302            r"### 0b. SCRATCHPAD
303`{scratchpad}` is your working memory for THIS objective.
304
305**Use for:**
306- Current objective understanding
307- Notes and reasoning for current work
308- Progress tracking and next steps
309
310Task markers:
311- `[ ]` pending
312- `[x]` done
313- `[~]` cancelled (with reason)
314
315",
316            scratchpad = self.core.scratchpad,
317        ));
318
319        // TASKS section - only when memories enabled
320        if self.memories_enabled {
321            prompt.push_str(
322                "### 0c. TASKS
323Runtime work tracking via CLI (preferred over scratchpad markers):
324
325```bash
326ralph tools task add 'Title' -p 2           # Create (priority 1-5, 1=highest)
327ralph tools task add 'X' --blocked-by Y     # With dependency
328ralph tools task ready                       # Unblocked tasks only
329ralph tools task close <id>                  # Mark complete (ONLY after verification)
330```
331
332**CRITICAL:** Only close tasks after verification (tests pass, build succeeds).
333
334",
335            );
336        }
337
338        // Add task breakdown guidance
339        prompt.push_str(
340            "### TASK BREAKDOWN\n\n\
341- One task = one testable unit of work\n\
342- Tasks should be completable in 1-2 iterations\n\
343- Break large features into smaller tasks\n\
344\n",
345        );
346
347        // Add state management guidance
348        prompt.push_str(&format!(
349            "### STATE MANAGEMENT\n\n\
350**Scratchpad** (`{scratchpad}`) — Objective working memory:\n\
351- Current objective and immediate plan\n\
352- Notes and reasoning for current work\n\
353- Progress tracking (ephemeral)\n\
354\n\
355**Memories** (`.ralph/agent/memories.md`) — Persistent learning:\n\
356- Codebase patterns and conventions\n\
357- Architectural decisions and rationale\n\
358- Recurring problem solutions\n\
359\n\
360**Context Files** (`.ralph/agent/*.md`) — Research artifacts:\n\
361- Analysis and temporary notes\n\
362- Read when relevant\n\
363\n\
364**Rule:** Notes for current objective go in scratchpad. Learnings for future objectives go in memories.\n\
365\n",
366            scratchpad = self.core.scratchpad,
367        ));
368
369        // List available context files in .ralph/agent/
370        if let Ok(entries) = std::fs::read_dir(".ralph/agent") {
371            let md_files: Vec<String> = entries
372                .filter_map(|e| e.ok())
373                .filter_map(|e| {
374                    let path = e.path();
375                    if path.extension().and_then(|s| s.to_str()) == Some("md")
376                        && path.file_name().and_then(|s| s.to_str()) != Some("memories.md")
377                    {
378                        path.file_name()
379                            .and_then(|s| s.to_str())
380                            .map(|s| s.to_string())
381                    } else {
382                        None
383                    }
384                })
385                .collect();
386
387            if !md_files.is_empty() {
388                prompt.push_str("### AVAILABLE CONTEXT FILES\n\n");
389                prompt.push_str(
390                    "Context files in `.ralph/agent/` (read if relevant to current work):\n",
391                );
392                for file in md_files {
393                    prompt.push_str(&format!("- `.ralph/agent/{}`\n", file));
394                }
395                prompt.push('\n');
396            }
397        }
398
399        prompt.push_str(&format!(
400            r"### GUARDRAILS
401{guardrails}
402
403",
404            guardrails = guardrails,
405        ));
406
407        prompt
408    }
409
410    fn workflow_section(&self) -> String {
411        // Different workflow for solo mode vs multi-hat mode
412        if self.hat_topology.is_some() {
413            // Check for fast path: starting_event set AND no scratchpad
414            if self.is_fresh_start() {
415                // Fast path: immediate delegation without planning
416                return format!(
417                    r"## WORKFLOW
418
419**FAST PATH**: You MUST publish `{}` immediately to start the hat workflow.
420You MUST NOT plan or analyze — delegate now.
421
422",
423                    self.starting_event.as_ref().unwrap()
424                );
425            }
426
427            // Multi-hat mode: Ralph coordinates and delegates
428            if self.memories_enabled {
429                // Memories mode: reference both scratchpad AND tasks CLI
430                format!(
431                    r"## WORKFLOW
432
433### 1. PLAN
434You MUST update `{scratchpad}` with your understanding and plan.
435You SHOULD create tasks with `ralph tools task add` for trackable work items.
436
437### 2. DELEGATE
438You MUST publish exactly ONE event to hand off to specialized hats.
439You MUST NOT do implementation work — delegation is your only job.
440
441",
442                    scratchpad = self.core.scratchpad
443                )
444            } else {
445                // Scratchpad-only mode (legacy)
446                format!(
447                    r"## WORKFLOW
448
449### 1. PLAN
450You MUST update `{scratchpad}` with prioritized tasks to complete the objective end-to-end.
451
452### 2. DELEGATE
453You MUST publish exactly ONE event to hand off to specialized hats.
454You MUST NOT do implementation work — delegation is your only job.
455
456",
457                    scratchpad = self.core.scratchpad
458                )
459            }
460        } else {
461            // Solo mode: Ralph does everything
462            if self.memories_enabled {
463                // Memories mode: reference both scratchpad AND tasks CLI
464                format!(
465                    r"## WORKFLOW
466
467### 1. Study the prompt.
468You MUST study, explore, and research what needs to be done.
469
470### 2. PLAN
471You MUST update `{scratchpad}` with your understanding and plan.
472You SHOULD create tasks with `ralph tools task add` for trackable work items.
473
474### 3. IMPLEMENT
475You MUST pick exactly ONE task to implement.
476
477### 4. VERIFY & COMMIT
478You MUST run tests and verify the implementation works.
479You MUST commit after verification passes - one commit per task.
480You SHOULD run `git diff --cached` to review staged changes before committing.
481You MUST close the task with `ralph tools task close` AFTER commit.
482You SHOULD save learnings to memories with `ralph tools memory add`.
483You MUST update scratchpad to reflect progress.
484
485### 5. EXIT
486You MUST exit after completing ONE task.
487
488",
489                    scratchpad = self.core.scratchpad
490                )
491            } else {
492                // Scratchpad-only mode (legacy)
493                format!(
494                    r"## WORKFLOW
495
496### 1. Study the prompt.
497You MUST study, explore, and research what needs to be done.
498You MAY use parallel subagents (up to 10) for searches.
499
500### 2. PLAN
501You MUST update `{scratchpad}` with prioritized tasks to complete the objective end-to-end.
502
503### 3. IMPLEMENT
504You MUST pick exactly ONE task to implement.
505You MUST NOT use more than 1 subagent for build/tests.
506
507### 4. COMMIT
508You MUST commit after completing each atomic unit of work.
509You MUST capture the why, not just the what.
510You SHOULD run `git diff` before committing to review changes.
511You MUST mark the task `[x]` in scratchpad when complete.
512
513### 5. REPEAT
514You MUST continue until all tasks are `[x]` or `[~]`.
515
516",
517                    scratchpad = self.core.scratchpad
518                )
519            }
520        }
521    }
522
523    fn hats_section(&self, topology: &HatTopology, active_hats: &[&ralph_proto::Hat]) -> String {
524        let mut section = String::new();
525
526        // When a specific hat is active, skip the topology overview (table + Mermaid)
527        // The hat just needs its instructions and publishing guide
528        if active_hats.is_empty() {
529            // Ralph is coordinating - show full topology for delegation decisions
530            section.push_str("## HATS\n\nDelegate via events.\n\n");
531
532            // Include starting_event instruction if configured
533            if let Some(ref starting_event) = self.starting_event {
534                section.push_str(&format!(
535                    "**After coordination, publish `{}` to start the workflow.**\n\n",
536                    starting_event
537                ));
538            }
539
540            // Derive Ralph's triggers and publishes from topology
541            // Ralph triggers on: task.start + all hats' publishes (results Ralph handles)
542            // Ralph publishes: all hats' subscribes_to (events Ralph can emit to delegate)
543            let mut ralph_triggers: Vec<&str> = vec!["task.start"];
544            let mut ralph_publishes: Vec<&str> = Vec::new();
545
546            for hat in &topology.hats {
547                for pub_event in &hat.publishes {
548                    if !ralph_triggers.contains(&pub_event.as_str()) {
549                        ralph_triggers.push(pub_event.as_str());
550                    }
551                }
552                for sub_event in &hat.subscribes_to {
553                    if !ralph_publishes.contains(&sub_event.as_str()) {
554                        ralph_publishes.push(sub_event.as_str());
555                    }
556                }
557            }
558
559            // Build hat table with Description column
560            section.push_str("| Hat | Triggers On | Publishes | Description |\n");
561            section.push_str("|-----|-------------|----------|-------------|\n");
562
563            // Add Ralph coordinator row first
564            section.push_str(&format!(
565                "| Ralph | {} | {} | Coordinates workflow, delegates to specialized hats |\n",
566                ralph_triggers.join(", "),
567                ralph_publishes.join(", ")
568            ));
569
570            // Add all other hats
571            for hat in &topology.hats {
572                let subscribes = hat.subscribes_to.join(", ");
573                let publishes = hat.publishes.join(", ");
574                section.push_str(&format!(
575                    "| {} | {} | {} | {} |\n",
576                    hat.name, subscribes, publishes, hat.description
577                ));
578            }
579
580            section.push('\n');
581
582            // Generate Mermaid topology diagram
583            section.push_str(&self.generate_mermaid_diagram(topology, &ralph_publishes));
584            section.push('\n');
585
586            // Add explicit constraint listing valid events Ralph can publish
587            if !ralph_publishes.is_empty() {
588                section.push_str(&format!(
589                    "**CONSTRAINT:** You MUST only publish events from this list: `{}`\n\
590                     Publishing other events will have no effect - no hat will receive them.\n\n",
591                    ralph_publishes.join("`, `")
592                ));
593            }
594
595            // Validate topology and log warnings for unreachable hats
596            self.validate_topology_reachability(topology);
597        } else {
598            // Specific hat(s) active - minimal section with just instructions + guide
599            section.push_str("## ACTIVE HAT\n\n");
600
601            for active_hat in active_hats {
602                // Find matching HatInfo from topology to access event_receivers
603                let hat_info = topology.hats.iter().find(|h| h.name == active_hat.name);
604
605                if !active_hat.instructions.trim().is_empty() {
606                    section.push_str(&format!("### {} Instructions\n\n", active_hat.name));
607                    section.push_str(&active_hat.instructions);
608                    if !active_hat.instructions.ends_with('\n') {
609                        section.push('\n');
610                    }
611                    section.push('\n');
612                }
613
614                // Add Event Publishing Guide after instructions (if hat publishes events)
615                if let Some(guide) = hat_info.and_then(|info| info.event_publishing_guide()) {
616                    section.push_str(&guide);
617                    section.push('\n');
618                }
619            }
620        }
621
622        section
623    }
624
625    /// Generates a Mermaid flowchart showing event flow between hats.
626    fn generate_mermaid_diagram(&self, topology: &HatTopology, ralph_publishes: &[&str]) -> String {
627        let mut diagram = String::from("```mermaid\nflowchart LR\n");
628
629        // Entry point: task.start -> Ralph
630        diagram.push_str("    task.start((task.start)) --> Ralph\n");
631
632        // Ralph -> hats (via ralph_publishes which are hat triggers)
633        for hat in &topology.hats {
634            for trigger in &hat.subscribes_to {
635                if ralph_publishes.contains(&trigger.as_str()) {
636                    // Sanitize hat name for Mermaid (remove emojis and special chars for node ID)
637                    let node_id = hat
638                        .name
639                        .chars()
640                        .filter(|c| c.is_alphanumeric())
641                        .collect::<String>();
642                    if node_id == hat.name {
643                        diagram.push_str(&format!("    Ralph -->|{}| {}\n", trigger, hat.name));
644                    } else {
645                        // If name has special chars, use label syntax
646                        diagram.push_str(&format!(
647                            "    Ralph -->|{}| {}[{}]\n",
648                            trigger, node_id, hat.name
649                        ));
650                    }
651                }
652            }
653        }
654
655        // Hats -> Ralph (via hat publishes)
656        for hat in &topology.hats {
657            let node_id = hat
658                .name
659                .chars()
660                .filter(|c| c.is_alphanumeric())
661                .collect::<String>();
662            for pub_event in &hat.publishes {
663                diagram.push_str(&format!("    {} -->|{}| Ralph\n", node_id, pub_event));
664            }
665        }
666
667        // Hat -> Hat connections (when one hat publishes what another triggers on)
668        for source_hat in &topology.hats {
669            let source_id = source_hat
670                .name
671                .chars()
672                .filter(|c| c.is_alphanumeric())
673                .collect::<String>();
674            for pub_event in &source_hat.publishes {
675                for target_hat in &topology.hats {
676                    if target_hat.name != source_hat.name
677                        && target_hat.subscribes_to.contains(pub_event)
678                    {
679                        let target_id = target_hat
680                            .name
681                            .chars()
682                            .filter(|c| c.is_alphanumeric())
683                            .collect::<String>();
684                        diagram.push_str(&format!(
685                            "    {} -->|{}| {}\n",
686                            source_id, pub_event, target_id
687                        ));
688                    }
689                }
690            }
691        }
692
693        diagram.push_str("```\n");
694        diagram
695    }
696
697    /// Validates that all hats are reachable from task.start.
698    /// Logs warnings for unreachable hats but doesn't fail.
699    fn validate_topology_reachability(&self, topology: &HatTopology) {
700        use std::collections::HashSet;
701        use tracing::warn;
702
703        // Collect all events that are published (reachable)
704        let mut reachable_events: HashSet<&str> = HashSet::new();
705        reachable_events.insert("task.start");
706
707        // Ralph publishes all hat triggers, so add those
708        for hat in &topology.hats {
709            for trigger in &hat.subscribes_to {
710                reachable_events.insert(trigger.as_str());
711            }
712        }
713
714        // Now add all events published by hats (they become reachable after hat runs)
715        for hat in &topology.hats {
716            for pub_event in &hat.publishes {
717                reachable_events.insert(pub_event.as_str());
718            }
719        }
720
721        // Check each hat's triggers - warn if none of them are reachable
722        for hat in &topology.hats {
723            let hat_reachable = hat
724                .subscribes_to
725                .iter()
726                .any(|t| reachable_events.contains(t.as_str()));
727            if !hat_reachable {
728                warn!(
729                    hat = %hat.name,
730                    triggers = ?hat.subscribes_to,
731                    "Hat has triggers that are never published - it may be unreachable"
732                );
733            }
734        }
735    }
736
737    fn event_writing_section(&self) -> String {
738        // Always use scratchpad for detailed output (scratchpad is always present)
739        let detailed_output_hint = format!(
740            "You SHOULD write detailed output to `{}` and emit only a brief event.",
741            self.core.scratchpad
742        );
743
744        format!(
745            r#"## EVENT WRITING
746
747Events are routing signals, not data transport. You SHOULD keep payloads brief.
748
749You MUST use `ralph emit` to write events (handles JSON escaping correctly):
750```bash
751ralph emit "build.done" "tests: pass, lint: pass"
752ralph emit "review.done" --json '{{"status": "approved", "issues": 0}}'
753```
754
755You MUST NOT use echo/cat to write events because shell escaping breaks JSON.
756
757{detailed_output_hint}
758
759**Constraints:**
760- You MUST stop working after publishing an event because a new iteration will start with fresh context
761- You MUST NOT continue with additional work after publishing because the next iteration handles it with the appropriate hat persona
762"#,
763            detailed_output_hint = detailed_output_hint
764        )
765    }
766
767    fn done_section(&self, objective: Option<&str>) -> String {
768        let mut section = format!(
769            r"## DONE
770
771You MUST output {} when the objective is complete and all tasks are done.
772",
773            self.completion_promise
774        );
775
776        // Add task verification when memories/tasks mode is enabled
777        if self.memories_enabled {
778            section.push_str(
779                r"
780**Before declaring completion:**
7811. Run `ralph tools task ready` to check for open tasks
7822. If any tasks are open, complete them first
7833. Only output LOOP_COMPLETE when YOUR tasks are all closed
784
785Tasks from other parallel loops are filtered out automatically. You only need to verify tasks YOU created for THIS objective are complete.
786
787You MUST NOT output LOOP_COMPLETE while tasks remain open.
788",
789            );
790        }
791
792        // Reinforce the objective at the end to bookend the prompt
793        if let Some(obj) = objective {
794            section.push_str(&format!(
795                r"
796**Remember your objective:**
797> {}
798
799You MUST NOT declare completion until this objective is fully satisfied.
800",
801                obj
802            ));
803        }
804
805        section
806    }
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812    use crate::config::RalphConfig;
813
814    #[test]
815    fn test_prompt_without_hats() {
816        let config = RalphConfig::default();
817        let registry = HatRegistry::new(); // Empty registry
818        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
819
820        let prompt = ralph.build_prompt("", &[]);
821
822        // Identity with RFC2119 style
823        assert!(prompt.contains(
824            "You are Ralph. You are running in a loop. You have fresh context each iteration."
825        ));
826
827        // Numbered orientation phases (RFC2119)
828        assert!(prompt.contains("### 0a. ORIENTATION"));
829        assert!(prompt.contains("MUST complete only one atomic task"));
830
831        // Scratchpad section with task markers
832        assert!(prompt.contains("### 0b. SCRATCHPAD"));
833        assert!(prompt.contains("Task markers:"));
834        assert!(prompt.contains("- `[ ]` pending"));
835        assert!(prompt.contains("- `[x]` done"));
836        assert!(prompt.contains("- `[~]` cancelled"));
837
838        // Workflow with numbered steps (solo mode) using RFC2119
839        assert!(prompt.contains("## WORKFLOW"));
840        assert!(prompt.contains("### 1. Study the prompt"));
841        assert!(prompt.contains("You MAY use parallel subagents (up to 10)"));
842        assert!(prompt.contains("### 2. PLAN"));
843        assert!(prompt.contains("### 3. IMPLEMENT"));
844        assert!(prompt.contains("You MUST NOT use more than 1 subagent for build/tests"));
845        assert!(prompt.contains("### 4. COMMIT"));
846        assert!(prompt.contains("You MUST capture the why"));
847        assert!(prompt.contains("### 5. REPEAT"));
848
849        // Should NOT have hats section when no hats
850        assert!(!prompt.contains("## HATS"));
851
852        // Event writing and completion using RFC2119
853        assert!(prompt.contains("## EVENT WRITING"));
854        assert!(prompt.contains("You MUST use `ralph emit`"));
855        assert!(prompt.contains("You MUST NOT use echo/cat"));
856        assert!(prompt.contains("LOOP_COMPLETE"));
857    }
858
859    #[test]
860    fn test_prompt_with_hats() {
861        // Test multi-hat mode WITHOUT starting_event (no fast path)
862        let yaml = r#"
863hats:
864  planner:
865    name: "Planner"
866    triggers: ["planning.start", "build.done", "build.blocked"]
867    publishes: ["build.task"]
868  builder:
869    name: "Builder"
870    triggers: ["build.task"]
871    publishes: ["build.done", "build.blocked"]
872"#;
873        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
874        let registry = HatRegistry::from_config(&config);
875        // Note: No starting_event - tests normal multi-hat workflow (not fast path)
876        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
877
878        let prompt = ralph.build_prompt("", &[]);
879
880        // Identity with RFC2119 style
881        assert!(prompt.contains(
882            "You are Ralph. You are running in a loop. You have fresh context each iteration."
883        ));
884
885        // Orientation phases
886        assert!(prompt.contains("### 0a. ORIENTATION"));
887        assert!(prompt.contains("### 0b. SCRATCHPAD"));
888
889        // Multi-hat workflow: PLAN + DELEGATE, not IMPLEMENT (RFC2119)
890        assert!(prompt.contains("## WORKFLOW"));
891        assert!(prompt.contains("### 1. PLAN"));
892        assert!(
893            prompt.contains("### 2. DELEGATE"),
894            "Multi-hat mode should have DELEGATE step"
895        );
896        assert!(
897            !prompt.contains("### 3. IMPLEMENT"),
898            "Multi-hat mode should NOT tell Ralph to implement"
899        );
900        assert!(
901            prompt.contains("You MUST stop working after publishing"),
902            "Should explicitly tell Ralph to stop after publishing event"
903        );
904
905        // Hats section when hats are defined
906        assert!(prompt.contains("## HATS"));
907        assert!(prompt.contains("Delegate via events"));
908        assert!(prompt.contains("| Hat | Triggers On | Publishes |"));
909
910        // Event writing and completion
911        assert!(prompt.contains("## EVENT WRITING"));
912        assert!(prompt.contains("LOOP_COMPLETE"));
913    }
914
915    #[test]
916    fn test_should_handle_always_true() {
917        let config = RalphConfig::default();
918        let registry = HatRegistry::new();
919        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
920
921        assert!(ralph.should_handle(&Topic::new("any.topic")));
922        assert!(ralph.should_handle(&Topic::new("build.task")));
923        assert!(ralph.should_handle(&Topic::new("unknown.event")));
924    }
925
926    #[test]
927    fn test_rfc2119_patterns_present() {
928        let config = RalphConfig::default();
929        let registry = HatRegistry::new();
930        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
931
932        let prompt = ralph.build_prompt("", &[]);
933
934        // Key RFC2119 language patterns
935        assert!(
936            prompt.contains("You MUST study"),
937            "Should use RFC2119 MUST with 'study' verb"
938        );
939        assert!(
940            prompt.contains("You MUST complete only one atomic task"),
941            "Should have RFC2119 MUST complete atomic task constraint"
942        );
943        assert!(
944            prompt.contains("You MAY use parallel subagents"),
945            "Should mention parallel subagents with MAY"
946        );
947        assert!(
948            prompt.contains("You MUST NOT use more than 1 subagent"),
949            "Should limit to 1 subagent for builds with MUST NOT"
950        );
951        assert!(
952            prompt.contains("You MUST capture the why"),
953            "Should emphasize 'why' in commits with MUST"
954        );
955
956        // Numbered guardrails (999+)
957        assert!(
958            prompt.contains("### GUARDRAILS"),
959            "Should have guardrails section"
960        );
961        assert!(
962            prompt.contains("999."),
963            "Guardrails should use high numbers"
964        );
965    }
966
967    #[test]
968    fn test_scratchpad_format_documented() {
969        let config = RalphConfig::default();
970        let registry = HatRegistry::new();
971        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
972
973        let prompt = ralph.build_prompt("", &[]);
974
975        // Task marker format is documented
976        assert!(prompt.contains("- `[ ]` pending"));
977        assert!(prompt.contains("- `[x]` done"));
978        assert!(prompt.contains("- `[~]` cancelled (with reason)"));
979    }
980
981    #[test]
982    fn test_starting_event_in_prompt() {
983        // When starting_event is configured, prompt should include delegation instruction
984        let yaml = r#"
985hats:
986  tdd_writer:
987    name: "TDD Writer"
988    triggers: ["tdd.start"]
989    publishes: ["test.written"]
990"#;
991        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
992        let registry = HatRegistry::from_config(&config);
993        let ralph = HatlessRalph::new(
994            "LOOP_COMPLETE",
995            config.core.clone(),
996            &registry,
997            Some("tdd.start".to_string()),
998        );
999
1000        let prompt = ralph.build_prompt("", &[]);
1001
1002        // Should include delegation instruction
1003        assert!(
1004            prompt.contains("After coordination, publish `tdd.start` to start the workflow"),
1005            "Prompt should include starting_event delegation instruction"
1006        );
1007    }
1008
1009    #[test]
1010    fn test_no_starting_event_instruction_when_none() {
1011        // When starting_event is None, no delegation instruction should appear
1012        let yaml = r#"
1013hats:
1014  some_hat:
1015    name: "Some Hat"
1016    triggers: ["some.event"]
1017"#;
1018        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1019        let registry = HatRegistry::from_config(&config);
1020        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1021
1022        let prompt = ralph.build_prompt("", &[]);
1023
1024        // Should NOT include delegation instruction
1025        assert!(
1026            !prompt.contains("After coordination, publish"),
1027            "Prompt should NOT include starting_event delegation when None"
1028        );
1029    }
1030
1031    #[test]
1032    fn test_hat_instructions_propagated_to_prompt() {
1033        // When a hat has instructions defined in config,
1034        // those instructions should appear in the generated prompt
1035        let yaml = r#"
1036hats:
1037  tdd_writer:
1038    name: "TDD Writer"
1039    triggers: ["tdd.start"]
1040    publishes: ["test.written"]
1041    instructions: |
1042      You are a Test-Driven Development specialist.
1043      Always write failing tests before implementation.
1044      Focus on edge cases and error handling.
1045"#;
1046        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1047        let registry = HatRegistry::from_config(&config);
1048        let ralph = HatlessRalph::new(
1049            "LOOP_COMPLETE",
1050            config.core.clone(),
1051            &registry,
1052            Some("tdd.start".to_string()),
1053        );
1054
1055        // Get the tdd_writer hat as active to see its instructions
1056        let tdd_writer = registry
1057            .get(&ralph_proto::HatId::new("tdd_writer"))
1058            .unwrap();
1059        let prompt = ralph.build_prompt("", &[tdd_writer]);
1060
1061        // Instructions should appear in the prompt
1062        assert!(
1063            prompt.contains("### TDD Writer Instructions"),
1064            "Prompt should include hat instructions section header"
1065        );
1066        assert!(
1067            prompt.contains("Test-Driven Development specialist"),
1068            "Prompt should include actual instructions content"
1069        );
1070        assert!(
1071            prompt.contains("Always write failing tests"),
1072            "Prompt should include full instructions"
1073        );
1074    }
1075
1076    #[test]
1077    fn test_empty_instructions_not_rendered() {
1078        // When a hat has empty/no instructions, no instructions section should appear
1079        let yaml = r#"
1080hats:
1081  builder:
1082    name: "Builder"
1083    triggers: ["build.task"]
1084    publishes: ["build.done"]
1085"#;
1086        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1087        let registry = HatRegistry::from_config(&config);
1088        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1089
1090        let prompt = ralph.build_prompt("", &[]);
1091
1092        // No instructions section should appear for hats without instructions
1093        assert!(
1094            !prompt.contains("### Builder Instructions"),
1095            "Prompt should NOT include instructions section for hat with empty instructions"
1096        );
1097    }
1098
1099    #[test]
1100    fn test_multiple_hats_with_instructions() {
1101        // When multiple hats have instructions, each should have its own section
1102        let yaml = r#"
1103hats:
1104  planner:
1105    name: "Planner"
1106    triggers: ["planning.start"]
1107    publishes: ["build.task"]
1108    instructions: "Plan carefully before implementation."
1109  builder:
1110    name: "Builder"
1111    triggers: ["build.task"]
1112    publishes: ["build.done"]
1113    instructions: "Focus on clean, testable code."
1114"#;
1115        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1116        let registry = HatRegistry::from_config(&config);
1117        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1118
1119        // Get both hats as active to see their instructions
1120        let planner = registry.get(&ralph_proto::HatId::new("planner")).unwrap();
1121        let builder = registry.get(&ralph_proto::HatId::new("builder")).unwrap();
1122        let prompt = ralph.build_prompt("", &[planner, builder]);
1123
1124        // Both hats' instructions should appear
1125        assert!(
1126            prompt.contains("### Planner Instructions"),
1127            "Prompt should include Planner instructions section"
1128        );
1129        assert!(
1130            prompt.contains("Plan carefully before implementation"),
1131            "Prompt should include Planner instructions content"
1132        );
1133        assert!(
1134            prompt.contains("### Builder Instructions"),
1135            "Prompt should include Builder instructions section"
1136        );
1137        assert!(
1138            prompt.contains("Focus on clean, testable code"),
1139            "Prompt should include Builder instructions content"
1140        );
1141    }
1142
1143    #[test]
1144    fn test_fast_path_with_starting_event() {
1145        // When starting_event is configured AND scratchpad doesn't exist,
1146        // should use fast path (skip PLAN step)
1147        let yaml = r#"
1148core:
1149  scratchpad: "/nonexistent/path/scratchpad.md"
1150hats:
1151  tdd_writer:
1152    name: "TDD Writer"
1153    triggers: ["tdd.start"]
1154    publishes: ["test.written"]
1155"#;
1156        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1157        let registry = HatRegistry::from_config(&config);
1158        let ralph = HatlessRalph::new(
1159            "LOOP_COMPLETE",
1160            config.core.clone(),
1161            &registry,
1162            Some("tdd.start".to_string()),
1163        );
1164
1165        let prompt = ralph.build_prompt("", &[]);
1166
1167        // Should use fast path - immediate delegation with RFC2119
1168        assert!(
1169            prompt.contains("FAST PATH"),
1170            "Prompt should indicate fast path when starting_event set and no scratchpad"
1171        );
1172        assert!(
1173            prompt.contains("You MUST publish `tdd.start` immediately"),
1174            "Prompt should instruct immediate event publishing with MUST"
1175        );
1176        assert!(
1177            !prompt.contains("### 1. PLAN"),
1178            "Fast path should skip PLAN step"
1179        );
1180    }
1181
1182    #[test]
1183    fn test_events_context_included_in_prompt() {
1184        // Given a non-empty events context
1185        // When build_prompt(context) is called
1186        // Then the prompt contains ## PENDING EVENTS section with the context
1187        let config = RalphConfig::default();
1188        let registry = HatRegistry::new();
1189        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1190
1191        let events_context = r"[task.start] User's task: Review this code for security vulnerabilities
1192[build.done] Build completed successfully";
1193
1194        let prompt = ralph.build_prompt(events_context, &[]);
1195
1196        assert!(
1197            prompt.contains("## PENDING EVENTS"),
1198            "Prompt should contain PENDING EVENTS section"
1199        );
1200        assert!(
1201            prompt.contains("Review this code for security vulnerabilities"),
1202            "Prompt should contain the user's task"
1203        );
1204        assert!(
1205            prompt.contains("Build completed successfully"),
1206            "Prompt should contain all events from context"
1207        );
1208    }
1209
1210    #[test]
1211    fn test_empty_context_no_pending_events_section() {
1212        // Given an empty events context
1213        // When build_prompt("") is called
1214        // Then no PENDING EVENTS section appears
1215        let config = RalphConfig::default();
1216        let registry = HatRegistry::new();
1217        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1218
1219        let prompt = ralph.build_prompt("", &[]);
1220
1221        assert!(
1222            !prompt.contains("## PENDING EVENTS"),
1223            "Empty context should not produce PENDING EVENTS section"
1224        );
1225    }
1226
1227    #[test]
1228    fn test_whitespace_only_context_no_pending_events_section() {
1229        // Given a whitespace-only events context
1230        // When build_prompt is called
1231        // Then no PENDING EVENTS section appears
1232        let config = RalphConfig::default();
1233        let registry = HatRegistry::new();
1234        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1235
1236        let prompt = ralph.build_prompt("   \n\t  ", &[]);
1237
1238        assert!(
1239            !prompt.contains("## PENDING EVENTS"),
1240            "Whitespace-only context should not produce PENDING EVENTS section"
1241        );
1242    }
1243
1244    #[test]
1245    fn test_events_section_before_workflow() {
1246        // Given events context with a task
1247        // When prompt is built
1248        // Then ## PENDING EVENTS appears BEFORE ## WORKFLOW
1249        let config = RalphConfig::default();
1250        let registry = HatRegistry::new();
1251        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1252
1253        let events_context = "[task.start] Implement feature X";
1254        let prompt = ralph.build_prompt(events_context, &[]);
1255
1256        let events_pos = prompt
1257            .find("## PENDING EVENTS")
1258            .expect("Should have PENDING EVENTS");
1259        let workflow_pos = prompt.find("## WORKFLOW").expect("Should have WORKFLOW");
1260
1261        assert!(
1262            events_pos < workflow_pos,
1263            "PENDING EVENTS ({}) should come before WORKFLOW ({})",
1264            events_pos,
1265            workflow_pos
1266        );
1267    }
1268
1269    // === Phase 3: Filtered Hat Instructions Tests ===
1270
1271    #[test]
1272    fn test_only_active_hat_instructions_included() {
1273        // Scenario 4 from plan.md: Only active hat instructions included in prompt
1274        let yaml = r#"
1275hats:
1276  security_reviewer:
1277    name: "Security Reviewer"
1278    triggers: ["review.security"]
1279    instructions: "Review code for security vulnerabilities."
1280  architecture_reviewer:
1281    name: "Architecture Reviewer"
1282    triggers: ["review.architecture"]
1283    instructions: "Review system design and architecture."
1284  correctness_reviewer:
1285    name: "Correctness Reviewer"
1286    triggers: ["review.correctness"]
1287    instructions: "Review logic and correctness."
1288"#;
1289        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1290        let registry = HatRegistry::from_config(&config);
1291        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1292
1293        // Get active hats - only security_reviewer is active
1294        let security_hat = registry
1295            .get(&ralph_proto::HatId::new("security_reviewer"))
1296            .unwrap();
1297        let active_hats = vec![security_hat];
1298
1299        let prompt = ralph.build_prompt("Event: review.security - Check auth", &active_hats);
1300
1301        // Should contain ONLY security_reviewer instructions
1302        assert!(
1303            prompt.contains("### Security Reviewer Instructions"),
1304            "Should include Security Reviewer instructions section"
1305        );
1306        assert!(
1307            prompt.contains("Review code for security vulnerabilities"),
1308            "Should include Security Reviewer instructions content"
1309        );
1310
1311        // Should NOT contain other hats' instructions
1312        assert!(
1313            !prompt.contains("### Architecture Reviewer Instructions"),
1314            "Should NOT include Architecture Reviewer instructions"
1315        );
1316        assert!(
1317            !prompt.contains("Review system design and architecture"),
1318            "Should NOT include Architecture Reviewer instructions content"
1319        );
1320        assert!(
1321            !prompt.contains("### Correctness Reviewer Instructions"),
1322            "Should NOT include Correctness Reviewer instructions"
1323        );
1324    }
1325
1326    #[test]
1327    fn test_multiple_active_hats_all_included() {
1328        // Scenario 6 from plan.md: Multiple active hats includes all instructions
1329        let yaml = r#"
1330hats:
1331  security_reviewer:
1332    name: "Security Reviewer"
1333    triggers: ["review.security"]
1334    instructions: "Review code for security vulnerabilities."
1335  architecture_reviewer:
1336    name: "Architecture Reviewer"
1337    triggers: ["review.architecture"]
1338    instructions: "Review system design and architecture."
1339  correctness_reviewer:
1340    name: "Correctness Reviewer"
1341    triggers: ["review.correctness"]
1342    instructions: "Review logic and correctness."
1343"#;
1344        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1345        let registry = HatRegistry::from_config(&config);
1346        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1347
1348        // Get active hats - both security_reviewer and architecture_reviewer are active
1349        let security_hat = registry
1350            .get(&ralph_proto::HatId::new("security_reviewer"))
1351            .unwrap();
1352        let arch_hat = registry
1353            .get(&ralph_proto::HatId::new("architecture_reviewer"))
1354            .unwrap();
1355        let active_hats = vec![security_hat, arch_hat];
1356
1357        let prompt = ralph.build_prompt("Events", &active_hats);
1358
1359        // Should contain BOTH active hats' instructions
1360        assert!(
1361            prompt.contains("### Security Reviewer Instructions"),
1362            "Should include Security Reviewer instructions"
1363        );
1364        assert!(
1365            prompt.contains("Review code for security vulnerabilities"),
1366            "Should include Security Reviewer content"
1367        );
1368        assert!(
1369            prompt.contains("### Architecture Reviewer Instructions"),
1370            "Should include Architecture Reviewer instructions"
1371        );
1372        assert!(
1373            prompt.contains("Review system design and architecture"),
1374            "Should include Architecture Reviewer content"
1375        );
1376
1377        // Should NOT contain inactive hat's instructions
1378        assert!(
1379            !prompt.contains("### Correctness Reviewer Instructions"),
1380            "Should NOT include Correctness Reviewer instructions"
1381        );
1382    }
1383
1384    #[test]
1385    fn test_no_active_hats_no_instructions() {
1386        // No active hats = no instructions section (but topology table still present)
1387        let yaml = r#"
1388hats:
1389  security_reviewer:
1390    name: "Security Reviewer"
1391    triggers: ["review.security"]
1392    instructions: "Review code for security vulnerabilities."
1393"#;
1394        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1395        let registry = HatRegistry::from_config(&config);
1396        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1397
1398        // No active hats
1399        let active_hats: Vec<&ralph_proto::Hat> = vec![];
1400
1401        let prompt = ralph.build_prompt("Events", &active_hats);
1402
1403        // Should NOT contain any instructions
1404        assert!(
1405            !prompt.contains("### Security Reviewer Instructions"),
1406            "Should NOT include instructions when no active hats"
1407        );
1408        assert!(
1409            !prompt.contains("Review code for security vulnerabilities"),
1410            "Should NOT include instructions content when no active hats"
1411        );
1412
1413        // But topology table should still be present
1414        assert!(prompt.contains("## HATS"), "Should still have HATS section");
1415        assert!(
1416            prompt.contains("| Hat | Triggers On | Publishes |"),
1417            "Should still have topology table"
1418        );
1419    }
1420
1421    #[test]
1422    fn test_topology_table_only_when_ralph_coordinating() {
1423        // Topology table + Mermaid shown only when Ralph is coordinating (no active hats)
1424        // When a hat is active, skip the table to reduce token usage
1425        let yaml = r#"
1426hats:
1427  security_reviewer:
1428    name: "Security Reviewer"
1429    triggers: ["review.security"]
1430    instructions: "Security instructions."
1431  architecture_reviewer:
1432    name: "Architecture Reviewer"
1433    triggers: ["review.architecture"]
1434    instructions: "Architecture instructions."
1435"#;
1436        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1437        let registry = HatRegistry::from_config(&config);
1438        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1439
1440        // Test 1: No active hats (Ralph coordinating) - should show table + Mermaid
1441        let prompt_coordinating = ralph.build_prompt("Events", &[]);
1442
1443        assert!(
1444            prompt_coordinating.contains("## HATS"),
1445            "Should have HATS section when coordinating"
1446        );
1447        assert!(
1448            prompt_coordinating.contains("| Hat | Triggers On | Publishes |"),
1449            "Should have topology table when coordinating"
1450        );
1451        assert!(
1452            prompt_coordinating.contains("```mermaid"),
1453            "Should have Mermaid diagram when coordinating"
1454        );
1455
1456        // Test 2: Active hat - should NOT show table/Mermaid, just instructions
1457        let security_hat = registry
1458            .get(&ralph_proto::HatId::new("security_reviewer"))
1459            .unwrap();
1460        let prompt_active = ralph.build_prompt("Events", &[security_hat]);
1461
1462        assert!(
1463            prompt_active.contains("## ACTIVE HAT"),
1464            "Should have ACTIVE HAT section when hat is active"
1465        );
1466        assert!(
1467            !prompt_active.contains("| Hat | Triggers On | Publishes |"),
1468            "Should NOT have topology table when hat is active"
1469        );
1470        assert!(
1471            !prompt_active.contains("```mermaid"),
1472            "Should NOT have Mermaid diagram when hat is active"
1473        );
1474        assert!(
1475            prompt_active.contains("### Security Reviewer Instructions"),
1476            "Should still have the active hat's instructions"
1477        );
1478    }
1479
1480    // === Memories/Scratchpad Exclusivity Tests ===
1481
1482    #[test]
1483    fn test_scratchpad_always_included() {
1484        // Scratchpad section should always be included (regardless of memories mode)
1485        let config = RalphConfig::default();
1486        let registry = HatRegistry::new();
1487        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1488
1489        let prompt = ralph.build_prompt("", &[]);
1490
1491        assert!(
1492            prompt.contains("### 0b. SCRATCHPAD"),
1493            "Scratchpad section should be included"
1494        );
1495        assert!(
1496            prompt.contains("`.ralph/agent/scratchpad.md`"),
1497            "Scratchpad path should be referenced"
1498        );
1499        assert!(
1500            prompt.contains("Task markers:"),
1501            "Task markers should be documented"
1502        );
1503    }
1504
1505    #[test]
1506    fn test_scratchpad_included_with_memories_enabled() {
1507        // When memories are enabled, scratchpad should STILL be included (not excluded)
1508        let config = RalphConfig::default();
1509        let registry = HatRegistry::new();
1510        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None)
1511            .with_memories_enabled(true);
1512
1513        let prompt = ralph.build_prompt("", &[]);
1514
1515        // Scratchpad should still be present
1516        assert!(
1517            prompt.contains("### 0b. SCRATCHPAD"),
1518            "Scratchpad section should be included even with memories enabled"
1519        );
1520        assert!(
1521            prompt.contains("Task markers:"),
1522            "Task markers should still be documented"
1523        );
1524
1525        // Tasks section should also be present
1526        assert!(
1527            prompt.contains("### 0c. TASKS"),
1528            "Tasks section should be included when memories enabled"
1529        );
1530        assert!(
1531            prompt.contains("ralph tools task"),
1532            "Tasks CLI commands should be documented"
1533        );
1534    }
1535
1536    #[test]
1537    fn test_no_tasks_section_without_memories() {
1538        // When memories are NOT enabled, no tasks CLI section should appear
1539        let config = RalphConfig::default();
1540        let registry = HatRegistry::new();
1541        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1542        // memories_enabled defaults to false
1543
1544        let prompt = ralph.build_prompt("", &[]);
1545
1546        // Should NOT have the tasks CLI section
1547        assert!(
1548            !prompt.contains("### 0c. TASKS"),
1549            "Tasks section should NOT be included when memories disabled"
1550        );
1551    }
1552
1553    #[test]
1554    fn test_workflow_references_both_scratchpad_and_tasks_with_memories() {
1555        // When memories enabled, workflow should reference BOTH scratchpad AND tasks CLI
1556        let config = RalphConfig::default();
1557        let registry = HatRegistry::new();
1558        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None)
1559            .with_memories_enabled(true);
1560
1561        let prompt = ralph.build_prompt("", &[]);
1562
1563        // Workflow should mention scratchpad
1564        assert!(
1565            prompt.contains("update scratchpad"),
1566            "Workflow should reference scratchpad when memories enabled"
1567        );
1568        // Workflow should also mention tasks CLI
1569        assert!(
1570            prompt.contains("ralph tools task"),
1571            "Workflow should reference tasks CLI when memories enabled"
1572        );
1573    }
1574
1575    #[test]
1576    fn test_multi_hat_mode_workflow_with_memories_enabled() {
1577        // Multi-hat mode should reference scratchpad AND tasks CLI when memories enabled
1578        let yaml = r#"
1579hats:
1580  builder:
1581    name: "Builder"
1582    triggers: ["build.task"]
1583    publishes: ["build.done"]
1584"#;
1585        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1586        let registry = HatRegistry::from_config(&config);
1587        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None)
1588            .with_memories_enabled(true);
1589
1590        let prompt = ralph.build_prompt("", &[]);
1591
1592        // Multi-hat workflow should mention scratchpad
1593        assert!(
1594            prompt.contains("scratchpad"),
1595            "Multi-hat workflow should reference scratchpad when memories enabled"
1596        );
1597        // And tasks CLI
1598        assert!(
1599            prompt.contains("ralph tools task add"),
1600            "Multi-hat workflow should reference tasks CLI when memories enabled"
1601        );
1602    }
1603
1604    #[test]
1605    fn test_guardrails_adapt_to_memories_mode() {
1606        // When memories enabled, guardrails should encourage saving to memories
1607        let config = RalphConfig::default();
1608        let registry = HatRegistry::new();
1609        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None)
1610            .with_memories_enabled(true);
1611
1612        let prompt = ralph.build_prompt("", &[]);
1613
1614        // With memories enabled + include_scratchpad still true (default),
1615        // the guardrail transformation doesn't apply
1616        // Just verify the prompt generates correctly
1617        assert!(
1618            prompt.contains("### GUARDRAILS"),
1619            "Guardrails section should be present"
1620        );
1621    }
1622
1623    #[test]
1624    fn test_guardrails_present_without_memories() {
1625        // Without memories, guardrails should still be present
1626        let config = RalphConfig::default();
1627        let registry = HatRegistry::new();
1628        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1629        // memories_enabled defaults to false
1630
1631        let prompt = ralph.build_prompt("", &[]);
1632
1633        assert!(
1634            prompt.contains("### GUARDRAILS"),
1635            "Guardrails section should be present"
1636        );
1637    }
1638
1639    // === Task Completion Verification Tests ===
1640
1641    #[test]
1642    fn test_task_closure_verification_in_tasks_section() {
1643        // When memories/tasks mode is enabled, the TASKS section should include
1644        // verification requirements before closing tasks
1645        let config = RalphConfig::default();
1646        let registry = HatRegistry::new();
1647        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None)
1648            .with_memories_enabled(true);
1649
1650        let prompt = ralph.build_prompt("", &[]);
1651
1652        // Should contain task section with verification info
1653        assert!(
1654            prompt.contains("### 0c. TASKS"),
1655            "Should include TASKS section when memories enabled"
1656        );
1657        assert!(
1658            prompt.contains("CRITICAL"),
1659            "Should include CRITICAL verification note"
1660        );
1661        assert!(
1662            prompt.contains("tests pass"),
1663            "Should mention tests passing"
1664        );
1665    }
1666
1667    #[test]
1668    fn test_workflow_verify_and_commit_step() {
1669        // Solo mode with memories should have VERIFY & COMMIT step
1670        let config = RalphConfig::default();
1671        let registry = HatRegistry::new();
1672        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None)
1673            .with_memories_enabled(true);
1674
1675        let prompt = ralph.build_prompt("", &[]);
1676
1677        // Should have VERIFY & COMMIT step
1678        assert!(
1679            prompt.contains("### 4. VERIFY & COMMIT"),
1680            "Should have VERIFY & COMMIT step in workflow"
1681        );
1682        assert!(
1683            prompt.contains("run tests and verify"),
1684            "Should require verification"
1685        );
1686        assert!(
1687            prompt.contains("ralph tools task close"),
1688            "Should reference task close command"
1689        );
1690    }
1691
1692    #[test]
1693    fn test_scratchpad_mode_still_has_commit_step() {
1694        // Scratchpad-only mode (no memories) should have COMMIT step
1695        let config = RalphConfig::default();
1696        let registry = HatRegistry::new();
1697        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1698        // memories_enabled defaults to false
1699
1700        let prompt = ralph.build_prompt("", &[]);
1701
1702        // Scratchpad mode uses different format - COMMIT step without task CLI
1703        assert!(
1704            prompt.contains("### 4. COMMIT"),
1705            "Should have COMMIT step in workflow"
1706        );
1707        assert!(
1708            prompt.contains("mark the task `[x]`"),
1709            "Should mark task in scratchpad"
1710        );
1711        // Scratchpad mode doesn't have the TASKS section
1712        assert!(
1713            !prompt.contains("### 0c. TASKS"),
1714            "Scratchpad mode should not have TASKS section"
1715        );
1716    }
1717
1718    // === Objective Section Tests ===
1719
1720    #[test]
1721    fn test_objective_section_present_with_set_objective() {
1722        // When objective is set via set_objective(), OBJECTIVE section should appear
1723        let config = RalphConfig::default();
1724        let registry = HatRegistry::new();
1725        let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1726        ralph.set_objective("Implement user authentication with JWT tokens".to_string());
1727
1728        let prompt = ralph.build_prompt("", &[]);
1729
1730        assert!(
1731            prompt.contains("## OBJECTIVE"),
1732            "Should have OBJECTIVE section when objective is set"
1733        );
1734        assert!(
1735            prompt.contains("Implement user authentication with JWT tokens"),
1736            "OBJECTIVE should contain the original user prompt"
1737        );
1738        assert!(
1739            prompt.contains("This is your primary goal"),
1740            "OBJECTIVE should emphasize this is the primary goal"
1741        );
1742    }
1743
1744    #[test]
1745    fn test_objective_reinforced_in_done_section() {
1746        // The objective should be restated in the DONE section (bookend pattern)
1747        // when Ralph is coordinating (no active hats)
1748        let config = RalphConfig::default();
1749        let registry = HatRegistry::new();
1750        let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1751        ralph.set_objective("Fix the login bug in auth module".to_string());
1752
1753        let prompt = ralph.build_prompt("", &[]);
1754
1755        // Check DONE section contains objective reinforcement
1756        let done_pos = prompt.find("## DONE").expect("Should have DONE section");
1757        let after_done = &prompt[done_pos..];
1758
1759        assert!(
1760            after_done.contains("Remember your objective"),
1761            "DONE section should remind about objective"
1762        );
1763        assert!(
1764            after_done.contains("Fix the login bug in auth module"),
1765            "DONE section should restate the objective"
1766        );
1767    }
1768
1769    #[test]
1770    fn test_objective_appears_before_pending_events() {
1771        // OBJECTIVE should appear BEFORE PENDING EVENTS for prominence
1772        let config = RalphConfig::default();
1773        let registry = HatRegistry::new();
1774        let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1775        ralph.set_objective("Build feature X".to_string());
1776
1777        let context = "Event: task.start - Build feature X";
1778        let prompt = ralph.build_prompt(context, &[]);
1779
1780        let objective_pos = prompt.find("## OBJECTIVE").expect("Should have OBJECTIVE");
1781        let events_pos = prompt
1782            .find("## PENDING EVENTS")
1783            .expect("Should have PENDING EVENTS");
1784
1785        assert!(
1786            objective_pos < events_pos,
1787            "OBJECTIVE ({}) should appear before PENDING EVENTS ({})",
1788            objective_pos,
1789            events_pos
1790        );
1791    }
1792
1793    #[test]
1794    fn test_no_objective_when_not_set() {
1795        // When no objective has been set, no OBJECTIVE section should appear
1796        let config = RalphConfig::default();
1797        let registry = HatRegistry::new();
1798        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1799
1800        let context = "Event: build.done - Build completed successfully";
1801        let prompt = ralph.build_prompt(context, &[]);
1802
1803        assert!(
1804            !prompt.contains("## OBJECTIVE"),
1805            "Should NOT have OBJECTIVE section when objective not set"
1806        );
1807    }
1808
1809    #[test]
1810    fn test_objective_set_correctly() {
1811        // Test that set_objective stores the objective and it appears in prompt
1812        let config = RalphConfig::default();
1813        let registry = HatRegistry::new();
1814        let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1815        ralph.set_objective("Review this PR for security issues".to_string());
1816
1817        let prompt = ralph.build_prompt("", &[]);
1818
1819        assert!(
1820            prompt.contains("Review this PR for security issues"),
1821            "Should show the stored objective"
1822        );
1823    }
1824
1825    #[test]
1826    fn test_objective_with_events_context() {
1827        // Objective should appear even when context has other events (not task.start)
1828        let config = RalphConfig::default();
1829        let registry = HatRegistry::new();
1830        let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1831        ralph.set_objective("Implement feature Y".to_string());
1832
1833        let context =
1834            "Event: build.done - Previous build succeeded\nEvent: test.passed - All tests green";
1835        let prompt = ralph.build_prompt(context, &[]);
1836
1837        assert!(
1838            prompt.contains("## OBJECTIVE"),
1839            "Should have OBJECTIVE section"
1840        );
1841        assert!(
1842            prompt.contains("Implement feature Y"),
1843            "OBJECTIVE should contain the stored objective"
1844        );
1845    }
1846
1847    #[test]
1848    fn test_done_section_without_objective() {
1849        // When no objective, DONE section should still work but without reinforcement
1850        let config = RalphConfig::default();
1851        let registry = HatRegistry::new();
1852        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1853
1854        let prompt = ralph.build_prompt("", &[]);
1855
1856        assert!(prompt.contains("## DONE"), "Should have DONE section");
1857        assert!(
1858            prompt.contains("LOOP_COMPLETE"),
1859            "DONE should mention completion promise"
1860        );
1861        assert!(
1862            !prompt.contains("Remember your objective"),
1863            "Should NOT have objective reinforcement without objective"
1864        );
1865    }
1866
1867    #[test]
1868    fn test_objective_persists_across_iterations() {
1869        // Objective is present in prompt even when context has no task.start event
1870        // (simulating iteration 2+ where the start event has been consumed)
1871        let config = RalphConfig::default();
1872        let registry = HatRegistry::new();
1873        let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1874        ralph.set_objective("Build a REST API with authentication".to_string());
1875
1876        // Simulate iteration 2: only non-start events present
1877        let context = "Event: build.done - Build completed";
1878        let prompt = ralph.build_prompt(context, &[]);
1879
1880        assert!(
1881            prompt.contains("## OBJECTIVE"),
1882            "OBJECTIVE should persist even without task.start in context"
1883        );
1884        assert!(
1885            prompt.contains("Build a REST API with authentication"),
1886            "Stored objective should appear in later iterations"
1887        );
1888    }
1889
1890    #[test]
1891    fn test_done_section_suppressed_when_hat_active() {
1892        // When active_hats is non-empty, prompt does NOT contain ## DONE
1893        let yaml = r#"
1894hats:
1895  builder:
1896    name: "Builder"
1897    triggers: ["build.task"]
1898    publishes: ["build.done"]
1899    instructions: "Build the code."
1900"#;
1901        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1902        let registry = HatRegistry::from_config(&config);
1903        let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1904        ralph.set_objective("Implement feature X".to_string());
1905
1906        let builder = registry.get(&ralph_proto::HatId::new("builder")).unwrap();
1907        let prompt = ralph.build_prompt("Event: build.task - Do the build", &[builder]);
1908
1909        assert!(
1910            !prompt.contains("## DONE"),
1911            "DONE section should be suppressed when a hat is active"
1912        );
1913        assert!(
1914            !prompt.contains("LOOP_COMPLETE"),
1915            "Completion promise should NOT appear when a hat is active"
1916        );
1917        // But objective should still be visible
1918        assert!(
1919            prompt.contains("## OBJECTIVE"),
1920            "OBJECTIVE should still appear even when hat is active"
1921        );
1922        assert!(
1923            prompt.contains("Implement feature X"),
1924            "Objective content should be visible to active hat"
1925        );
1926    }
1927
1928    #[test]
1929    fn test_done_section_present_when_coordinating() {
1930        // When active_hats is empty, prompt contains ## DONE with objective reinforcement
1931        let yaml = r#"
1932hats:
1933  builder:
1934    name: "Builder"
1935    triggers: ["build.task"]
1936    publishes: ["build.done"]
1937"#;
1938        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1939        let registry = HatRegistry::from_config(&config);
1940        let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1941        ralph.set_objective("Complete the TDD cycle".to_string());
1942
1943        // No active hats - Ralph is coordinating
1944        let prompt = ralph.build_prompt("Event: build.done - Build finished", &[]);
1945
1946        assert!(
1947            prompt.contains("## DONE"),
1948            "DONE section should appear when Ralph is coordinating"
1949        );
1950        assert!(
1951            prompt.contains("LOOP_COMPLETE"),
1952            "Completion promise should appear when coordinating"
1953        );
1954    }
1955
1956    #[test]
1957    fn test_objective_in_done_section_when_coordinating() {
1958        // DONE section includes "Remember your objective" when Ralph is coordinating
1959        let config = RalphConfig::default();
1960        let registry = HatRegistry::new();
1961        let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
1962        ralph.set_objective("Deploy the application".to_string());
1963
1964        let prompt = ralph.build_prompt("", &[]);
1965
1966        let done_pos = prompt.find("## DONE").expect("Should have DONE section");
1967        let after_done = &prompt[done_pos..];
1968
1969        assert!(
1970            after_done.contains("Remember your objective"),
1971            "DONE section should remind about objective when coordinating"
1972        );
1973        assert!(
1974            after_done.contains("Deploy the application"),
1975            "DONE section should contain the objective text"
1976        );
1977    }
1978
1979    // === Event Publishing Guide Tests ===
1980
1981    #[test]
1982    fn test_event_publishing_guide_with_receivers() {
1983        // When a hat publishes events and other hats receive them,
1984        // the guide should show who receives each event
1985        let yaml = r#"
1986hats:
1987  builder:
1988    name: "Builder"
1989    description: "Builds and tests code"
1990    triggers: ["build.task"]
1991    publishes: ["build.done", "build.blocked"]
1992  confessor:
1993    name: "Confessor"
1994    description: "Produces a ConfessionReport; rewarded for honesty"
1995    triggers: ["build.done"]
1996    publishes: ["confession.done"]
1997"#;
1998        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1999        let registry = HatRegistry::from_config(&config);
2000        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
2001
2002        // Get the builder hat as active
2003        let builder = registry.get(&ralph_proto::HatId::new("builder")).unwrap();
2004        let prompt = ralph.build_prompt("[build.task] Build the feature", &[builder]);
2005
2006        // Should include Event Publishing Guide
2007        assert!(
2008            prompt.contains("### Event Publishing Guide"),
2009            "Should include Event Publishing Guide section"
2010        );
2011        assert!(
2012            prompt.contains("When you publish:"),
2013            "Guide should explain what happens when publishing"
2014        );
2015        // build.done has a receiver (Confessor)
2016        assert!(
2017            prompt.contains("`build.done` → Received by: Confessor"),
2018            "Should show Confessor receives build.done"
2019        );
2020        assert!(
2021            prompt.contains("Produces a ConfessionReport; rewarded for honesty"),
2022            "Should include receiver's description"
2023        );
2024        // build.blocked has no receiver, so falls back to Ralph
2025        assert!(
2026            prompt.contains("`build.blocked` → Received by: Ralph (coordinates next steps)"),
2027            "Should show Ralph receives orphan events"
2028        );
2029    }
2030
2031    #[test]
2032    fn test_event_publishing_guide_no_publishes() {
2033        // When a hat doesn't publish any events, no guide should appear
2034        let yaml = r#"
2035hats:
2036  observer:
2037    name: "Observer"
2038    description: "Only observes"
2039    triggers: ["events.*"]
2040"#;
2041        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2042        let registry = HatRegistry::from_config(&config);
2043        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
2044
2045        let observer = registry.get(&ralph_proto::HatId::new("observer")).unwrap();
2046        let prompt = ralph.build_prompt("[events.start] Start", &[observer]);
2047
2048        // Should NOT include Event Publishing Guide
2049        assert!(
2050            !prompt.contains("### Event Publishing Guide"),
2051            "Should NOT include Event Publishing Guide when hat has no publishes"
2052        );
2053    }
2054
2055    #[test]
2056    fn test_event_publishing_guide_all_orphan_events() {
2057        // When all published events have no receivers, all should show Ralph
2058        let yaml = r#"
2059hats:
2060  solo:
2061    name: "Solo"
2062    triggers: ["solo.start"]
2063    publishes: ["solo.done", "solo.failed"]
2064"#;
2065        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2066        let registry = HatRegistry::from_config(&config);
2067        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
2068
2069        let solo = registry.get(&ralph_proto::HatId::new("solo")).unwrap();
2070        let prompt = ralph.build_prompt("[solo.start] Go", &[solo]);
2071
2072        assert!(
2073            prompt.contains("### Event Publishing Guide"),
2074            "Should include guide even for orphan events"
2075        );
2076        assert!(
2077            prompt.contains("`solo.done` → Received by: Ralph (coordinates next steps)"),
2078            "Orphan solo.done should go to Ralph"
2079        );
2080        assert!(
2081            prompt.contains("`solo.failed` → Received by: Ralph (coordinates next steps)"),
2082            "Orphan solo.failed should go to Ralph"
2083        );
2084    }
2085
2086    #[test]
2087    fn test_event_publishing_guide_multiple_receivers() {
2088        // When an event has multiple receivers, all should be listed
2089        let yaml = r#"
2090hats:
2091  broadcaster:
2092    name: "Broadcaster"
2093    triggers: ["broadcast.start"]
2094    publishes: ["signal.sent"]
2095  listener1:
2096    name: "Listener1"
2097    description: "First listener"
2098    triggers: ["signal.sent"]
2099  listener2:
2100    name: "Listener2"
2101    description: "Second listener"
2102    triggers: ["signal.sent"]
2103"#;
2104        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2105        let registry = HatRegistry::from_config(&config);
2106        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
2107
2108        let broadcaster = registry
2109            .get(&ralph_proto::HatId::new("broadcaster"))
2110            .unwrap();
2111        let prompt = ralph.build_prompt("[broadcast.start] Go", &[broadcaster]);
2112
2113        assert!(
2114            prompt.contains("### Event Publishing Guide"),
2115            "Should include guide"
2116        );
2117        // Both listeners should be mentioned (order may vary due to HashMap iteration)
2118        assert!(
2119            prompt.contains("Listener1 (First listener)"),
2120            "Should list Listener1 as receiver"
2121        );
2122        assert!(
2123            prompt.contains("Listener2 (Second listener)"),
2124            "Should list Listener2 as receiver"
2125        );
2126    }
2127
2128    #[test]
2129    fn test_event_publishing_guide_excludes_self() {
2130        // If a hat subscribes to its own event, it should NOT be listed as receiver
2131        let yaml = r#"
2132hats:
2133  looper:
2134    name: "Looper"
2135    triggers: ["loop.continue", "loop.start"]
2136    publishes: ["loop.continue"]
2137"#;
2138        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2139        let registry = HatRegistry::from_config(&config);
2140        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
2141
2142        let looper = registry.get(&ralph_proto::HatId::new("looper")).unwrap();
2143        let prompt = ralph.build_prompt("[loop.start] Start", &[looper]);
2144
2145        assert!(
2146            prompt.contains("### Event Publishing Guide"),
2147            "Should include guide"
2148        );
2149        // Self-reference should be excluded, so should fall back to Ralph
2150        assert!(
2151            prompt.contains("`loop.continue` → Received by: Ralph (coordinates next steps)"),
2152            "Self-subscription should be excluded, falling back to Ralph"
2153        );
2154    }
2155
2156    #[test]
2157    fn test_event_publishing_guide_receiver_without_description() {
2158        // When a receiver has no description, just show the name
2159        let yaml = r#"
2160hats:
2161  sender:
2162    name: "Sender"
2163    triggers: ["send.start"]
2164    publishes: ["message.sent"]
2165  receiver:
2166    name: "NoDescReceiver"
2167    triggers: ["message.sent"]
2168"#;
2169        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2170        let registry = HatRegistry::from_config(&config);
2171        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
2172
2173        let sender = registry.get(&ralph_proto::HatId::new("sender")).unwrap();
2174        let prompt = ralph.build_prompt("[send.start] Go", &[sender]);
2175
2176        assert!(
2177            prompt.contains("`message.sent` → Received by: NoDescReceiver"),
2178            "Should show receiver name without parentheses when no description"
2179        );
2180        // Should NOT have empty parentheses
2181        assert!(
2182            !prompt.contains("NoDescReceiver ()"),
2183            "Should NOT have empty parentheses for receiver without description"
2184        );
2185    }
2186
2187    // === Event Publishing Constraint Tests ===
2188
2189    #[test]
2190    fn test_constraint_lists_valid_events_when_coordinating() {
2191        // When Ralph is coordinating (no active hats), the prompt should include
2192        // a CONSTRAINT listing valid events to publish
2193        let yaml = r#"
2194hats:
2195  test_writer:
2196    name: "Test Writer"
2197    triggers: ["tdd.start"]
2198    publishes: ["test.written"]
2199  implementer:
2200    name: "Implementer"
2201    triggers: ["test.written"]
2202    publishes: ["test.passing"]
2203"#;
2204        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2205        let registry = HatRegistry::from_config(&config);
2206        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
2207
2208        // No active hats - Ralph is coordinating
2209        let prompt = ralph.build_prompt("[task.start] Do TDD for feature X", &[]);
2210
2211        // Should contain CONSTRAINT with valid events
2212        assert!(
2213            prompt.contains("**CONSTRAINT:**"),
2214            "Prompt should include CONSTRAINT when coordinating"
2215        );
2216        assert!(
2217            prompt.contains("tdd.start"),
2218            "CONSTRAINT should list tdd.start as valid event"
2219        );
2220        assert!(
2221            prompt.contains("test.written"),
2222            "CONSTRAINT should list test.written as valid event"
2223        );
2224        assert!(
2225            prompt.contains("Publishing other events will have no effect"),
2226            "CONSTRAINT should warn about invalid events"
2227        );
2228    }
2229
2230    #[test]
2231    fn test_no_constraint_when_hat_is_active() {
2232        // When a hat is active, the CONSTRAINT should NOT appear
2233        // (the active hat has its own Event Publishing Guide)
2234        let yaml = r#"
2235hats:
2236  builder:
2237    name: "Builder"
2238    triggers: ["build.task"]
2239    publishes: ["build.done"]
2240    instructions: "Build the code."
2241"#;
2242        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2243        let registry = HatRegistry::from_config(&config);
2244        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
2245
2246        // Builder hat is active
2247        let builder = registry.get(&ralph_proto::HatId::new("builder")).unwrap();
2248        let prompt = ralph.build_prompt("[build.task] Build feature X", &[builder]);
2249
2250        // Should NOT contain the coordinating CONSTRAINT
2251        assert!(
2252            !prompt.contains("**CONSTRAINT:** You MUST only publish events from this list"),
2253            "Active hat should NOT have coordinating CONSTRAINT"
2254        );
2255
2256        // Should have Event Publishing Guide instead
2257        assert!(
2258            prompt.contains("### Event Publishing Guide"),
2259            "Active hat should have Event Publishing Guide"
2260        );
2261    }
2262
2263    #[test]
2264    fn test_no_constraint_when_no_hats() {
2265        // When there are no hats (solo mode), no CONSTRAINT should appear
2266        let config = RalphConfig::default();
2267        let registry = HatRegistry::new(); // Empty registry
2268        let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), &registry, None);
2269
2270        let prompt = ralph.build_prompt("[task.start] Do something", &[]);
2271
2272        // Should NOT contain CONSTRAINT (no hats to coordinate)
2273        assert!(
2274            !prompt.contains("**CONSTRAINT:**"),
2275            "Solo mode should NOT have CONSTRAINT"
2276        );
2277    }
2278}