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