Skip to main content

ralph_core/event_loop/
mod.rs

1//! Event loop orchestration.
2//!
3//! The event loop coordinates the execution of hats via pub/sub messaging.
4
5mod loop_state;
6#[cfg(test)]
7mod tests;
8
9pub use loop_state::LoopState;
10
11use crate::config::{HatBackend, InjectMode, RalphConfig, ScratchpadConfig};
12use crate::event_parser::{EventParser, MutationEvidence, MutationStatus};
13use crate::event_reader::EventReader;
14use crate::hat_registry::HatRegistry;
15use crate::hatless_ralph::HatlessRalph;
16use crate::instructions::InstructionBuilder;
17use crate::loop_context::LoopContext;
18use crate::memory_store::{MarkdownMemoryStore, format_memories_as_markdown, truncate_to_budget};
19use crate::skill_registry::SkillRegistry;
20use crate::text::floor_char_boundary;
21use ralph_proto::{CheckinContext, Event, EventBus, Hat, HatId, RobotService};
22use serde::{Deserialize, Serialize};
23use serde_json::{Map, Value};
24use std::collections::HashMap;
25use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28use std::sync::atomic::AtomicBool;
29use std::time::Duration;
30use tracing::{debug, info, warn};
31
32/// Result of processing events from JSONL.
33#[derive(Debug, Clone)]
34pub struct ProcessedEvents {
35    /// Whether any valid events were found and published.
36    pub had_events: bool,
37    /// Whether any published events matched the semantic `plan.*` topic family.
38    pub had_plan_events: bool,
39    /// Structured context for the first processed `human.interact` event,
40    /// including the question payload and post-dispatch outcome metadata.
41    pub human_interact_context: Option<Value>,
42    /// Whether any events lacked specific hat subscribers (orphans handled by Ralph).
43    pub has_orphans: bool,
44}
45
46/// Result of processing events from JSONL with wave events partitioned out.
47#[derive(Debug)]
48pub struct ProcessedEventsWithWaves {
49    /// Normal event processing results.
50    pub processed: ProcessedEvents,
51    /// Wave events extracted before normal processing (have wave_id set).
52    pub wave_events: Vec<crate::event_reader::Event>,
53}
54
55/// Durable subset of loop runtime state restored by `ralph run --continue`.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct PersistedLoopState {
58    pub iteration: u32,
59    pub total_cost_usd: f64,
60    pub last_hat: Option<String>,
61    #[serde(default)]
62    pub peak_input_tokens: u64,
63    #[serde(default)]
64    pub last_input_tokens: Option<u64>,
65    #[serde(default)]
66    pub hat_peak_input_tokens: HashMap<String, u64>,
67}
68
69/// Reason the event loop terminated.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum TerminationReason {
72    /// Completion promise was detected in output.
73    CompletionPromise,
74    /// Maximum iterations reached.
75    MaxIterations,
76    /// Maximum runtime exceeded.
77    MaxRuntime,
78    /// Maximum cost exceeded.
79    MaxCost,
80    /// Too many consecutive failures.
81    ConsecutiveFailures,
82    /// Loop thrashing detected (repeated blocked events).
83    LoopThrashing,
84    /// Stale loop detected (same topic emitted 3+ times consecutively).
85    LoopStale,
86    /// Too many consecutive malformed JSONL lines in events file.
87    ValidationFailure,
88    /// Manually stopped.
89    Stopped,
90    /// Interrupted by signal (SIGINT/SIGTERM).
91    Interrupted,
92    /// Restart requested via Telegram `/restart` command.
93    RestartRequested,
94    /// Workspace directory (worktree) was removed externally.
95    WorkspaceGone,
96    /// Loop was cancelled gracefully via loop.cancel event (human rejection, timeout).
97    Cancelled,
98}
99
100impl TerminationReason {
101    /// Returns the exit code for this termination reason per spec.
102    ///
103    /// Per spec "Loop Termination" section:
104    /// - 0: Completion promise detected (success)
105    /// - 1: Consecutive failures or unrecoverable error (failure)
106    /// - 2: Max iterations, max runtime, or max cost exceeded (limit)
107    /// - 130: User interrupt (SIGINT = 128 + 2)
108    pub fn exit_code(&self) -> i32 {
109        match self {
110            TerminationReason::CompletionPromise => 0,
111            TerminationReason::ConsecutiveFailures
112            | TerminationReason::LoopThrashing
113            | TerminationReason::LoopStale
114            | TerminationReason::ValidationFailure
115            | TerminationReason::Stopped
116            | TerminationReason::WorkspaceGone => 1,
117            TerminationReason::MaxIterations
118            | TerminationReason::MaxRuntime
119            | TerminationReason::MaxCost => 2,
120            TerminationReason::Interrupted => 130,
121            // Restart uses exit code 3 to signal the caller to exec-replace
122            TerminationReason::RestartRequested => 3,
123            // Cancelled is a clean exit (0) — the loop stopped intentionally
124            TerminationReason::Cancelled => 0,
125        }
126    }
127
128    /// Returns the reason string for use in loop.terminate event payload.
129    ///
130    /// Per spec event payload format:
131    /// `completed | max_iterations | max_runtime | consecutive_failures | interrupted | error`
132    pub fn as_str(&self) -> &'static str {
133        match self {
134            TerminationReason::CompletionPromise => "completed",
135            TerminationReason::MaxIterations => "max_iterations",
136            TerminationReason::MaxRuntime => "max_runtime",
137            TerminationReason::MaxCost => "max_cost",
138            TerminationReason::ConsecutiveFailures => "consecutive_failures",
139            TerminationReason::LoopThrashing => "loop_thrashing",
140            TerminationReason::LoopStale => "loop_stale",
141            TerminationReason::ValidationFailure => "validation_failure",
142            TerminationReason::Stopped => "stopped",
143            TerminationReason::Interrupted => "interrupted",
144            TerminationReason::RestartRequested => "restart_requested",
145            TerminationReason::WorkspaceGone => "workspace_gone",
146            TerminationReason::Cancelled => "cancelled",
147        }
148    }
149
150    /// Returns true if this is a successful completion (not an error or limit).
151    pub fn is_success(&self) -> bool {
152        matches!(self, TerminationReason::CompletionPromise)
153    }
154}
155
156/// The main event loop orchestrator.
157pub struct EventLoop {
158    config: RalphConfig,
159    registry: HatRegistry,
160    bus: EventBus,
161    state: LoopState,
162    instruction_builder: InstructionBuilder,
163    ralph: HatlessRalph,
164    /// Cached human guidance messages that should persist across iterations.
165    robot_guidance: Vec<String>,
166    /// Event reader for consuming events from JSONL file.
167    /// Made pub(crate) to allow tests to override the path.
168    pub(crate) event_reader: EventReader,
169    diagnostics: crate::diagnostics::DiagnosticsCollector,
170    /// Loop context for path resolution (None for legacy single-loop mode).
171    loop_context: Option<LoopContext>,
172    /// Skill registry for the current loop.
173    skill_registry: SkillRegistry,
174    /// Robot service for human-in-the-loop communication.
175    /// Injected externally when `human.enabled` is true and this is the primary loop.
176    robot_service: Option<Box<dyn RobotService>>,
177}
178
179impl EventLoop {
180    /// Creates a new event loop from configuration.
181    pub fn new(config: RalphConfig) -> Self {
182        // Try to create diagnostics collector, but fall back to disabled if it fails
183        // (e.g., in tests without proper directory setup)
184        let diagnostics = crate::diagnostics::DiagnosticsCollector::new(std::path::Path::new("."))
185            .unwrap_or_else(|e| {
186                debug!(
187                    "Failed to initialize diagnostics: {}, using disabled collector",
188                    e
189                );
190                crate::diagnostics::DiagnosticsCollector::disabled()
191            });
192
193        Self::with_diagnostics(config, diagnostics)
194    }
195
196    /// Creates a new event loop with a loop context for path resolution.
197    ///
198    /// The loop context determines where events, tasks, and other state files
199    /// are located. Use this for multi-loop scenarios where each loop runs
200    /// in an isolated workspace (git worktree).
201    pub fn with_context(config: RalphConfig, context: LoopContext) -> Self {
202        let diagnostics = crate::diagnostics::DiagnosticsCollector::new(context.workspace())
203            .unwrap_or_else(|e| {
204                debug!(
205                    "Failed to initialize diagnostics: {}, using disabled collector",
206                    e
207                );
208                crate::diagnostics::DiagnosticsCollector::disabled()
209            });
210
211        Self::with_context_and_diagnostics(config, context, diagnostics)
212    }
213
214    /// Creates a new event loop with explicit loop context and diagnostics.
215    pub fn with_context_and_diagnostics(
216        mut config: RalphConfig,
217        context: LoopContext,
218        diagnostics: crate::diagnostics::DiagnosticsCollector,
219    ) -> Self {
220        // Solo mode safety guard: force scratchpad enabled when no hats defined
221        if config.hats.is_empty() && !config.core.scratchpad.enabled {
222            warn!(
223                "core.scratchpad.enabled is false but no hats are defined. \
224                 Scratchpad is the only continuity mechanism in solo mode — forcing enabled."
225            );
226            config.core.scratchpad.enabled = true;
227        }
228
229        let registry = HatRegistry::from_config(&config);
230        let instruction_builder =
231            InstructionBuilder::with_events(config.core.clone(), config.events.clone());
232
233        let mut bus = EventBus::new();
234
235        // Per spec: "Hatless Ralph is constant — Cannot be replaced, overwritten, or configured away"
236        // Ralph is ALWAYS registered as the universal fallback for orphaned events.
237        // Custom hats are registered first (higher priority), Ralph catches everything else.
238        for hat in registry.all() {
239            bus.register(hat.clone());
240        }
241
242        // Always register Ralph as catch-all coordinator
243        // Per spec: "Ralph runs when no hat triggered — Universal fallback for orphaned events"
244        let ralph_hat = ralph_proto::Hat::new("ralph", "Ralph").subscribe("*"); // Subscribe to all events
245        bus.register(ralph_hat);
246
247        if registry.is_empty() {
248            debug!("Solo mode: Ralph is the only coordinator");
249        } else {
250            debug!(
251                "Multi-hat mode: {} custom hats + Ralph as fallback",
252                registry.len()
253            );
254        }
255
256        // Build skill registry from config
257        let mut skill_registry = if config.skills.enabled {
258            SkillRegistry::from_config(
259                &config.skills,
260                context.workspace(),
261                Some(config.cli.backend.as_str()),
262            )
263            .unwrap_or_else(|e| {
264                warn!(
265                    "Failed to build skill registry: {}, using empty registry",
266                    e
267                );
268                SkillRegistry::new(Some(config.cli.backend.as_str()))
269            })
270        } else {
271            SkillRegistry::new(Some(config.cli.backend.as_str()))
272        };
273
274        // Remove task/memory skills from the index when their config is disabled
275        if !config.tasks.enabled {
276            skill_registry.remove("ralph-tools-tasks");
277        }
278        if !config.memories.enabled {
279            skill_registry.remove("ralph-tools-memories");
280        }
281
282        let skill_index = if config.skills.enabled {
283            skill_registry.build_index(None)
284        } else {
285            String::new()
286        };
287
288        // When memories are enabled, add tasks CLI instructions alongside scratchpad
289        let ralph = HatlessRalph::new(
290            config.event_loop.completion_promise.clone(),
291            config.core.clone(),
292            &registry,
293            config.event_loop.starting_event.clone(),
294        )
295        .with_memories_enabled(config.memories.enabled)
296        .with_skill_index(skill_index);
297
298        // Read timestamped events path from marker file, fall back to default
299        // The marker file contains a relative path like ".ralph/events-20260127-123456.jsonl"
300        // which we resolve relative to the workspace root
301        let events_path = std::fs::read_to_string(context.current_events_marker())
302            .map(|s| {
303                let relative = s.trim();
304                context.workspace().join(relative)
305            })
306            .unwrap_or_else(|_| context.events_path());
307        let event_reader = EventReader::new(&events_path);
308
309        Self {
310            config,
311            registry,
312            bus,
313            state: LoopState::new(),
314            instruction_builder,
315            ralph,
316            robot_guidance: Vec::new(),
317            event_reader,
318            diagnostics,
319            loop_context: Some(context),
320            skill_registry,
321            robot_service: None,
322        }
323    }
324
325    /// Creates a new event loop with explicit diagnostics collector (for testing).
326    pub fn with_diagnostics(
327        mut config: RalphConfig,
328        diagnostics: crate::diagnostics::DiagnosticsCollector,
329    ) -> Self {
330        // Solo mode safety guard: force scratchpad enabled when no hats defined
331        if config.hats.is_empty() && !config.core.scratchpad.enabled {
332            warn!(
333                "core.scratchpad.enabled is false but no hats are defined. \
334                 Scratchpad is the only continuity mechanism in solo mode — forcing enabled."
335            );
336            config.core.scratchpad.enabled = true;
337        }
338
339        let registry = HatRegistry::from_config(&config);
340        let instruction_builder =
341            InstructionBuilder::with_events(config.core.clone(), config.events.clone());
342
343        let mut bus = EventBus::new();
344
345        // Per spec: "Hatless Ralph is constant — Cannot be replaced, overwritten, or configured away"
346        // Ralph is ALWAYS registered as the universal fallback for orphaned events.
347        // Custom hats are registered first (higher priority), Ralph catches everything else.
348        for hat in registry.all() {
349            bus.register(hat.clone());
350        }
351
352        // Always register Ralph as catch-all coordinator
353        // Per spec: "Ralph runs when no hat triggered — Universal fallback for orphaned events"
354        let ralph_hat = ralph_proto::Hat::new("ralph", "Ralph").subscribe("*"); // Subscribe to all events
355        bus.register(ralph_hat);
356
357        if registry.is_empty() {
358            debug!("Solo mode: Ralph is the only coordinator");
359        } else {
360            debug!(
361                "Multi-hat mode: {} custom hats + Ralph as fallback",
362                registry.len()
363            );
364        }
365
366        // Build skill registry from config
367        let workspace_root = std::path::Path::new(".");
368        let mut skill_registry = if config.skills.enabled {
369            SkillRegistry::from_config(
370                &config.skills,
371                workspace_root,
372                Some(config.cli.backend.as_str()),
373            )
374            .unwrap_or_else(|e| {
375                warn!(
376                    "Failed to build skill registry: {}, using empty registry",
377                    e
378                );
379                SkillRegistry::new(Some(config.cli.backend.as_str()))
380            })
381        } else {
382            SkillRegistry::new(Some(config.cli.backend.as_str()))
383        };
384
385        // Remove task/memory skills from the index when their config is disabled
386        if !config.tasks.enabled {
387            skill_registry.remove("ralph-tools-tasks");
388        }
389        if !config.memories.enabled {
390            skill_registry.remove("ralph-tools-memories");
391        }
392
393        let skill_index = if config.skills.enabled {
394            skill_registry.build_index(None)
395        } else {
396            String::new()
397        };
398
399        // When memories are enabled, add tasks CLI instructions alongside scratchpad
400        let ralph = HatlessRalph::new(
401            config.event_loop.completion_promise.clone(),
402            config.core.clone(),
403            &registry,
404            config.event_loop.starting_event.clone(),
405        )
406        .with_memories_enabled(config.memories.enabled)
407        .with_skill_index(skill_index);
408
409        // Read events path from marker file, fall back to default if not present
410        // The marker file is written by run_loop_impl() at run startup
411        let events_path = std::fs::read_to_string(".ralph/current-events")
412            .map(|s| s.trim().to_string())
413            .unwrap_or_else(|_| ".ralph/events.jsonl".to_string());
414        let event_reader = EventReader::new(&events_path);
415
416        Self {
417            config,
418            registry,
419            bus,
420            state: LoopState::new(),
421            instruction_builder,
422            ralph,
423            robot_guidance: Vec::new(),
424            event_reader,
425            diagnostics,
426            loop_context: None,
427            skill_registry,
428            robot_service: None,
429        }
430    }
431
432    /// Injects a robot service for human-in-the-loop communication.
433    ///
434    /// Call this after construction to enable `human.interact` event handling,
435    /// periodic check-ins, and question/response flow. The service is typically
436    /// created by the CLI layer (e.g., `TelegramService`) and injected here,
437    /// keeping the core event loop decoupled from any specific communication
438    /// platform.
439    pub fn set_robot_service(&mut self, service: Box<dyn RobotService>) {
440        self.robot_service = Some(service);
441    }
442
443    /// Returns the loop context, if one was provided.
444    pub fn loop_context(&self) -> Option<&LoopContext> {
445        self.loop_context.as_ref()
446    }
447
448    /// Returns the tasks path based on loop context or default.
449    fn tasks_path(&self) -> PathBuf {
450        self.loop_context
451            .as_ref()
452            .map(|ctx| ctx.tasks_path())
453            .unwrap_or_else(|| PathBuf::from(".ralph/agent/tasks.jsonl"))
454    }
455
456    /// Returns the scratchpad path based on loop context and active scratchpad config.
457    ///
458    /// When a per-hat scratchpad override is active (path differs from global default),
459    /// the custom path is resolved relative to the loop context workspace for worktree
460    /// isolation. When using the default/global path, loop context's standard resolution
461    /// applies.
462    fn scratchpad_path(&self) -> PathBuf {
463        let active_path = &self.ralph.active_scratchpad().path;
464
465        match self.loop_context.as_ref() {
466            Some(ctx) => ctx.workspace().join(active_path),
467            None => PathBuf::from(active_path),
468        }
469    }
470
471    /// Returns the global scratchpad path (ignoring per-hat overrides).
472    /// Used for guidance persistence which is cross-hat state.
473    fn global_scratchpad_path(&self) -> PathBuf {
474        self.loop_context
475            .as_ref()
476            .map(|ctx| ctx.scratchpad_path())
477            .unwrap_or_else(|| PathBuf::from(&self.config.core.scratchpad.path))
478    }
479
480    /// Returns the current loop state.
481    pub fn state(&self) -> &LoopState {
482        &self.state
483    }
484
485    /// Record this iteration's context-token usage for `hat`.
486    ///
487    /// Passthrough to `LoopState::record_iteration_tokens` — preserves the
488    /// `record_event` encapsulation precedent (callers don't need `&mut LoopState`).
489    pub fn record_iteration_tokens(&mut self, hat: &HatId, tokens: u64) {
490        self.state.record_iteration_tokens(hat, tokens);
491    }
492
493    /// Returns the path used for durable `--continue` loop state.
494    pub fn loop_state_path(&self) -> PathBuf {
495        self.loop_context
496            .as_ref()
497            .map(|ctx| ctx.ralph_dir().join("api/loop-state.json"))
498            .unwrap_or_else(|| PathBuf::from(".ralph/api/loop-state.json"))
499    }
500
501    /// Serializes the durable subset of runtime state.
502    pub fn persisted_loop_state(&self) -> PersistedLoopState {
503        PersistedLoopState {
504            iteration: self.state.iteration,
505            total_cost_usd: self.state.cumulative_cost,
506            last_hat: self
507                .state
508                .last_hat
509                .as_ref()
510                .map(|hat| hat.as_str().to_string()),
511            peak_input_tokens: self.state.peak_input_tokens,
512            last_input_tokens: self.state.last_input_tokens,
513            hat_peak_input_tokens: self
514                .state
515                .hat_peak_input_tokens
516                .iter()
517                .map(|(hat, tokens)| (hat.as_str().to_string(), *tokens))
518                .collect(),
519        }
520    }
521
522    /// Restores persisted `--continue` state from disk if present.
523    pub fn restore_loop_state(&mut self, path: &std::path::Path) -> std::io::Result<()> {
524        if !path.exists() {
525            return Ok(());
526        }
527
528        let raw = std::fs::read_to_string(path)?;
529        let persisted: PersistedLoopState = serde_json::from_str(&raw).map_err(|error| {
530            std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string())
531        })?;
532        self.state.iteration = persisted.iteration;
533        self.state.cumulative_cost = persisted.total_cost_usd;
534        self.state.last_hat = persisted.last_hat.map(HatId::new);
535        self.state.peak_input_tokens = persisted.peak_input_tokens;
536        self.state.last_input_tokens = persisted.last_input_tokens;
537        self.state.hat_peak_input_tokens = persisted
538            .hat_peak_input_tokens
539            .into_iter()
540            .map(|(hat, tokens)| (HatId::new(hat), tokens))
541            .collect();
542        Ok(())
543    }
544
545    /// Saves persisted `--continue` state to disk.
546    pub fn save_loop_state(&self, path: &std::path::Path) -> std::io::Result<()> {
547        if let Some(parent) = path.parent() {
548            std::fs::create_dir_all(parent)?;
549        }
550        let payload = serde_json::to_string_pretty(&self.persisted_loop_state())?;
551        std::fs::write(path, payload)
552    }
553
554    /// Removes stale persisted loop state for a fresh run.
555    pub fn clear_loop_state(path: &std::path::Path) -> std::io::Result<()> {
556        match std::fs::remove_file(path) {
557            Ok(()) => Ok(()),
558            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
559            Err(error) => Err(error),
560        }
561    }
562
563    /// Resets the stale-loop topic counter.
564    ///
565    /// Call after processing wave results — multiple events with the same topic
566    /// (e.g. `review.done` from parallel workers) are expected and should not
567    /// trigger the stale loop detector.
568    pub fn reset_stale_topic_counter(&mut self) {
569        self.state.consecutive_same_signature = 0;
570        self.state.last_emitted_signature = None;
571    }
572
573    /// Returns the configuration.
574    pub fn config(&self) -> &RalphConfig {
575        &self.config
576    }
577
578    /// Returns the hat registry.
579    pub fn registry(&self) -> &HatRegistry {
580        &self.registry
581    }
582
583    /// Records hook telemetry for diagnostics.
584    pub fn log_hook_run_telemetry(&self, entry: crate::diagnostics::HookRunTelemetryEntry) {
585        self.diagnostics.log_hook_run(entry);
586    }
587
588    /// Logs the full prompt for an iteration to the diagnostics session.
589    pub fn log_prompt(&self, iteration: u32, hat: &str, prompt: &str) {
590        self.diagnostics.log_prompt(iteration, hat, prompt);
591    }
592
593    /// Gets the backend configuration for a hat.
594    ///
595    /// If the hat has a backend configured, returns that.
596    /// Otherwise, returns None (caller should use global backend).
597    pub fn get_hat_backend(&self, hat_id: &HatId) -> Option<&HatBackend> {
598        self.registry
599            .get_config(hat_id)
600            .and_then(|config| config.backend.as_ref())
601    }
602
603    /// Adds an observer that receives all published events.
604    ///
605    /// Multiple observers can be added (e.g., session recorder + TUI).
606    /// Each observer is called before events are routed to subscribers.
607    pub fn add_observer<F>(&mut self, observer: F)
608    where
609        F: Fn(&Event) + Send + 'static,
610    {
611        self.bus.add_observer(observer);
612    }
613
614    /// Sets a single observer, clearing any existing observers.
615    ///
616    /// Prefer `add_observer` when multiple observers are needed.
617    #[deprecated(since = "2.0.0", note = "Use add_observer instead")]
618    pub fn set_observer<F>(&mut self, observer: F)
619    where
620        F: Fn(&Event) + Send + 'static,
621    {
622        #[allow(deprecated)]
623        self.bus.set_observer(observer);
624    }
625
626    /// Checks if any termination condition is met.
627    pub fn check_termination(&self) -> Option<TerminationReason> {
628        let cfg = &self.config.event_loop;
629
630        if self.state.iteration >= cfg.max_iterations {
631            return Some(TerminationReason::MaxIterations);
632        }
633
634        if self.state.elapsed().as_secs() >= cfg.max_runtime_seconds {
635            return Some(TerminationReason::MaxRuntime);
636        }
637
638        if let Some(max_cost) = cfg.max_cost_usd
639            && self.state.cumulative_cost >= max_cost
640        {
641            return Some(TerminationReason::MaxCost);
642        }
643
644        if self.state.consecutive_failures >= cfg.max_consecutive_failures {
645            return Some(TerminationReason::ConsecutiveFailures);
646        }
647
648        // Check for loop thrashing: planner keeps dispatching abandoned tasks
649        if self.state.abandoned_task_redispatches >= 3 {
650            return Some(TerminationReason::LoopThrashing);
651        }
652
653        // Check for validation failures: too many consecutive malformed JSONL lines
654        if self.state.consecutive_malformed_events >= 3 {
655            return Some(TerminationReason::ValidationFailure);
656        }
657
658        // Check for stale loop: same event signature emitted 3+ times in a row
659        if self.state.consecutive_same_signature >= 3 {
660            let topic = self
661                .state
662                .last_emitted_signature
663                .as_ref()
664                .map(|signature| signature.topic.as_str())
665                .unwrap_or("?");
666            warn!(
667                topic,
668                count = self.state.consecutive_same_signature,
669                "Stale loop detected: same event signature emitted consecutively"
670            );
671            return Some(TerminationReason::LoopStale);
672        }
673
674        // Check for stop signal from Telegram /stop or CLI stop-requested
675        let stop_path =
676            std::path::Path::new(&self.config.core.workspace_root).join(".ralph/stop-requested");
677        if stop_path.exists() {
678            let _ = std::fs::remove_file(&stop_path);
679            return Some(TerminationReason::Stopped);
680        }
681
682        // Check for restart signal from Telegram /restart command
683        let restart_path =
684            std::path::Path::new(&self.config.core.workspace_root).join(".ralph/restart-requested");
685        if restart_path.exists() {
686            return Some(TerminationReason::RestartRequested);
687        }
688
689        // Check if workspace directory has been removed (zombie worktree detection)
690        if !std::path::Path::new(&self.config.core.workspace_root).is_dir() {
691            return Some(TerminationReason::WorkspaceGone);
692        }
693
694        None
695    }
696
697    /// Check if a loop.cancel event was detected.
698    ///
699    /// Unlike check_completion_event(), this does NOT validate required_events.
700    /// Cancellation is an explicit abort — it doesn't need the workflow to be complete.
701    pub fn check_cancellation_event(&mut self) -> Option<TerminationReason> {
702        if !self.state.cancellation_requested {
703            return None;
704        }
705        self.state.cancellation_requested = false;
706        info!("Loop cancelled gracefully via loop.cancel event");
707
708        self.diagnostics.log_orchestration(
709            self.state.iteration,
710            "loop",
711            crate::diagnostics::OrchestrationEvent::LoopTerminated {
712                reason: "cancelled".to_string(),
713            },
714        );
715
716        Some(TerminationReason::Cancelled)
717    }
718
719    /// Request completion from the text fallback path.
720    ///
721    /// When a backend outputs a completion promise as plain text (without
722    /// using `ralph emit`), this sets `completion_requested = true` so that
723    /// `check_completion_event()` can apply all safety checks (persistent mode,
724    /// required events, runtime tasks) before terminating.
725    pub fn request_completion_from_text_fallback(&mut self) {
726        self.state.completion_requested = true;
727        info!("Completion requested via text fallback (output contained completion promise)");
728    }
729
730    /// Checks if a completion event was received and returns termination reason.
731    ///
732    /// Completion is accepted via JSONL events (e.g., `ralph emit`) or via
733    /// [`request_completion_from_text_fallback`].
734    pub fn check_completion_event(&mut self) -> Option<TerminationReason> {
735        if !self.state.completion_requested {
736            return None;
737        }
738
739        // Event chain validation: check required events were seen
740        let required = &self.config.event_loop.required_events;
741        if !required.is_empty() {
742            let missing = self.state.missing_required_events(required);
743            if !missing.is_empty() {
744                warn!(
745                    missing = ?missing,
746                    "Rejecting LOOP_COMPLETE: required events not seen during loop lifetime"
747                );
748                self.state.completion_requested = false;
749
750                // Inject task.resume so the loop continues
751                let resume_payload = format!(
752                    "LOOP_COMPLETE rejected: missing required events: {:?}. \
753                     The agent must complete all workflow phases before emitting LOOP_COMPLETE. \
754                     Use loop.cancel to abort the workflow instead.",
755                    missing
756                );
757                self.bus.publish(Event::new("task.resume", resume_payload));
758                return None;
759            }
760        }
761
762        if !self.state.unacknowledged_guidance.is_empty() {
763            let guidance_count = self.state.unacknowledged_guidance.len();
764            warn!(
765                guidance_count,
766                "Rejecting LOOP_COMPLETE: human.guidance is unacknowledged"
767            );
768            self.state.completion_requested = false;
769            self.bus.publish(Event::new(
770                "task.resume",
771                format!(
772                    "Completion rejected: {guidance_count} human.guidance message(s) remain unacknowledged. Address the guidance, then emit human.guidance.ack with a summary before emitting the completion promise."
773                ),
774            ));
775            return None;
776        }
777
778        self.state.completion_requested = false;
779
780        // In persistent mode, suppress completion and keep the loop alive
781        if self.config.event_loop.persistent {
782            info!("Completion event suppressed - persistent mode active, loop staying alive");
783
784            self.diagnostics.log_orchestration(
785                self.state.iteration,
786                "loop",
787                crate::diagnostics::OrchestrationEvent::LoopTerminated {
788                    reason: "completion_event_suppressed_persistent".to_string(),
789                },
790            );
791
792            // Inject a task.resume event so the loop continues with an idle prompt
793            let resume_event = Event::new(
794                "task.resume",
795                "Persistent mode: loop staying alive after completion signal. \
796                 Check for new tasks or await human guidance.",
797            );
798            self.bus.publish(resume_event);
799
800            return None;
801        }
802
803        // Runtime tasks are the canonical queue when memories/tasks mode is enabled.
804        if self.config.memories.enabled {
805            if let Ok(false) = self.verify_tasks_complete() {
806                let open_tasks = self.get_open_task_list();
807                warn!(
808                    open_tasks = ?open_tasks,
809                    "Rejecting completion event with {} open task(s)",
810                    open_tasks.len()
811                );
812                self.bus.publish(Event::new(
813                    "task.resume",
814                    format!(
815                        "Completion rejected: runtime tasks remain open: {:?}. Close, fail, or reopen outstanding tasks before emitting the completion promise.",
816                        open_tasks
817                    ),
818                ));
819                return None;
820            }
821        } else if let Ok(false) = self.verify_scratchpad_complete() {
822            warn!("Completion event with pending scratchpad tasks - trusting agent decision");
823        }
824
825        info!("Completion event detected - terminating");
826
827        // Log loop terminated
828        self.diagnostics.log_orchestration(
829            self.state.iteration,
830            "loop",
831            crate::diagnostics::OrchestrationEvent::LoopTerminated {
832                reason: "completion_event".to_string(),
833            },
834        );
835
836        Some(TerminationReason::CompletionPromise)
837    }
838
839    /// Initializes the loop by publishing the start event.
840    pub fn initialize(&mut self, prompt_content: &str) {
841        // Use configured starting_event or default to task.start for backward compatibility
842        let topic = self
843            .config
844            .event_loop
845            .starting_event
846            .clone()
847            .unwrap_or_else(|| "task.start".to_string());
848        self.initialize_with_topic(&topic, prompt_content);
849    }
850
851    /// Initializes the loop for resume mode by publishing task.resume.
852    ///
853    /// Per spec: "User can run `ralph resume` to restart reading existing scratchpad."
854    /// The planner should read the existing scratchpad rather than doing fresh gap analysis.
855    pub fn initialize_resume(&mut self, prompt_content: &str) {
856        // Resume always uses task.resume regardless of starting_event config
857        self.initialize_with_topic("task.resume", prompt_content);
858    }
859
860    /// Common initialization logic with configurable topic.
861    fn initialize_with_topic(&mut self, topic: &str, prompt_content: &str) {
862        // Store the objective so it persists across all iterations.
863        // After iteration 1, bus.take_pending() consumes the start event,
864        // so without this the objective would be invisible to later hats.
865        self.ralph.set_objective(prompt_content.to_string());
866
867        let start_event = Event::new(topic, prompt_content);
868        self.bus.publish(start_event);
869        debug!(topic = topic, "Published {} event", topic);
870    }
871
872    /// Gets the next hat to execute (if any have pending events).
873    ///
874    /// Per "Hatless Ralph" architecture: When custom hats are defined, Ralph is
875    /// always the executor. Custom hats define topology (pub/sub contracts) that
876    /// Ralph uses for coordination context, but Ralph handles all iterations.
877    ///
878    /// - Solo mode (no custom hats): Returns "ralph" if Ralph has pending events
879    /// - Multi-hat mode (custom hats defined): Always returns "ralph" if ANY hat has pending events
880    pub fn next_hat(&self) -> Option<&HatId> {
881        let next = self.bus.next_hat_with_pending();
882
883        // If no pending hat events but human interactions are pending, route to Ralph.
884        if next.is_none() && self.bus.has_human_pending() {
885            return self.bus.hat_ids().find(|id| id.as_str() == "ralph");
886        }
887
888        // If no pending events, return None
889        next.as_ref()?;
890
891        // In multi-hat mode, always route to Ralph (custom hats define topology only)
892        // Ralph's prompt includes the ## HATS section for coordination awareness
893        if self.registry.is_empty() {
894            // Solo mode - return the next hat (which is "ralph")
895            next
896        } else {
897            // Return "ralph" - the constant coordinator
898            // Find ralph in the bus's registered hats
899            self.bus.hat_ids().find(|id| id.as_str() == "ralph")
900        }
901    }
902
903    /// Replays persisted events needed to resume an interrupted/parked loop.
904    ///
905    /// Resume has two separate needs:
906    /// - all historical topics hydrate `seen_topics` so event-chain validation
907    ///   knows what happened before the process restarted;
908    /// - events appended after the last `loop.terminate` are republished to the
909    ///   in-memory bus so watchdog/human resume events trigger hats naturally.
910    ///
911    /// Events at or before the last `loop.terminate` are state history only and
912    /// must not be republished, otherwise `--continue` would rerun old work.
913    pub fn replay_resume_events_from_jsonl(&mut self) -> std::io::Result<ProcessedEvents> {
914        let path = self.event_reader.path().to_path_buf();
915        let mut historical_reader = EventReader::new(&path);
916        let result = historical_reader.read_new_events()?;
917
918        for event in &result.events {
919            self.state.seen_topics.insert(event.topic.clone());
920        }
921
922        let last_terminate_index = result
923            .events
924            .iter()
925            .rposition(|event| event.topic == "loop.terminate");
926        let last_terminate_line = if result.malformed.is_empty() {
927            None
928        } else {
929            Self::last_terminate_line_number(&path)?
930        };
931        let replay_events = match last_terminate_index {
932            Some(index) => result.events.into_iter().skip(index + 1).collect(),
933            None => Vec::new(),
934        };
935        let replay_malformed = match last_terminate_line {
936            Some(line_number) => result
937                .malformed
938                .into_iter()
939                .filter(|malformed| malformed.line_number > line_number)
940                .collect(),
941            None => Vec::new(),
942        };
943
944        let processed = self.process_parse_result(crate::event_reader::ParseResult {
945            events: replay_events,
946            malformed: replay_malformed,
947        })?;
948
949        if let Ok(metadata) = std::fs::metadata(&path) {
950            self.event_reader.set_position(metadata.len());
951        }
952
953        Ok(processed)
954    }
955
956    fn last_terminate_line_number(path: &Path) -> std::io::Result<Option<u64>> {
957        if !path.exists() {
958            return Ok(None);
959        }
960
961        let file = std::fs::File::open(path)?;
962        let reader = std::io::BufReader::new(file);
963        let mut last_terminate = None;
964
965        for (index, line) in reader.lines().enumerate() {
966            let line = line?;
967            if line.trim().is_empty() {
968                continue;
969            }
970            if let Ok(event) = serde_json::from_str::<crate::event_reader::Event>(&line)
971                && event.topic == "loop.terminate"
972            {
973                last_terminate = Some(index as u64 + 1);
974            }
975        }
976
977        Ok(last_terminate)
978    }
979
980    /// Advances the event reader to the current end of the events file.
981    ///
982    /// Call this after writing observability records (e.g. start event) to the
983    /// events JSONL file so they are not re-read by `process_events_from_jsonl`.
984    /// The start event is already published to the bus via `initialize()`, so
985    /// re-reading it from the file would cause double-delivery.
986    pub fn sync_event_reader_to_file_end(&mut self) {
987        let path = self.event_reader.path();
988        if let Ok(metadata) = std::fs::metadata(path) {
989            self.event_reader.set_position(metadata.len());
990        }
991    }
992
993    /// Checks if any hats have pending events.
994    ///
995    /// Use this after `process_output` to detect if the LLM failed to publish an event.
996    /// If false after processing, the loop will terminate on the next iteration.
997    pub fn has_pending_events(&self) -> bool {
998        self.bus.next_hat_with_pending().is_some() || self.bus.has_human_pending()
999    }
1000
1001    /// Checks if any pending events are human-related (human.response, human.guidance).
1002    ///
1003    /// Used to skip cooldown delays when a human event is next, since we don't
1004    /// want to artificially delay the response to a human interaction.
1005    pub fn has_pending_human_events(&self) -> bool {
1006        self.bus.has_human_pending()
1007    }
1008
1009    /// Injects `human.guidance` events directly into the in-memory bus.
1010    ///
1011    /// This is used for local TUI/RPC guidance so the next prompt boundary
1012    /// sees the message immediately without waiting for a JSONL reread.
1013    pub fn inject_human_guidance<I, S>(&mut self, messages: I)
1014    where
1015        I: IntoIterator<Item = S>,
1016        S: Into<String>,
1017    {
1018        for message in messages {
1019            let event = Event::new("human.guidance", message.into());
1020            self.state.record_event(&event);
1021            self.bus.publish(event);
1022        }
1023    }
1024
1025    /// Returns whether unread JSONL events include any semantic `plan.*` topics.
1026    ///
1027    /// This allows callers to dispatch `pre.plan.created` hooks before
1028    /// event publication handling without consuming unread events.
1029    pub fn has_pending_plan_events_in_jsonl(&self) -> std::io::Result<bool> {
1030        let result = self.event_reader.peek_new_events()?;
1031        Ok(result
1032            .events
1033            .iter()
1034            .any(|event| event.topic.starts_with("plan.")))
1035    }
1036
1037    /// Returns structured context for the first unread `human.interact` event,
1038    /// if one is present in JSONL without consuming reader state.
1039    pub fn pending_human_interact_context_in_jsonl(&self) -> std::io::Result<Option<Value>> {
1040        let result = self.event_reader.peek_new_events()?;
1041        Ok(result
1042            .events
1043            .iter()
1044            .find(|event| event.topic == "human.interact")
1045            .map(|event| {
1046                Self::parse_human_interact_context(event.payload.as_deref().unwrap_or_default())
1047            }))
1048    }
1049
1050    /// Gets the topics a hat is allowed to publish.
1051    ///
1052    /// Used to build retry prompts when the LLM forgets to publish an event.
1053    pub fn get_hat_publishes(&self, hat_id: &HatId) -> Vec<String> {
1054        self.registry
1055            .get(hat_id)
1056            .map(|hat| hat.publishes.iter().map(|t| t.to_string()).collect())
1057            .unwrap_or_default()
1058    }
1059
1060    /// Injects a fallback event to recover from a stalled loop.
1061    ///
1062    /// When no hats have pending events (agent failed to publish), this method
1063    /// injects a `task.resume` event which Ralph will handle to attempt recovery.
1064    ///
1065    /// Returns true if a fallback event was injected, false if recovery is not possible.
1066    pub fn inject_fallback_event(&mut self) -> bool {
1067        // If a custom hat was last executing, target the fallback back to it
1068        // This preserves hat context instead of always falling back to Ralph
1069        let fallback_event = match &self.state.last_hat {
1070            Some(hat_id) if hat_id.as_str() != "ralph" => {
1071                let publishes = self.get_hat_publishes(hat_id);
1072                let payload = if publishes.is_empty() {
1073                    format!(
1074                        "RECOVERY: Previous iteration by hat `{}` did not publish an event. \
1075                         Emit exactly one valid next event via `ralph emit`, or stop only after \
1076                         publishing the configured completion event.",
1077                        hat_id.as_str()
1078                    )
1079                } else {
1080                    format!(
1081                        "RECOVERY: Previous iteration by hat `{}` did not publish an event. \
1082                         This failed because no event was emitted. Emit exactly ONE valid next \
1083                         event via `ralph emit`. Allowed topics: `{}`. Do not only write prose \
1084                         or update files. Stop immediately after emitting.",
1085                        hat_id.as_str(),
1086                        publishes.join("`, `")
1087                    )
1088                };
1089
1090                debug!(
1091                    hat = %hat_id.as_str(),
1092                    "Injecting fallback event to recover - targeting last hat with task.resume"
1093                );
1094                Event::new("task.resume", payload).with_target(hat_id.clone())
1095            }
1096            _ => {
1097                debug!("Injecting fallback event to recover - triggering Ralph with task.resume");
1098                Event::new(
1099                    "task.resume",
1100                    "RECOVERY: Previous iteration did not publish an event. \
1101                     Review the scratchpad and either dispatch the next task or complete the loop.",
1102                )
1103            }
1104        };
1105
1106        self.bus.publish(fallback_event);
1107        true
1108    }
1109
1110    /// Builds the prompt for a hat's execution.
1111    ///
1112    /// Per "Hatless Ralph" architecture:
1113    /// - Solo mode: Ralph handles everything with his own prompt
1114    /// - Multi-hat mode: Ralph is the sole executor, custom hats define topology only
1115    ///
1116    /// When in multi-hat mode, this method collects ALL pending events across all hats
1117    /// and builds Ralph's prompt with that context. The `## HATS` section in Ralph's
1118    /// prompt documents the topology for coordination awareness.
1119    ///
1120    /// If memories are configured with `inject: auto`, this method also prepends
1121    /// primed memories to the prompt context. If a scratchpad file exists and is
1122    /// non-empty, its content is also prepended (before memories).
1123    pub fn build_prompt(&mut self, hat_id: &HatId) -> Option<String> {
1124        // Handle "ralph" hat - the constant coordinator
1125        // Per spec: "Hatless Ralph is constant — Cannot be replaced, overwritten, or configured away"
1126        if hat_id.as_str() == "ralph" {
1127            if self.registry.is_empty() {
1128                // Solo mode - just Ralph's events, no hats to filter
1129                let mut events = self.bus.take_pending(&hat_id.clone());
1130                let mut human_events = self.bus.take_human_pending();
1131                events.append(&mut human_events);
1132
1133                // Separate human.guidance events from regular events
1134                let (guidance_events, regular_events): (Vec<_>, Vec<_>) = events
1135                    .into_iter()
1136                    .partition(|e| e.topic.as_str() == "human.guidance");
1137
1138                let events_context = regular_events
1139                    .iter()
1140                    .map(|e| Self::format_event(e))
1141                    .collect::<Vec<_>>()
1142                    .join("\n");
1143
1144                // Solo mode: set scratchpad and iteration before guidance persistence
1145                self.ralph
1146                    .set_active_scratchpad(self.config.core.scratchpad.clone());
1147                self.ralph.set_iteration(self.state.iteration);
1148
1149                // Persist and inject human guidance into prompt if present
1150                self.update_robot_guidance(guidance_events);
1151                self.apply_robot_guidance();
1152
1153                // Build base prompt and prepend memories + scratchpad + ready tasks
1154                let base_prompt = self.ralph.build_prompt(&events_context, &[]);
1155                self.ralph.clear_robot_guidance();
1156                let with_skills = self.prepend_auto_inject_skills(base_prompt);
1157                let with_scratchpad = self.prepend_scratchpad(with_skills);
1158                let final_prompt = self.prepend_ready_tasks(with_scratchpad);
1159
1160                debug!("build_prompt: routing to HatlessRalph (solo mode)");
1161                return Some(final_prompt);
1162            } else {
1163                // Multi-hat mode: collect events and determine active hats
1164                let mut all_hat_ids: Vec<HatId> = self.bus.hat_ids().cloned().collect();
1165                // Deterministic ordering (avoid HashMap iteration order nondeterminism).
1166                all_hat_ids.sort_by(|a, b| a.as_str().cmp(b.as_str()));
1167
1168                let mut all_events = Vec::new();
1169                let mut system_events = Vec::new();
1170
1171                for id in &all_hat_ids {
1172                    let pending = self.bus.take_pending(id);
1173                    if pending.is_empty() {
1174                        continue;
1175                    }
1176
1177                    let (drop_pending, exhausted_event) = self.check_hat_exhaustion(id, &pending);
1178                    if drop_pending {
1179                        // Drop the pending events that would have activated the hat.
1180                        if let Some(exhausted_event) = exhausted_event {
1181                            all_events.push(exhausted_event.clone());
1182                            system_events.push(exhausted_event);
1183                        }
1184                        continue;
1185                    }
1186
1187                    all_events.extend(pending);
1188                }
1189
1190                let mut human_events = self.bus.take_human_pending();
1191                all_events.append(&mut human_events);
1192
1193                // Publish orchestrator-generated system events after consuming pending events,
1194                // so they become visible in the event log and can be handled next iteration.
1195                for event in system_events {
1196                    self.bus.publish(event);
1197                }
1198
1199                // Separate human.guidance events from regular events
1200                let (guidance_events, regular_events): (Vec<_>, Vec<_>) = all_events
1201                    .into_iter()
1202                    .partition(|e| e.topic.as_str() == "human.guidance");
1203
1204                // Ignore kickoff/recovery noise when a real downstream event is pending.
1205                let effective_regular_events = self.effective_regular_events(&regular_events);
1206
1207                // Determine which hats are active based on regular events
1208                let active_hat_ids = self.determine_active_hat_ids(&regular_events);
1209                self.record_hat_activations(&active_hat_ids);
1210                self.state.last_active_hat_ids = active_hat_ids.clone();
1211
1212                // Resolve scratchpad config for the active hat (or global default).
1213                // Must happen BEFORE guidance persistence so guidance is written
1214                // to the correct hat's scratchpad file.
1215                let resolved_scratchpad = if let Some(hat_id) = active_hat_ids.first() {
1216                    let hat_scratchpad = self
1217                        .registry
1218                        .get_config(hat_id)
1219                        .and_then(|c| c.scratchpad.as_ref());
1220                    ScratchpadConfig::resolve(hat_scratchpad, &self.config.core.scratchpad)
1221                } else {
1222                    // Ralph coordinating — use global
1223                    self.config.core.scratchpad.clone()
1224                };
1225                self.ralph.set_active_scratchpad(resolved_scratchpad);
1226                self.ralph.set_iteration(self.state.iteration);
1227
1228                // Persist and inject human guidance after scratchpad resolution
1229                // (must also happen before immutable borrows from determine_active_hats)
1230                self.update_robot_guidance(guidance_events);
1231                self.apply_robot_guidance();
1232
1233                let active_hats = self.determine_active_hats(&regular_events);
1234
1235                // Format events for context
1236                let events_context = effective_regular_events
1237                    .iter()
1238                    .map(|e| Self::format_event(e))
1239                    .collect::<Vec<_>>()
1240                    .join("\n");
1241
1242                // Build base prompt and prepend memories + scratchpad if available
1243                let base_prompt = self.ralph.build_prompt(&events_context, &active_hats);
1244
1245                // Build prompt with active hats - filters instructions to only active hats
1246                debug!(
1247                    "build_prompt: routing to HatlessRalph (multi-hat coordinator mode), active_hats: {:?}",
1248                    active_hats
1249                        .iter()
1250                        .map(|h| h.id.as_str())
1251                        .collect::<Vec<_>>()
1252                );
1253
1254                // Clear guidance after active_hats references are no longer needed
1255                self.ralph.clear_robot_guidance();
1256                let with_skills = self.prepend_auto_inject_skills(base_prompt);
1257                let with_scratchpad = self.prepend_scratchpad(with_skills);
1258                let final_prompt = self.prepend_ready_tasks(with_scratchpad);
1259
1260                return Some(final_prompt);
1261            }
1262        }
1263
1264        // Non-ralph hat requested - this shouldn't happen in multi-hat mode since
1265        // next_hat() always returns "ralph" when custom hats are defined.
1266        // But we keep this code path for backward compatibility and tests.
1267        let events = self.bus.take_pending(&hat_id.clone());
1268        let events_context = events
1269            .iter()
1270            .map(|e| Self::format_event(e))
1271            .collect::<Vec<_>>()
1272            .join("\n");
1273
1274        let hat = self.registry.get(hat_id)?;
1275
1276        // Debug logging to trace hat routing
1277        debug!(
1278            "build_prompt: hat_id='{}', instructions.is_empty()={}",
1279            hat_id.as_str(),
1280            hat.instructions.is_empty()
1281        );
1282
1283        // All hats use build_custom_hat with ghuntley-style prompts
1284        debug!(
1285            "build_prompt: routing to build_custom_hat() for '{}'",
1286            hat_id.as_str()
1287        );
1288        let hat_scratchpad = self
1289            .registry
1290            .get_config(hat_id)
1291            .and_then(|c| c.scratchpad.as_ref());
1292        let resolved_scratchpad =
1293            ScratchpadConfig::resolve(hat_scratchpad, &self.config.core.scratchpad);
1294        self.ralph
1295            .set_active_scratchpad(resolved_scratchpad.clone());
1296        Some(
1297            self.instruction_builder
1298                .build_custom_hat(hat, &events_context, &resolved_scratchpad),
1299        )
1300    }
1301
1302    /// Stores guidance payloads, persists them to scratchpad, and prepares them for prompt injection.
1303    ///
1304    /// Guidance events are ephemeral in the event bus (consumed by `take_pending`).
1305    /// This method both caches them in memory for prompt injection and appends
1306    /// them to the scratchpad file so they survive across process restarts.
1307    fn update_robot_guidance(&mut self, guidance_events: Vec<Event>) {
1308        if guidance_events.is_empty() {
1309            return;
1310        }
1311
1312        // Persist new guidance to scratchpad before caching
1313        self.persist_guidance_to_scratchpad(&guidance_events);
1314
1315        self.robot_guidance
1316            .extend(guidance_events.into_iter().map(|e| e.payload));
1317    }
1318
1319    /// Appends human guidance entries to the scratchpad file for durability.
1320    ///
1321    /// Each guidance message is written as a timestamped markdown entry so it
1322    /// appears alongside the agent's own thinking and survives process restarts.
1323    ///
1324    /// When scratchpad is disabled for the current hat, persists to the global
1325    /// scratchpad path (guidance is cross-hat state). If global is also disabled,
1326    /// skips persistence.
1327    fn persist_guidance_to_scratchpad(&self, guidance_events: &[Event]) {
1328        use std::io::Write;
1329
1330        // When hat scratchpad is disabled, fall back to global scratchpad
1331        let scratchpad_path = if self.ralph.active_scratchpad().enabled {
1332            self.scratchpad_path()
1333        } else {
1334            if !self.config.core.scratchpad.enabled {
1335                debug!("Both hat and global scratchpad disabled, skipping guidance persistence");
1336                return;
1337            }
1338            self.global_scratchpad_path()
1339        };
1340        let resolved_path = if scratchpad_path.is_relative() {
1341            self.config.core.workspace_root.join(&scratchpad_path)
1342        } else {
1343            scratchpad_path
1344        };
1345
1346        // Create parent directories if needed
1347        if let Some(parent) = resolved_path.parent()
1348            && !parent.exists()
1349            && let Err(e) = std::fs::create_dir_all(parent)
1350        {
1351            warn!("Failed to create scratchpad directory: {}", e);
1352            return;
1353        }
1354
1355        let mut file = match std::fs::OpenOptions::new()
1356            .create(true)
1357            .append(true)
1358            .open(&resolved_path)
1359        {
1360            Ok(f) => f,
1361            Err(e) => {
1362                warn!("Failed to open scratchpad for guidance persistence: {}", e);
1363                return;
1364            }
1365        };
1366
1367        let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
1368        for event in guidance_events {
1369            let entry = format!(
1370                "\n### HUMAN GUIDANCE ({})\n\n{}\n",
1371                timestamp, event.payload
1372            );
1373            if let Err(e) = file.write_all(entry.as_bytes()) {
1374                warn!("Failed to write guidance to scratchpad: {}", e);
1375            }
1376        }
1377
1378        info!(
1379            count = guidance_events.len(),
1380            "Persisted human guidance to scratchpad"
1381        );
1382    }
1383
1384    /// Injects cached guidance into the next prompt build.
1385    fn apply_robot_guidance(&mut self) {
1386        if self.robot_guidance.is_empty() {
1387            return;
1388        }
1389
1390        self.ralph.set_robot_guidance(self.robot_guidance.clone());
1391    }
1392
1393    /// Prepends auto-injected skill content to the prompt.
1394    ///
1395    /// This generalizes the former `prepend_memories()` into a skill auto-injection
1396    /// pipeline that handles memories, tools, and any other auto-inject skills.
1397    ///
1398    /// Injection order:
1399    /// 1. Memory data + ralph-tools skill (special case: loads memory data from store, applies budget)
1400    /// 2. RObot interaction skill (gated by `robot.enabled`)
1401    /// 3. Other auto-inject skills from the registry (wrapped in XML tags)
1402    fn prepend_auto_inject_skills(&self, prompt: String) -> String {
1403        let mut prefix = String::new();
1404
1405        // 1. Memory data + ralph-tools skill — special case with data loading
1406        self.inject_memories_and_tools_skill(&mut prefix);
1407
1408        // 2. RObot interaction skill — gated by robot.enabled
1409        self.inject_robot_skill(&mut prefix);
1410
1411        // 3. Other auto-inject skills from the registry
1412        self.inject_custom_auto_skills(&mut prefix);
1413
1414        if prefix.is_empty() {
1415            return prompt;
1416        }
1417
1418        prefix.push_str("\n\n");
1419        prefix.push_str(&prompt);
1420        prefix
1421    }
1422
1423    /// Injects memory data and the ralph-tools skill into the prefix.
1424    ///
1425    /// Special case: loads memory entries from the store, applies budget
1426    /// truncation, then appends the ralph-tools skill content (which covers
1427    /// both tasks and memories CLI usage).
1428    /// Memory data is gated by `memories.enabled && memories.inject == Auto`.
1429    /// The ralph-tools skill is injected when either memories or tasks are enabled.
1430    fn inject_memories_and_tools_skill(&self, prefix: &mut String) {
1431        let memories_config = &self.config.memories;
1432
1433        // Inject memory DATA if memories are enabled with auto-inject
1434        if memories_config.enabled && memories_config.inject == InjectMode::Auto {
1435            info!(
1436                "Memory injection check: enabled={}, inject={:?}, workspace_root={:?}",
1437                memories_config.enabled, memories_config.inject, self.config.core.workspace_root
1438            );
1439
1440            let workspace_root = &self.config.core.workspace_root;
1441            let store = MarkdownMemoryStore::with_default_path(workspace_root);
1442            let memories_path = workspace_root.join(".ralph/agent/memories.md");
1443
1444            info!(
1445                "Looking for memories at: {:?} (exists: {})",
1446                memories_path,
1447                memories_path.exists()
1448            );
1449
1450            let memories = match store.load() {
1451                Ok(memories) => {
1452                    info!("Successfully loaded {} memories from store", memories.len());
1453                    memories
1454                }
1455                Err(e) => {
1456                    info!(
1457                        "Failed to load memories for injection: {} (path: {:?})",
1458                        e, memories_path
1459                    );
1460                    Vec::new()
1461                }
1462            };
1463
1464            if memories.is_empty() {
1465                info!("Memory store is empty - no memories to inject");
1466            } else {
1467                let mut memories_content = format_memories_as_markdown(&memories);
1468
1469                if memories_config.budget > 0 {
1470                    let original_len = memories_content.len();
1471                    memories_content =
1472                        truncate_to_budget(&memories_content, memories_config.budget);
1473                    debug!(
1474                        "Applied budget: {} chars -> {} chars (budget: {})",
1475                        original_len,
1476                        memories_content.len(),
1477                        memories_config.budget
1478                    );
1479                }
1480
1481                info!(
1482                    "Injecting {} memories ({} chars) into prompt",
1483                    memories.len(),
1484                    memories_content.len()
1485                );
1486
1487                prefix.push_str(&memories_content);
1488            }
1489        }
1490
1491        // Inject ralph-tools skills conditionally based on config
1492        let tasks_enabled = self.config.tasks.enabled;
1493
1494        // Base skill (shared commands) when either memories or tasks are enabled
1495        if (memories_config.enabled || tasks_enabled)
1496            && let Some(skill) = self.skill_registry.get("ralph-tools")
1497        {
1498            if !prefix.is_empty() {
1499                prefix.push_str("\n\n");
1500            }
1501            prefix.push_str(&format!(
1502                "<ralph-tools-skill>\n{}\n</ralph-tools-skill>",
1503                skill.content.trim()
1504            ));
1505            debug!("Injected ralph-tools skill from registry");
1506        }
1507
1508        // Tasks skill — only when tasks are enabled
1509        if tasks_enabled && let Some(skill) = self.skill_registry.get("ralph-tools-tasks") {
1510            if !prefix.is_empty() {
1511                prefix.push_str("\n\n");
1512            }
1513            prefix.push_str(&format!(
1514                "<ralph-tools-tasks-skill>\n{}\n</ralph-tools-tasks-skill>",
1515                skill.content.trim()
1516            ));
1517            debug!("Injected ralph-tools-tasks skill from registry");
1518        }
1519
1520        // Memories skill — only when memories are enabled
1521        if memories_config.enabled
1522            && let Some(skill) = self.skill_registry.get("ralph-tools-memories")
1523        {
1524            if !prefix.is_empty() {
1525                prefix.push_str("\n\n");
1526            }
1527            prefix.push_str(&format!(
1528                "<ralph-tools-memories-skill>\n{}\n</ralph-tools-memories-skill>",
1529                skill.content.trim()
1530            ));
1531            debug!("Injected ralph-tools-memories skill from registry");
1532        }
1533    }
1534
1535    /// Injects the RObot interaction skill content into the prefix.
1536    ///
1537    /// Gated by `robot.enabled`. Teaches agents how and when to interact
1538    /// with humans via `human.interact` events.
1539    fn inject_robot_skill(&self, prefix: &mut String) {
1540        if !self.config.robot.enabled {
1541            return;
1542        }
1543
1544        if let Some(skill) = self.skill_registry.get("robot-interaction") {
1545            if !prefix.is_empty() {
1546                prefix.push_str("\n\n");
1547            }
1548            prefix.push_str(&format!(
1549                "<robot-skill>\n{}\n</robot-skill>",
1550                skill.content.trim()
1551            ));
1552            debug!("Injected robot interaction skill from registry");
1553        }
1554    }
1555
1556    /// Injects any user-configured auto-inject skills (excluding built-in skills handled separately).
1557    fn inject_custom_auto_skills(&self, prefix: &mut String) {
1558        for skill in self.skill_registry.auto_inject_skills(None) {
1559            // Skip built-in skills handled above
1560            if matches!(
1561                skill.name.as_str(),
1562                "ralph-tools" | "ralph-tools-tasks" | "ralph-tools-memories" | "robot-interaction"
1563            ) {
1564                continue;
1565            }
1566
1567            if !prefix.is_empty() {
1568                prefix.push_str("\n\n");
1569            }
1570            prefix.push_str(&format!(
1571                "<{name}-skill>\n{content}\n</{name}-skill>",
1572                name = skill.name,
1573                content = skill.content.trim()
1574            ));
1575            debug!("Injected auto-inject skill: {}", skill.name);
1576        }
1577    }
1578
1579    /// Prepends scratchpad content to the prompt if the file exists and is non-empty.
1580    ///
1581    /// The scratchpad is the agent's working memory for the current objective.
1582    /// Auto-injecting saves one tool call per iteration.
1583    /// When the file exceeds the budget, the TAIL is kept (most recent entries).
1584    fn prepend_scratchpad(&self, prompt: String) -> String {
1585        // Skip injection when scratchpad is disabled for the current hat
1586        if !self.ralph.active_scratchpad().enabled {
1587            return prompt;
1588        }
1589
1590        let scratchpad_path = self.scratchpad_path();
1591
1592        let resolved_path = if scratchpad_path.is_relative() {
1593            self.config.core.workspace_root.join(&scratchpad_path)
1594        } else {
1595            scratchpad_path
1596        };
1597
1598        if !resolved_path.exists() {
1599            debug!(
1600                "Scratchpad not found at {:?}, skipping injection",
1601                resolved_path
1602            );
1603            return prompt;
1604        }
1605
1606        let content = match std::fs::read_to_string(&resolved_path) {
1607            Ok(c) => c,
1608            Err(e) => {
1609                info!("Failed to read scratchpad for injection: {}", e);
1610                return prompt;
1611            }
1612        };
1613
1614        if content.trim().is_empty() {
1615            debug!("Scratchpad is empty, skipping injection");
1616            return prompt;
1617        }
1618
1619        // Budget: 4000 tokens ~16000 chars. Keep the TAIL (most recent content).
1620        let char_budget = 4000 * 4;
1621        let content = if content.len() > char_budget {
1622            // Find a line boundary near the start of the tail
1623            let start = content.len() - char_budget;
1624            // Ensure we start at a valid UTF-8 character boundary
1625            let start = floor_char_boundary(&content, start);
1626            let line_start = content[start..].find('\n').map_or(start, |n| start + n + 1);
1627            let discarded = &content[..line_start];
1628
1629            // Summarize discarded content by extracting markdown headings
1630            let headings: Vec<&str> = discarded
1631                .lines()
1632                .filter(|line| line.starts_with('#'))
1633                .collect();
1634            let summary = if headings.is_empty() {
1635                format!(
1636                    "<!-- earlier content truncated ({} chars omitted) -->",
1637                    line_start
1638                )
1639            } else {
1640                format!(
1641                    "<!-- earlier content truncated ({} chars omitted) -->\n\
1642                     <!-- discarded sections: {} -->",
1643                    line_start,
1644                    headings.join(" | ")
1645                )
1646            };
1647
1648            format!("{}\n\n{}", summary, &content[line_start..])
1649        } else {
1650            content
1651        };
1652
1653        info!("Injecting scratchpad ({} chars) into prompt", content.len());
1654
1655        let mut final_prompt = format!(
1656            "<scratchpad path=\"{}\">\n{}\n</scratchpad>\n\n",
1657            self.ralph.active_scratchpad().path,
1658            content
1659        );
1660        final_prompt.push_str(&prompt);
1661        final_prompt
1662    }
1663
1664    /// Prepends ready tasks to the prompt if tasks are enabled and any exist.
1665    ///
1666    /// Loads the task store and formats ready (unblocked, open) tasks into
1667    /// a `<ready-tasks>` XML block. This saves the agent a tool call per
1668    /// iteration and puts tasks at the same prominence as the scratchpad.
1669    fn prepend_ready_tasks(&self, prompt: String) -> String {
1670        if !self.config.tasks.enabled {
1671            return prompt;
1672        }
1673
1674        use crate::task::TaskStatus;
1675        use crate::task_store::TaskStore;
1676
1677        let tasks_path = self.tasks_path();
1678        let resolved_path = if tasks_path.is_relative() {
1679            self.config.core.workspace_root.join(&tasks_path)
1680        } else {
1681            tasks_path
1682        };
1683
1684        if !resolved_path.exists() {
1685            return prompt;
1686        }
1687
1688        let store = match TaskStore::load(&resolved_path) {
1689            Ok(s) => s,
1690            Err(e) => {
1691                info!("Failed to load task store for injection: {}", e);
1692                return prompt;
1693            }
1694        };
1695
1696        let current_loop_id = self.current_loop_id();
1697
1698        let ready = Self::filter_tasks_by_loop(store.ready(), current_loop_id.as_deref());
1699        let open = Self::filter_tasks_by_loop(store.open(), current_loop_id.as_deref());
1700        let all_count =
1701            Self::filter_tasks_by_loop(store.all().iter().collect(), current_loop_id.as_deref())
1702                .len();
1703        let closed_count = all_count - open.len();
1704
1705        if open.is_empty() && closed_count == 0 {
1706            return prompt;
1707        }
1708
1709        let mut section = String::from("<ready-tasks>\n");
1710        if ready.is_empty() && open.is_empty() {
1711            section.push_str("No open tasks. Create tasks with `ralph tools task add`.\n");
1712        } else {
1713            section.push_str(&format!(
1714                "## Tasks: {} ready, {} open, {} closed\n\n",
1715                ready.len(),
1716                open.len(),
1717                closed_count
1718            ));
1719            for task in &ready {
1720                let status_icon = match task.status {
1721                    TaskStatus::Open => "[ ]",
1722                    TaskStatus::InProgress => "[~]",
1723                    _ => "[?]",
1724                };
1725                section.push_str(&format!(
1726                    "- {} [P{}] {} ({}){}\n",
1727                    status_icon,
1728                    task.priority,
1729                    task.title,
1730                    task.id,
1731                    task.key
1732                        .as_deref()
1733                        .map(|key| format!(" — key: {key}"))
1734                        .unwrap_or_default()
1735                ));
1736            }
1737            // Show blocked tasks separately so agent knows they exist
1738            let ready_ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
1739            let blocked: Vec<_> = open
1740                .iter()
1741                .filter(|t| !ready_ids.contains(&t.id.as_str()))
1742                .collect();
1743            if !blocked.is_empty() {
1744                section.push_str("\nBlocked:\n");
1745                for task in blocked {
1746                    section.push_str(&format!(
1747                        "- [blocked] [P{}] {} ({}){} — blocked by: {}\n",
1748                        task.priority,
1749                        task.title,
1750                        task.id,
1751                        task.key
1752                            .as_deref()
1753                            .map(|key| format!(" — key: {key}"))
1754                            .unwrap_or_default(),
1755                        task.blocked_by.join(", ")
1756                    ));
1757                }
1758            }
1759        }
1760        section.push_str("</ready-tasks>\n\n");
1761
1762        info!(
1763            "Injecting ready tasks ({} ready, {} open, {} closed) into prompt",
1764            ready.len(),
1765            open.len(),
1766            closed_count
1767        );
1768
1769        let mut final_prompt = section;
1770        final_prompt.push_str(&prompt);
1771        final_prompt
1772    }
1773
1774    /// Builds the Ralph prompt (coordination mode).
1775    pub fn build_ralph_prompt(&self, prompt_content: &str) -> String {
1776        self.ralph.build_prompt(prompt_content, &[])
1777    }
1778
1779    /// Determines which hats should be active based on pending events.
1780    /// Returns list of Hat references that are triggered by any pending event.
1781    fn determine_active_hats(&self, events: &[Event]) -> Vec<&Hat> {
1782        let mut active_hats = Vec::new();
1783        for id in self.determine_active_hat_ids(events) {
1784            if let Some(hat) = self.registry.get(&id) {
1785                active_hats.push(hat);
1786            }
1787        }
1788        active_hats
1789    }
1790
1791    fn determine_active_hat_ids(&self, events: &[Event]) -> Vec<HatId> {
1792        let mut entrypoint_hat_ids = Vec::new();
1793        let mut progressed_hat_ids = Vec::new();
1794        for event in events {
1795            // Prefer direct event target over topic-based lookup
1796            let hat_id = if let Some(target) = &event.target
1797                && self.registry.get(target).is_some()
1798            {
1799                target.clone()
1800            } else if let Some(hat) = self.registry.get_for_topic(event.topic.as_str()) {
1801                hat.id.clone()
1802            } else {
1803                continue;
1804            };
1805
1806            let list = if self.is_entrypoint_topic(event.topic.as_str()) {
1807                &mut entrypoint_hat_ids
1808            } else {
1809                &mut progressed_hat_ids
1810            };
1811            if !list.iter().any(|id| id == &hat_id) {
1812                list.push(hat_id);
1813            }
1814        }
1815        // Prefer progressed hats over entrypoint hats. Entrypoint events
1816        // (starting_event, task.start, task.resume) linger in the bus after
1817        // the first hat runs. Including them would re-activate the first hat
1818        // alongside downstream hats, confusing the agent with multiple hat
1819        // instructions when only the downstream hat should run.
1820        if progressed_hat_ids.is_empty() {
1821            entrypoint_hat_ids
1822        } else {
1823            progressed_hat_ids
1824        }
1825    }
1826
1827    fn effective_regular_events<'a>(&self, events: &'a [Event]) -> Vec<&'a Event> {
1828        let has_downstream_event = events
1829            .iter()
1830            .any(|event| !Self::is_kickoff_or_recovery_event(event.topic.as_str()));
1831        events
1832            .iter()
1833            .filter(|event| {
1834                !has_downstream_event || !Self::is_kickoff_or_recovery_event(event.topic.as_str())
1835            })
1836            .collect()
1837    }
1838
1839    fn is_kickoff_or_recovery_event(topic: &str) -> bool {
1840        topic == "task.start" || topic == "task.resume" || topic.strip_suffix(".start").is_some()
1841    }
1842
1843    fn is_entrypoint_topic(&self, topic: &str) -> bool {
1844        topic == "task.start"
1845            || topic == "task.resume"
1846            || topic.strip_suffix(".start").is_some()
1847            || self.config.event_loop.starting_event.as_deref() == Some(topic)
1848    }
1849
1850    fn peek_pending_regular_events(&self) -> Vec<Event> {
1851        let mut events = Vec::new();
1852        for hat_id in self.bus.hat_ids() {
1853            if let Some(pending) = self.bus.peek_pending(hat_id) {
1854                events.extend(pending.iter().cloned());
1855            }
1856        }
1857        events
1858    }
1859
1860    /// Formats an event for prompt context.
1861    ///
1862    /// For top-level prompts (task.start, task.resume), wraps the payload in
1863    /// `<top-level-prompt>` XML tags to clearly delineate the user's original request.
1864    fn format_event(event: &Event) -> String {
1865        let topic = &event.topic;
1866        let payload = &event.payload;
1867
1868        if topic.as_str() == "task.start" || topic.as_str() == "task.resume" {
1869            format!(
1870                "Event: {} - <top-level-prompt>\n{}\n</top-level-prompt>",
1871                topic, payload
1872            )
1873        } else {
1874            format!("Event: {} - {}", topic, payload)
1875        }
1876    }
1877
1878    fn check_hat_exhaustion(&mut self, hat_id: &HatId, dropped: &[Event]) -> (bool, Option<Event>) {
1879        let Some(config) = self.registry.get_config(hat_id) else {
1880            return (false, None);
1881        };
1882        let Some(max) = config.max_activations else {
1883            return (false, None);
1884        };
1885
1886        let count = *self.state.hat_activation_counts.get(hat_id).unwrap_or(&0);
1887        if count < max {
1888            return (false, None);
1889        }
1890
1891        // Emit only once per hat per run (avoid flooding).
1892        let should_emit = self.state.exhausted_hats.insert(hat_id.clone());
1893
1894        if !should_emit {
1895            // Hat is already exhausted - drop pending events silently.
1896            return (true, None);
1897        }
1898
1899        let mut dropped_topics: Vec<String> = dropped.iter().map(|e| e.topic.to_string()).collect();
1900        dropped_topics.sort();
1901
1902        let payload = format!(
1903            "Hat '{hat}' exhausted.\n- max_activations: {max}\n- activations: {count}\n- dropped_topics:\n  - {topics}",
1904            hat = hat_id.as_str(),
1905            max = max,
1906            count = count,
1907            topics = dropped_topics.join("\n  - ")
1908        );
1909
1910        warn!(
1911            hat = %hat_id.as_str(),
1912            max_activations = max,
1913            activations = count,
1914            "Hat exhausted (max_activations reached)"
1915        );
1916
1917        (
1918            true,
1919            Some(Event::new(
1920                format!("{}.exhausted", hat_id.as_str()),
1921                payload,
1922            )),
1923        )
1924    }
1925
1926    fn record_hat_activations(&mut self, active_hat_ids: &[HatId]) {
1927        for hat_id in active_hat_ids {
1928            *self
1929                .state
1930                .hat_activation_counts
1931                .entry(hat_id.clone())
1932                .or_insert(0) += 1;
1933        }
1934    }
1935
1936    /// Returns the primary active hat ID for display purposes.
1937    /// Returns the first active hat, or "ralph" if no specific hat is active.
1938    /// BTreeMap iteration is already sorted by key.
1939    pub fn get_active_hat_id(&self) -> HatId {
1940        let pending_events = self.peek_pending_regular_events();
1941        if let Some(active_hat_id) = self
1942            .determine_active_hat_ids(&pending_events)
1943            .into_iter()
1944            .next()
1945        {
1946            return active_hat_id;
1947        }
1948        HatId::new("ralph")
1949    }
1950
1951    /// Injects a default event for a hat when the agent wrote no events.
1952    ///
1953    /// Call this after `process_events_from_jsonl` returns `Ok(false)` (no events found).
1954    /// If the hat has `default_publishes` configured, this injects the default event.
1955    ///
1956    /// If the default topic matches the completion promise, `completion_requested` is set
1957    /// so the loop can terminate. Without this, completion events injected via
1958    /// `default_publishes` would only be published to the bus (triggering downstream hats)
1959    /// but never detected by `check_completion_event`, causing an infinite loop.
1960    pub fn check_default_publishes(&mut self, hat_id: &HatId) {
1961        if let Some(config) = self.registry.get_config(hat_id)
1962            && let Some(default_topic) = &config.default_publishes
1963        {
1964            if default_topic.as_str() == self.config.event_loop.completion_promise {
1965                warn!(
1966                    hat = %hat_id.as_str(),
1967                    topic = %default_topic,
1968                    "default_publishes matches completion_promise — requiring explicit agent event"
1969                );
1970                let resume_event = Event::new(
1971                    "task.resume",
1972                    format!(
1973                        "Recovery: hat `{}` produced no events, and its default_publishes is the completion promise `{}`. Ralph will not complete silently; emit explicit completion evidence or a valid non-terminal event.",
1974                        hat_id.as_str(),
1975                        default_topic
1976                    ),
1977                )
1978                .with_target(hat_id.clone());
1979                self.state.record_event(&resume_event);
1980                self.bus.publish(resume_event);
1981                return;
1982            }
1983
1984            let default_event = Event::new(default_topic.as_str(), "").with_source(hat_id.clone());
1985
1986            debug!(
1987                hat = %hat_id.as_str(),
1988                topic = %default_topic,
1989                "No events written by hat, injecting default_publishes event"
1990            );
1991
1992            self.state.record_event(&default_event);
1993            self.bus.publish(default_event);
1994        }
1995    }
1996
1997    /// Returns a mutable reference to the event bus for direct event publishing.
1998    ///
1999    /// This is primarily used for planning sessions to inject user responses
2000    /// as events into the orchestration loop.
2001    pub fn bus(&mut self) -> &mut EventBus {
2002        &mut self.bus
2003    }
2004
2005    /// Processes output from a hat execution.
2006    ///
2007    /// Returns the termination reason if the loop should stop.
2008    pub fn process_output(
2009        &mut self,
2010        hat_id: &HatId,
2011        output: &str,
2012        success: bool,
2013    ) -> Option<TerminationReason> {
2014        self.state.iteration += 1;
2015        self.state.last_hat = Some(hat_id.clone());
2016
2017        // Periodic robot check-in
2018        if let Some(interval_secs) = self.config.robot.checkin_interval_seconds
2019            && let Some(ref robot_service) = self.robot_service
2020        {
2021            let elapsed = self.state.elapsed();
2022            let interval = std::time::Duration::from_secs(interval_secs);
2023            let last = self
2024                .state
2025                .last_checkin_at
2026                .map(|t| t.elapsed())
2027                .unwrap_or(elapsed);
2028
2029            if last >= interval {
2030                let context = self.build_checkin_context(hat_id);
2031                match robot_service.send_checkin(self.state.iteration, elapsed, Some(&context)) {
2032                    Ok(_) => {
2033                        self.state.last_checkin_at = Some(std::time::Instant::now());
2034                        debug!(iteration = self.state.iteration, "Sent robot check-in");
2035                    }
2036                    Err(e) => {
2037                        warn!(error = %e, "Failed to send robot check-in");
2038                    }
2039                }
2040            }
2041        }
2042
2043        // Log iteration started
2044        self.diagnostics.log_orchestration(
2045            self.state.iteration,
2046            "loop",
2047            crate::diagnostics::OrchestrationEvent::IterationStarted,
2048        );
2049
2050        // Log hat selected
2051        self.diagnostics.log_orchestration(
2052            self.state.iteration,
2053            "loop",
2054            crate::diagnostics::OrchestrationEvent::HatSelected {
2055                hat: hat_id.to_string(),
2056                reason: "process_output".to_string(),
2057            },
2058        );
2059
2060        // Track failures
2061        if success {
2062            self.state.consecutive_failures = 0;
2063        } else {
2064            self.state.consecutive_failures += 1;
2065        }
2066
2067        let _ = output;
2068
2069        // File-modification audit: detect when a hat with disallowed Edit/Write tools
2070        // modified files. This is hard enforcement — emits a scope_violation event.
2071        self.audit_file_modifications(hat_id);
2072
2073        // Events are ONLY read from the JSONL file written by `ralph emit`.
2074        // This enforces tool use and prevents confabulation (agent claiming to emit without actually doing so).
2075        // See process_events_from_jsonl() for event processing.
2076
2077        // Check termination conditions
2078        self.check_termination()
2079    }
2080
2081    /// Audits file modifications after a hat iteration.
2082    ///
2083    /// If the hat has `Edit` or `Write` in its `disallowed_tools`, checks whether
2084    /// files were modified (via `git diff --stat HEAD`). If so, emits a
2085    /// `<hat_id>.scope_violation` event.
2086    fn audit_file_modifications(&mut self, hat_id: &HatId) {
2087        let config = match self.registry.get_config(hat_id) {
2088            Some(c) => c,
2089            None => return,
2090        };
2091
2092        let has_write_restriction = config
2093            .disallowed_tools
2094            .iter()
2095            .any(|t| t == "Edit" || t == "Write");
2096
2097        if !has_write_restriction {
2098            return;
2099        }
2100
2101        let workspace = &self.config.core.workspace_root;
2102        let diff_output = std::process::Command::new("git")
2103            .args(["diff", "--stat", "HEAD"])
2104            .current_dir(workspace)
2105            .output();
2106
2107        match diff_output {
2108            Ok(output) if !output.stdout.is_empty() => {
2109                let diff_stat = String::from_utf8_lossy(&output.stdout).trim().to_string();
2110                warn!(
2111                    hat = %hat_id.as_str(),
2112                    diff = %diff_stat,
2113                    "Hat modified files despite tool restrictions (scope violation)"
2114                );
2115
2116                let violation_topic = format!("{}.scope_violation", hat_id.as_str());
2117                let violation = Event::new(
2118                    violation_topic.as_str(),
2119                    format!(
2120                        "Hat '{}' modified files with Edit/Write disallowed:\n{}",
2121                        hat_id.as_str(),
2122                        diff_stat
2123                    ),
2124                );
2125                self.bus.publish(violation);
2126            }
2127            Err(e) => {
2128                debug!(error = %e, "Could not run git diff for file-modification audit");
2129            }
2130            _ => {} // No modifications — all good
2131        }
2132    }
2133
2134    /// Extracts task identifier from build.blocked payload.
2135    /// Uses first line of payload as task ID.
2136    fn extract_task_id(payload: &str) -> String {
2137        payload
2138            .lines()
2139            .next()
2140            .unwrap_or("unknown")
2141            .trim()
2142            .to_string()
2143    }
2144
2145    /// Adds cost to the cumulative total.
2146    pub fn add_cost(&mut self, cost: f64) {
2147        self.state.cumulative_cost += cost;
2148    }
2149
2150    /// Verifies all tasks in scratchpad are complete or cancelled.
2151    ///
2152    /// Returns:
2153    /// - `Ok(true)` if all tasks are `[x]` or `[~]`, or if scratchpad is disabled
2154    /// - `Ok(false)` if any tasks are `[ ]` (pending)
2155    /// - `Err(...)` if scratchpad doesn't exist or can't be read
2156    fn verify_scratchpad_complete(&self) -> Result<bool, std::io::Error> {
2157        // Nothing to verify when scratchpad is disabled
2158        if !self.ralph.active_scratchpad().enabled {
2159            return Ok(true);
2160        }
2161
2162        let scratchpad_path = self.scratchpad_path();
2163
2164        if !scratchpad_path.exists() {
2165            return Err(std::io::Error::new(
2166                std::io::ErrorKind::NotFound,
2167                "Scratchpad does not exist",
2168            ));
2169        }
2170
2171        let content = std::fs::read_to_string(scratchpad_path)?;
2172
2173        let has_pending = content
2174            .lines()
2175            .any(|line| line.trim_start().starts_with("- [ ]"));
2176
2177        Ok(!has_pending)
2178    }
2179
2180    /// Reads the current loop ID from the marker file.
2181    ///
2182    /// Returns `None` if no marker exists or is empty, which means
2183    /// task queries should be unfiltered (backwards compatible).
2184    fn current_loop_id(&self) -> Option<String> {
2185        self.loop_context
2186            .as_ref()
2187            .and_then(|ctx| {
2188                let marker_path = ctx.ralph_dir().join("current-loop-id");
2189                std::fs::read_to_string(&marker_path).ok()
2190            })
2191            .map(|id| id.trim().to_string())
2192            .filter(|id| !id.is_empty())
2193    }
2194
2195    /// Filters a task list by loop ID. When `loop_id` is `None`, returns all tasks.
2196    fn filter_tasks_by_loop<'a>(
2197        tasks: Vec<&'a crate::task::Task>,
2198        loop_id: Option<&str>,
2199    ) -> Vec<&'a crate::task::Task> {
2200        match loop_id {
2201            Some(id) => tasks
2202                .into_iter()
2203                .filter(|t| t.loop_id.as_deref() == Some(id))
2204                .collect(),
2205            None => tasks,
2206        }
2207    }
2208
2209    fn verify_tasks_complete(&self) -> Result<bool, std::io::Error> {
2210        use crate::task_store::TaskStore;
2211
2212        let tasks_path = self.tasks_path();
2213
2214        // No tasks file = no pending tasks = complete
2215        if !tasks_path.exists() {
2216            return Ok(true);
2217        }
2218
2219        let store = TaskStore::load(&tasks_path)?;
2220        let current_loop_id = self.current_loop_id();
2221        let open = Self::filter_tasks_by_loop(store.open(), current_loop_id.as_deref());
2222        Ok(open.is_empty())
2223    }
2224
2225    /// Builds a [`CheckinContext`] with current loop state for robot check-ins.
2226    fn build_checkin_context(&self, hat_id: &HatId) -> CheckinContext {
2227        let (open_tasks, closed_tasks) = self.count_tasks();
2228        CheckinContext {
2229            current_hat: Some(hat_id.as_str().to_string()),
2230            open_tasks,
2231            closed_tasks,
2232            cumulative_cost: self.state.cumulative_cost,
2233        }
2234    }
2235
2236    /// Counts open and closed tasks from the task store.
2237    ///
2238    /// Returns `(open_count, closed_count)`. "Open" means non-terminal tasks,
2239    /// "closed" means tasks with `TaskStatus::Closed`.
2240    fn count_tasks(&self) -> (usize, usize) {
2241        use crate::task_store::TaskStore;
2242
2243        let tasks_path = self.tasks_path();
2244        if !tasks_path.exists() {
2245            return (0, 0);
2246        }
2247
2248        match TaskStore::load(&tasks_path) {
2249            Ok(store) => {
2250                let current_loop_id = self.current_loop_id();
2251                let all = Self::filter_tasks_by_loop(
2252                    store.all().iter().collect(),
2253                    current_loop_id.as_deref(),
2254                );
2255                let open = Self::filter_tasks_by_loop(store.open(), current_loop_id.as_deref());
2256                let closed = all.len() - open.len();
2257                (open.len(), closed)
2258            }
2259            Err(_) => (0, 0),
2260        }
2261    }
2262
2263    /// Returns a list of open task descriptions for logging purposes.
2264    fn get_open_task_list(&self) -> Vec<String> {
2265        use crate::task_store::TaskStore;
2266
2267        let tasks_path = self.tasks_path();
2268        if let Ok(store) = TaskStore::load(&tasks_path) {
2269            let current_loop_id = self.current_loop_id();
2270            let open = Self::filter_tasks_by_loop(store.open(), current_loop_id.as_deref());
2271            return open
2272                .iter()
2273                .map(|t| format!("{}: {}", t.id, t.title))
2274                .collect();
2275        }
2276        vec![]
2277    }
2278
2279    fn warn_on_mutation_evidence(&self, evidence: &crate::event_parser::BackpressureEvidence) {
2280        let threshold = self.config.event_loop.mutation_score_warn_threshold;
2281
2282        match &evidence.mutants {
2283            Some(mutants) => {
2284                if let Some(reason) = Self::mutation_warning_reason(mutants, threshold) {
2285                    warn!(
2286                        reason = %reason,
2287                        mutants_status = ?mutants.status,
2288                        mutants_score = mutants.score_percent,
2289                        mutants_threshold = threshold,
2290                        "Mutation testing warning"
2291                    );
2292                }
2293            }
2294            None => {
2295                if let Some(threshold) = threshold {
2296                    warn!(
2297                        mutants_threshold = threshold,
2298                        "Mutation testing warning: missing mutation evidence in build.done payload"
2299                    );
2300                }
2301            }
2302        }
2303    }
2304
2305    fn mutation_warning_reason(
2306        mutants: &MutationEvidence,
2307        threshold: Option<f64>,
2308    ) -> Option<String> {
2309        match mutants.status {
2310            MutationStatus::Fail => Some("mutation testing failed".to_string()),
2311            MutationStatus::Warn => Some(Self::format_mutation_message(
2312                "mutation score below threshold",
2313                mutants.score_percent,
2314            )),
2315            MutationStatus::Unknown => Some("mutation testing status unknown".to_string()),
2316            MutationStatus::Pass => {
2317                let threshold = threshold?;
2318
2319                match mutants.score_percent {
2320                    Some(score) if score < threshold => Some(format!(
2321                        "mutation score {:.2}% below threshold {:.2}%",
2322                        score, threshold
2323                    )),
2324                    Some(_) => None,
2325                    None => Some(format!(
2326                        "mutation score missing (threshold {:.2}%)",
2327                        threshold
2328                    )),
2329                }
2330            }
2331        }
2332    }
2333
2334    fn format_mutation_message(message: &str, score: Option<f64>) -> String {
2335        match score {
2336            Some(score) => format!("{message} ({score:.2}%)"),
2337            None => message.to_string(),
2338        }
2339    }
2340
2341    fn parse_human_interact_context(payload: &str) -> Value {
2342        let mut context = match serde_json::from_str::<Value>(payload) {
2343            Ok(Value::Object(map)) => map,
2344            Ok(value) => {
2345                let mut map = Map::new();
2346                map.insert("question".to_string(), value);
2347                map
2348            }
2349            Err(_) => {
2350                let mut map = Map::new();
2351                map.insert("question".to_string(), Value::String(payload.to_string()));
2352                map
2353            }
2354        };
2355
2356        if !context.contains_key("question") {
2357            context.insert("question".to_string(), Value::String(payload.to_string()));
2358        }
2359
2360        Value::Object(context)
2361    }
2362
2363    fn is_restart_request_payload(payload: &str) -> bool {
2364        let payload = payload.to_ascii_lowercase();
2365        payload.contains("restart yourself") || payload.contains("restart ralph")
2366    }
2367
2368    fn is_restart_request_event(event: &Event) -> bool {
2369        matches!(event.topic.as_str(), "human.response" | "user.prompt")
2370            && Self::is_restart_request_payload(&event.payload)
2371    }
2372
2373    fn mark_restart_requested(&self, source: &str) {
2374        let restart_path =
2375            std::path::Path::new(&self.config.core.workspace_root).join(".ralph/restart-requested");
2376
2377        if let Some(parent) = restart_path.parent()
2378            && let Err(err) = std::fs::create_dir_all(parent)
2379        {
2380            warn!(
2381                error = %err,
2382                path = %parent.display(),
2383                "Failed to create restart-requested parent directory"
2384            );
2385            return;
2386        }
2387
2388        if let Err(err) = std::fs::write(&restart_path, source) {
2389            warn!(
2390                error = %err,
2391                path = %restart_path.display(),
2392                "Failed to write restart-requested signal"
2393            );
2394            return;
2395        }
2396
2397        info!(
2398            source,
2399            path = %restart_path.display(),
2400            "Restart requested from human text"
2401        );
2402    }
2403
2404    /// Processes events from JSONL and routes orphaned events to Ralph.
2405    ///
2406    /// Also handles backpressure for malformed JSONL lines by:
2407    /// 1. Emitting `event.malformed` system events for each parse failure
2408    /// 2. Tracking consecutive failures for termination check
2409    /// 3. Resetting counter when valid events are parsed
2410    ///
2411    /// Returns [`ProcessedEvents`] indicating whether events were found, whether
2412    /// semantic `plan.*` topics were published, structured `human.interact`
2413    /// context/outcome metadata, and whether any were orphans that Ralph should
2414    /// handle.
2415    pub fn process_events_from_jsonl(&mut self) -> std::io::Result<ProcessedEvents> {
2416        let result = self.event_reader.read_new_events()?;
2417        self.process_parse_result(result)
2418    }
2419
2420    /// Inner event processing that operates on an already-parsed `ParseResult`.
2421    ///
2422    /// This is the single source of truth for event validation, backpressure,
2423    /// scope enforcement, and bus publishing. Both `process_events_from_jsonl`
2424    /// and `process_events_from_jsonl_with_waves` delegate to this method.
2425    fn process_parse_result(
2426        &mut self,
2427        result: crate::event_reader::ParseResult,
2428    ) -> std::io::Result<ProcessedEvents> {
2429        // Handle malformed lines with backpressure
2430        for malformed in &result.malformed {
2431            let payload = format!(
2432                "Line {}: {}\nContent: {}",
2433                malformed.line_number, malformed.error, &malformed.content
2434            );
2435            let event = Event::new("event.malformed", &payload);
2436            self.bus.publish(event);
2437            self.state.consecutive_malformed_events += 1;
2438            warn!(
2439                line = malformed.line_number,
2440                consecutive = self.state.consecutive_malformed_events,
2441                "Malformed event line detected"
2442            );
2443        }
2444
2445        // Reset counter when valid events are parsed
2446        if !result.events.is_empty() {
2447            self.state.consecutive_malformed_events = 0;
2448        }
2449
2450        if result.events.is_empty() && result.malformed.is_empty() {
2451            return Ok(ProcessedEvents {
2452                had_events: false,
2453                had_plan_events: false,
2454                human_interact_context: None,
2455                has_orphans: false,
2456            });
2457        }
2458
2459        // Drop orchestrator-synthetic observer rows (spec §10) before scope
2460        // enforcement so Ralph's own summary telemetry cannot be mistaken for
2461        // an out-of-scope agent-authored event.
2462        let events: Vec<_> = result
2463            .events
2464            .into_iter()
2465            .filter(|event| event.topic.as_str() != "iteration.summary")
2466            .collect();
2467
2468        // --- Scope enforcement: filter events against active hat's publishes ---
2469        // Only active when enforce_hat_scope is true in config (opt-in).
2470        let events = if self.config.event_loop.enforce_hat_scope {
2471            let active_hats = self.state.last_active_hat_ids.clone();
2472            let (in_scope, out_of_scope): (Vec<_>, Vec<_>) =
2473                events.into_iter().partition(|event| {
2474                    if active_hats.is_empty() {
2475                        return true; // Ralph coordinating — no scope restriction
2476                    }
2477                    active_hats
2478                        .iter()
2479                        .any(|hat_id| self.registry.can_publish(hat_id, event.topic.as_str()))
2480                });
2481
2482            for event in &out_of_scope {
2483                let violation_hat = active_hats.first().map(|h| h.as_str()).unwrap_or("unknown");
2484                warn!(
2485                    active_hats = ?active_hats,
2486                    topic = %event.topic,
2487                    "Scope violation: active hat(s) cannot publish this topic — dropping event"
2488                );
2489                let violation_topic = format!("{}.scope_violation", violation_hat);
2490                let violation_payload = format!(
2491                    "Attempted to publish '{}': {}",
2492                    event.topic,
2493                    event.payload.clone().unwrap_or_default()
2494                );
2495                let violation = Event::new(violation_topic, violation_payload);
2496                self.bus.publish(violation);
2497            }
2498
2499            in_scope
2500        } else {
2501            events
2502        };
2503        // --- End scope enforcement ---
2504
2505        let mut has_orphans = false;
2506
2507        // Validate and transform events (apply backpressure for build.done)
2508        let mut validated_events = Vec::new();
2509        let completion_topic = self.config.event_loop.completion_promise.clone();
2510        let cancellation_topic = self.config.event_loop.cancellation_promise.clone();
2511        let total_events = events.len();
2512        for (index, event) in events.into_iter().enumerate() {
2513            let payload = event.payload.clone().unwrap_or_default();
2514
2515            // Detect loop.cancel — unconditional graceful termination
2516            if !cancellation_topic.is_empty() && event.topic.as_str() == cancellation_topic {
2517                info!(
2518                    payload = %payload,
2519                    "loop.cancel event detected — scheduling graceful termination"
2520                );
2521                self.state.cancellation_requested = true;
2522                // Continue processing remaining events (they may contain cleanup info)
2523                continue;
2524            }
2525
2526            if event.topic.as_str() == "human.guidance" {
2527                self.state.unacknowledged_guidance.push(payload.clone());
2528                validated_events.push(Event::new(event.topic.as_str(), &payload));
2529                continue;
2530            }
2531
2532            if event.topic.as_str() == "human.guidance.ack" {
2533                info!(
2534                    payload = %payload,
2535                    guidance_count = self.state.unacknowledged_guidance.len(),
2536                    "human.guidance acknowledged"
2537                );
2538                self.state.unacknowledged_guidance.clear();
2539                validated_events.push(Event::new(event.topic.as_str(), &payload));
2540                continue;
2541            }
2542
2543            if event.topic.as_str() == completion_topic {
2544                if index + 1 == total_events {
2545                    self.state.completion_requested = true;
2546                    self.diagnostics.log_orchestration(
2547                        self.state.iteration,
2548                        "jsonl",
2549                        crate::diagnostics::OrchestrationEvent::EventPublished {
2550                            topic: event.topic.clone(),
2551                        },
2552                    );
2553                    info!(
2554                        topic = %event.topic,
2555                        "Completion event detected in JSONL"
2556                    );
2557                } else {
2558                    warn!(
2559                        topic = %event.topic,
2560                        index = index,
2561                        total_events = total_events,
2562                        "Completion event ignored because it was not the last event"
2563                    );
2564                }
2565                continue;
2566            }
2567
2568            if event.topic == "build.done" {
2569                // Validate build.done events have backpressure evidence
2570                if let Some(evidence) = EventParser::parse_backpressure_evidence(&payload) {
2571                    if evidence.all_passed() {
2572                        self.warn_on_mutation_evidence(&evidence);
2573                        validated_events.push(Event::new(event.topic.as_str(), &payload));
2574                    } else {
2575                        // Evidence present but checks failed - synthesize build.blocked
2576                        warn!(
2577                            tests = evidence.tests_passed,
2578                            lint = evidence.lint_passed,
2579                            typecheck = evidence.typecheck_passed,
2580                            audit = evidence.audit_passed,
2581                            coverage = evidence.coverage_passed,
2582                            complexity = evidence.complexity_score,
2583                            duplication = evidence.duplication_passed,
2584                            performance = evidence.performance_regression,
2585                            specs = evidence.specs_verified,
2586                            "build.done rejected: backpressure checks failed"
2587                        );
2588
2589                        let complexity = evidence
2590                            .complexity_score
2591                            .map(|value| format!("{value:.2}"))
2592                            .unwrap_or_else(|| "missing".to_string());
2593                        let performance = match evidence.performance_regression {
2594                            Some(true) => "regression".to_string(),
2595                            Some(false) => "pass".to_string(),
2596                            None => "missing".to_string(),
2597                        };
2598                        let specs = match evidence.specs_verified {
2599                            Some(true) => "pass".to_string(),
2600                            Some(false) => "fail".to_string(),
2601                            None => "not reported".to_string(),
2602                        };
2603
2604                        self.diagnostics.log_orchestration(
2605                            self.state.iteration,
2606                            "jsonl",
2607                            crate::diagnostics::OrchestrationEvent::BackpressureTriggered {
2608                                reason: format!(
2609                                    "backpressure checks failed: tests={}, lint={}, typecheck={}, audit={}, coverage={}, complexity={}, duplication={}, performance={}, specs={}",
2610                                    evidence.tests_passed,
2611                                    evidence.lint_passed,
2612                                    evidence.typecheck_passed,
2613                                    evidence.audit_passed,
2614                                    evidence.coverage_passed,
2615                                    complexity,
2616                                    evidence.duplication_passed,
2617                                    performance,
2618                                    specs
2619                                ),
2620                            },
2621                        );
2622
2623                        validated_events.push(Event::new(
2624                            "build.blocked",
2625                            "Backpressure checks failed. Fix tests/lint/typecheck/audit/coverage/complexity/duplication/specs before emitting build.done.",
2626                        ));
2627                    }
2628                } else {
2629                    // No evidence found - synthesize build.blocked
2630                    warn!("build.done rejected: missing backpressure evidence");
2631
2632                    self.diagnostics.log_orchestration(
2633                        self.state.iteration,
2634                        "jsonl",
2635                        crate::diagnostics::OrchestrationEvent::BackpressureTriggered {
2636                            reason: "missing backpressure evidence".to_string(),
2637                        },
2638                    );
2639
2640                    validated_events.push(Event::new(
2641                        "build.blocked",
2642                        "Missing backpressure evidence. Include 'tests: pass', 'lint: pass', 'typecheck: pass', 'audit: pass', 'coverage: pass', 'complexity: <score>', 'duplication: pass', 'performance: pass' (optional), 'specs: pass' (optional) in build.done payload.",
2643                    ));
2644                }
2645            } else if event.topic == "review.done" && !event.is_wave_event() {
2646                // Validate review.done events have verification evidence.
2647                // Wave worker events skip this — wave reviews are read-only
2648                // and don't run tests/builds.
2649                if let Some(evidence) = EventParser::parse_review_evidence(&payload) {
2650                    if evidence.is_verified() {
2651                        validated_events.push(Event::new(event.topic.as_str(), &payload));
2652                    } else {
2653                        // Evidence present but checks failed - synthesize review.blocked
2654                        warn!(
2655                            tests = evidence.tests_passed,
2656                            build = evidence.build_passed,
2657                            "review.done rejected: verification checks failed"
2658                        );
2659
2660                        self.diagnostics.log_orchestration(
2661                            self.state.iteration,
2662                            "jsonl",
2663                            crate::diagnostics::OrchestrationEvent::BackpressureTriggered {
2664                                reason: format!(
2665                                    "review verification failed: tests={}, build={}",
2666                                    evidence.tests_passed, evidence.build_passed
2667                                ),
2668                            },
2669                        );
2670
2671                        validated_events.push(Event::new(
2672                            "review.blocked",
2673                            "Review verification failed. Run tests and build before emitting review.done.",
2674                        ));
2675                    }
2676                } else {
2677                    // No evidence found - synthesize review.blocked
2678                    warn!("review.done rejected: missing verification evidence");
2679
2680                    self.diagnostics.log_orchestration(
2681                        self.state.iteration,
2682                        "jsonl",
2683                        crate::diagnostics::OrchestrationEvent::BackpressureTriggered {
2684                            reason: "missing review verification evidence".to_string(),
2685                        },
2686                    );
2687
2688                    validated_events.push(Event::new(
2689                        "review.blocked",
2690                        "Missing verification evidence. Include 'tests: pass' and 'build: pass' in review.done payload.",
2691                    ));
2692                }
2693            } else if event.topic == "verify.passed" {
2694                if let Some(report) = EventParser::parse_quality_report(&payload) {
2695                    if report.meets_thresholds() {
2696                        validated_events.push(Event::new(event.topic.as_str(), &payload));
2697                    } else {
2698                        let failed = report.failed_dimensions();
2699                        let reason = if failed.is_empty() {
2700                            "quality thresholds failed".to_string()
2701                        } else {
2702                            format!("quality thresholds failed: {}", failed.join(", "))
2703                        };
2704
2705                        warn!(
2706                            failed_dimensions = ?failed,
2707                            "verify.passed rejected: quality thresholds failed"
2708                        );
2709
2710                        self.diagnostics.log_orchestration(
2711                            self.state.iteration,
2712                            "jsonl",
2713                            crate::diagnostics::OrchestrationEvent::BackpressureTriggered {
2714                                reason,
2715                            },
2716                        );
2717
2718                        validated_events.push(Event::new(
2719                            "verify.failed",
2720                            "Quality thresholds failed. Include quality.tests, quality.coverage, quality.lint, quality.audit, quality.mutation, quality.complexity with thresholds in verify.passed payload.",
2721                        ));
2722                    }
2723                } else {
2724                    // No quality report found - synthesize verify.failed
2725                    warn!("verify.passed rejected: missing quality report");
2726
2727                    self.diagnostics.log_orchestration(
2728                        self.state.iteration,
2729                        "jsonl",
2730                        crate::diagnostics::OrchestrationEvent::BackpressureTriggered {
2731                            reason: "missing quality report".to_string(),
2732                        },
2733                    );
2734
2735                    validated_events.push(Event::new(
2736                        "verify.failed",
2737                        "Missing quality report. Include quality.tests, quality.coverage, quality.lint, quality.audit, quality.mutation, quality.complexity in verify.passed payload.",
2738                    ));
2739                }
2740            } else if event.topic == "verify.failed" {
2741                if EventParser::parse_quality_report(&payload).is_none() {
2742                    warn!("verify.failed missing quality report");
2743                }
2744                validated_events.push(Event::new(event.topic.as_str(), &payload));
2745            } else {
2746                // Non-backpressure events pass through unchanged
2747                validated_events.push(Event::new(event.topic.as_str(), &payload));
2748            }
2749        }
2750
2751        // Track build.blocked events for thrashing detection
2752        let blocked_events: Vec<_> = validated_events
2753            .iter()
2754            .filter(|e| e.topic == "build.blocked".into())
2755            .collect();
2756
2757        for blocked_event in &blocked_events {
2758            let task_id = Self::extract_task_id(&blocked_event.payload);
2759
2760            let count = self
2761                .state
2762                .task_block_counts
2763                .entry(task_id.clone())
2764                .or_insert(0);
2765            *count += 1;
2766
2767            debug!(
2768                task_id = %task_id,
2769                block_count = *count,
2770                "Task blocked"
2771            );
2772
2773            // After 3 blocks on same task, emit build.task.abandoned
2774            if *count >= 3 && !self.state.abandoned_tasks.contains(&task_id) {
2775                warn!(
2776                    task_id = %task_id,
2777                    "Task abandoned after 3 consecutive blocks"
2778                );
2779
2780                self.state.abandoned_tasks.push(task_id.clone());
2781
2782                self.diagnostics.log_orchestration(
2783                    self.state.iteration,
2784                    "jsonl",
2785                    crate::diagnostics::OrchestrationEvent::TaskAbandoned {
2786                        reason: format!(
2787                            "3 consecutive build.blocked events for task '{}'",
2788                            task_id
2789                        ),
2790                    },
2791                );
2792
2793                let abandoned_event = Event::new(
2794                    "build.task.abandoned",
2795                    format!(
2796                        "Task '{}' abandoned after 3 consecutive build.blocked events",
2797                        task_id
2798                    ),
2799                );
2800
2801                self.bus.publish(abandoned_event);
2802            }
2803        }
2804
2805        // Track hat-level blocking for legacy thrashing detection
2806        let has_blocked_event = !blocked_events.is_empty();
2807
2808        if has_blocked_event {
2809            self.state.consecutive_blocked += 1;
2810        } else {
2811            self.state.consecutive_blocked = 0;
2812            self.state.last_blocked_hat = None;
2813        }
2814
2815        // Handle human.interact blocking behavior:
2816        // When a human.interact event is detected and robot service is active,
2817        // send the question and block until human.response or timeout.
2818        let mut response_event = None;
2819        let mut human_interact_context = None;
2820        let mut events_arrived_during_robot_wait = Vec::new();
2821        let ask_human_idx = validated_events
2822            .iter()
2823            .position(|e| e.topic == "human.interact".into());
2824
2825        if let Some(idx) = ask_human_idx {
2826            let ask_event = &validated_events[idx];
2827            let payload = ask_event.payload.clone();
2828
2829            let mut context = match Self::parse_human_interact_context(&payload) {
2830                Value::Object(map) => map,
2831                _ => Map::new(),
2832            };
2833            context
2834                .entry("iteration".to_string())
2835                .or_insert_with(|| Value::from(self.state.iteration));
2836            context.entry("hat".to_string()).or_insert_with(|| {
2837                ask_event
2838                    .source
2839                    .as_ref()
2840                    .or(ask_event.target.as_ref())
2841                    .map(|hat| Value::String(hat.as_str().to_string()))
2842                    .unwrap_or_else(|| Value::String(self.get_active_hat_id().as_str().to_string()))
2843            });
2844
2845            if let Some(ref robot_service) = self.robot_service {
2846                let robot_payload = Value::Object(context.clone()).to_string();
2847                info!(
2848                    payload = %payload,
2849                    "human.interact event detected — sending question via robot service"
2850                );
2851
2852                // Send the question (includes retry with exponential backoff)
2853                let send_ok = match robot_service.send_question(&robot_payload) {
2854                    Ok(_message_id) => true,
2855                    Err(e) => {
2856                        warn!(
2857                            error = %e,
2858                            "Failed to send human.interact question after retries — treating as timeout"
2859                        );
2860                        // Log to diagnostics
2861                        self.diagnostics.log_error(
2862                            self.state.iteration,
2863                            "telegram",
2864                            crate::diagnostics::DiagnosticError::TelegramSendError {
2865                                operation: "send_question".to_string(),
2866                                error: e.to_string(),
2867                                retry_count: 3,
2868                            },
2869                        );
2870                        context.insert(
2871                            "outcome".to_string(),
2872                            Value::String("send_failure".to_string()),
2873                        );
2874                        context.insert("error".to_string(), Value::String(e.to_string()));
2875                        false
2876                    }
2877                };
2878
2879                // Block: poll events file for human.response
2880                // Per spec, even on send failure we treat as timeout (continue without blocking)
2881                if send_ok {
2882                    // Read the active events path from the current-events marker,
2883                    // falling back to the default events.jsonl if not available.
2884                    let events_path = self
2885                        .loop_context
2886                        .as_ref()
2887                        .and_then(|ctx| {
2888                            std::fs::read_to_string(ctx.current_events_marker())
2889                                .ok()
2890                                .map(|s| ctx.workspace().join(s.trim()))
2891                        })
2892                        .or_else(|| {
2893                            std::fs::read_to_string(".ralph/current-events")
2894                                .ok()
2895                                .map(|s| PathBuf::from(s.trim()))
2896                        })
2897                        .unwrap_or_else(|| {
2898                            self.loop_context
2899                                .as_ref()
2900                                .map(|ctx| ctx.events_path())
2901                                .unwrap_or_else(|| self.event_reader.path().to_path_buf())
2902                        });
2903
2904                    match robot_service.wait_for_response(&events_path) {
2905                        Ok(Some(response)) => {
2906                            info!(
2907                                response = %response,
2908                                "Received human.response — continuing loop"
2909                            );
2910                            context.insert(
2911                                "outcome".to_string(),
2912                                Value::String("response".to_string()),
2913                            );
2914                            context.insert("response".to_string(), Value::String(response.clone()));
2915                            let event = Event::new("human.response", &response);
2916                            if robot_service.response_events_are_durable() {
2917                                events_arrived_during_robot_wait.extend(
2918                                    self.skip_consumed_robot_response_event(
2919                                        &events_path,
2920                                        &response,
2921                                    ),
2922                                );
2923                            } else {
2924                                match self.append_robot_response_event(&events_path, &event) {
2925                                    Ok(events) => {
2926                                        events_arrived_during_robot_wait.extend(events);
2927                                    }
2928                                    Err(error) => {
2929                                        warn!(
2930                                            error = %error,
2931                                            path = %events_path.display(),
2932                                            "Failed to record human.response event"
2933                                        );
2934                                    }
2935                                }
2936                            }
2937                            response_event = Some(event);
2938                        }
2939                        Ok(None) => {
2940                            warn!(
2941                                timeout_secs = robot_service.timeout_secs(),
2942                                "Human response timeout — injecting human.timeout event"
2943                            );
2944                            context.insert(
2945                                "outcome".to_string(),
2946                                Value::String("timeout".to_string()),
2947                            );
2948                            context.insert(
2949                                "timeout_seconds".to_string(),
2950                                Value::from(robot_service.timeout_secs()),
2951                            );
2952                            let timeout_event = Event::new(
2953                                "human.timeout",
2954                                format!(
2955                                    "No response after {}s. Original question: {}",
2956                                    robot_service.timeout_secs(),
2957                                    payload
2958                                ),
2959                            );
2960                            response_event = Some(timeout_event);
2961                        }
2962                        Err(e) => {
2963                            warn!(
2964                                error = %e,
2965                                "Error waiting for human response — injecting human.timeout event"
2966                            );
2967                            context.insert(
2968                                "outcome".to_string(),
2969                                Value::String("wait_error".to_string()),
2970                            );
2971                            context.insert("error".to_string(), Value::String(e.to_string()));
2972                            let timeout_event = Event::new(
2973                                "human.timeout",
2974                                format!(
2975                                    "Error waiting for response: {}. Original question: {}",
2976                                    e, payload
2977                                ),
2978                            );
2979                            response_event = Some(timeout_event);
2980                        }
2981                    }
2982                }
2983            } else {
2984                debug!(
2985                    "human.interact event detected but no robot service active — passing through"
2986                );
2987                context.insert(
2988                    "outcome".to_string(),
2989                    Value::String("no_robot_service".to_string()),
2990                );
2991            }
2992
2993            human_interact_context = Some(Value::Object(context));
2994        }
2995
2996        let intervening_count = events_arrived_during_robot_wait.len();
2997        for (index, event) in events_arrived_during_robot_wait.into_iter().enumerate() {
2998            let payload = event.payload.clone();
2999
3000            if !cancellation_topic.is_empty() && event.topic.as_str() == cancellation_topic {
3001                info!(
3002                    payload = %payload,
3003                    "loop.cancel event detected during robot wait — scheduling graceful termination"
3004                );
3005                self.state.cancellation_requested = true;
3006                continue;
3007            }
3008
3009            if event.topic.as_str() == "human.guidance" {
3010                self.state.unacknowledged_guidance.push(payload);
3011                validated_events.push(event);
3012                continue;
3013            }
3014
3015            if event.topic.as_str() == "human.guidance.ack" {
3016                info!(
3017                    payload = %payload,
3018                    guidance_count = self.state.unacknowledged_guidance.len(),
3019                    "human.guidance acknowledged during robot wait"
3020                );
3021                self.state.unacknowledged_guidance.clear();
3022                validated_events.push(event);
3023                continue;
3024            }
3025
3026            if event.topic.as_str() == completion_topic {
3027                if index + 1 == intervening_count {
3028                    self.state.completion_requested = true;
3029                    self.diagnostics.log_orchestration(
3030                        self.state.iteration,
3031                        "jsonl",
3032                        crate::diagnostics::OrchestrationEvent::EventPublished {
3033                            topic: event.topic.to_string(),
3034                        },
3035                    );
3036                    info!(
3037                        topic = %event.topic,
3038                        "Completion event detected during robot wait"
3039                    );
3040                } else {
3041                    warn!(
3042                        topic = %event.topic,
3043                        index = index,
3044                        total_events = intervening_count,
3045                        "Completion event during robot wait ignored because it was not the last event"
3046                    );
3047                }
3048                continue;
3049            }
3050
3051            validated_events.push(event);
3052        }
3053
3054        let restart_requested = validated_events.iter().any(Self::is_restart_request_event)
3055            || response_event
3056                .as_ref()
3057                .is_some_and(Self::is_restart_request_event);
3058        if restart_requested {
3059            self.mark_restart_requested("human_text");
3060        }
3061
3062        // Track whether any events will be published (before the loop consumes them).
3063        let had_events = !validated_events.is_empty();
3064        let had_plan_events = validated_events
3065            .iter()
3066            .any(|event| event.topic.as_str().starts_with("plan."));
3067
3068        // Publish validated events to the bus.
3069        // Ralph is always registered with subscribe("*"), so every event has at least
3070        // one subscriber. Events without a specific hat subscriber are "orphaned" —
3071        // Ralph handles them as the universal fallback.
3072        for event in validated_events {
3073            // Record topic for event chain validation
3074            self.state.record_event(&event);
3075
3076            self.diagnostics.log_orchestration(
3077                self.state.iteration,
3078                "jsonl",
3079                crate::diagnostics::OrchestrationEvent::EventPublished {
3080                    topic: event.topic.to_string(),
3081                },
3082            );
3083
3084            if !self.registry.has_subscriber(event.topic.as_str()) {
3085                has_orphans = true;
3086            }
3087
3088            debug!(
3089                topic = %event.topic,
3090                "Publishing event from JSONL"
3091            );
3092            self.bus.publish(event);
3093        }
3094
3095        // Publish human.response event if one was received during blocking
3096        if let Some(response) = response_event {
3097            self.state.record_event(&response);
3098            info!(
3099                topic = %response.topic,
3100                "Publishing human.response event from robot service"
3101            );
3102            self.bus.publish(response);
3103        }
3104
3105        Ok(ProcessedEvents {
3106            had_events,
3107            had_plan_events,
3108            human_interact_context,
3109            has_orphans,
3110        })
3111    }
3112
3113    fn append_robot_response_event(
3114        &mut self,
3115        events_path: &Path,
3116        event: &Event,
3117    ) -> std::io::Result<Vec<Event>> {
3118        if let Some(parent) = events_path.parent() {
3119            std::fs::create_dir_all(parent)?;
3120        }
3121
3122        let reader_position = self.event_reader.position();
3123        let before_len = std::fs::metadata(events_path)
3124            .map(|metadata| metadata.len())
3125            .unwrap_or(0);
3126        let line = Self::serialize_jsonl_event(event)?;
3127        let mut file = std::fs::OpenOptions::new()
3128            .create(true)
3129            .append(true)
3130            .open(events_path)?;
3131        writeln!(file, "{line}")?;
3132        file.flush()?;
3133        let after_len = std::fs::metadata(events_path)?.len();
3134
3135        if self.event_reader.path() == events_path && self.event_reader.position() == before_len {
3136            self.event_reader.set_position(after_len);
3137            return Ok(Vec::new());
3138        }
3139
3140        if self.event_reader.path() == events_path && reader_position < before_len {
3141            let intervening_events =
3142                Self::read_jsonl_events_between(events_path, reader_position, before_len)?;
3143            self.event_reader.set_position(after_len);
3144            return Ok(intervening_events);
3145        }
3146
3147        Ok(Vec::new())
3148    }
3149
3150    fn serialize_jsonl_event(event: &Event) -> std::io::Result<String> {
3151        let mut value = serde_json::json!({
3152            "topic": event.topic.as_str(),
3153            "payload": event.payload,
3154            "ts": chrono::Utc::now().to_rfc3339(),
3155        });
3156
3157        if let Some(wave_id) = &event.wave_id {
3158            value["wave_id"] = Value::String(wave_id.clone());
3159        }
3160        if let Some(wave_index) = event.wave_index {
3161            value["wave_index"] = Value::from(wave_index);
3162        }
3163        if let Some(wave_total) = event.wave_total {
3164            value["wave_total"] = Value::from(wave_total);
3165        }
3166
3167        serde_json::to_string(&value).map_err(std::io::Error::other)
3168    }
3169
3170    fn skip_consumed_robot_response_event(
3171        &mut self,
3172        events_path: &Path,
3173        response: &str,
3174    ) -> Vec<Event> {
3175        if self.event_reader.path() != events_path {
3176            return Vec::new();
3177        }
3178
3179        let Ok(mut file) = std::fs::File::open(events_path) else {
3180            return Vec::new();
3181        };
3182        let mut current_pos = self.event_reader.position();
3183        if file.seek(SeekFrom::Start(current_pos)).is_err() {
3184            return Vec::new();
3185        }
3186
3187        let mut intervening_events = Vec::new();
3188        let reader = BufReader::new(file);
3189        for line in reader.lines() {
3190            let Ok(line) = line else {
3191                return intervening_events;
3192            };
3193            current_pos += line.len() as u64 + 1;
3194
3195            if line.trim().is_empty() {
3196                continue;
3197            }
3198
3199            let Ok(event) = serde_json::from_str::<crate::event_reader::Event>(&line) else {
3200                continue;
3201            };
3202            if event.topic == "human.response" && event.payload.as_deref() == Some(response) {
3203                self.event_reader.set_position(current_pos);
3204                return intervening_events;
3205            }
3206            intervening_events.push(event.into());
3207        }
3208        intervening_events
3209    }
3210
3211    fn read_jsonl_events_between(
3212        events_path: &Path,
3213        start: u64,
3214        end: u64,
3215    ) -> std::io::Result<Vec<Event>> {
3216        let mut file = std::fs::File::open(events_path)?;
3217        file.seek(SeekFrom::Start(start))?;
3218
3219        let reader = BufReader::new(file);
3220        let mut current_pos = start;
3221        let mut events = Vec::new();
3222        for line in reader.lines() {
3223            let line = line?;
3224            current_pos += line.len() as u64 + 1;
3225            if current_pos > end {
3226                break;
3227            }
3228            if line.trim().is_empty() {
3229                continue;
3230            }
3231            if let Ok(event) = serde_json::from_str::<crate::event_reader::Event>(&line) {
3232                events.push(event.into());
3233            }
3234        }
3235        Ok(events)
3236    }
3237
3238    /// Process events from JSONL, partitioning wave events from regular events.
3239    ///
3240    /// Wave events (those with `wave_id` set and targeting a concurrent hat) are
3241    /// extracted and returned separately. Regular events go through the full
3242    /// backpressure pipeline via `process_parse_result`.
3243    pub fn process_events_from_jsonl_with_waves(
3244        &mut self,
3245    ) -> std::io::Result<ProcessedEventsWithWaves> {
3246        let result = self.event_reader.read_new_events()?;
3247
3248        // Partition: wave dispatch events vs regular events.
3249        // Only events that target a concurrent hat (concurrency > 1) are wave dispatches.
3250        // Wave *results* (e.g. review.done) have wave_id set but should be treated as
3251        // regular events so they reach the bus and trigger downstream hats (e.g. aggregator).
3252        //
3253        // Uses find_by_trigger + get_config — the same resolution path as
3254        // detect_wave_events — to ensure partition and detection agree.
3255        let (wave_events, regular_events): (Vec<_>, Vec<_>) =
3256            result.events.into_iter().partition(|e| {
3257                e.wave_id.is_some()
3258                    && self
3259                        .registry
3260                        .find_by_trigger(e.topic.as_str())
3261                        .and_then(|hat_id| self.registry.get_config(hat_id))
3262                        .is_some_and(|hat_config| hat_config.concurrency > 1)
3263            });
3264
3265        if !wave_events.is_empty() {
3266            debug!(
3267                wave_count = wave_events.len(),
3268                regular_count = regular_events.len(),
3269                "Partitioned wave events from regular events"
3270            );
3271        }
3272
3273        // Delegate regular events to the full pipeline (backpressure, scope
3274        // enforcement, human.interact, plan detection, etc.)
3275        let regular_result = crate::event_reader::ParseResult {
3276            events: regular_events,
3277            malformed: result.malformed,
3278        };
3279        let processed = self.process_parse_result(regular_result)?;
3280
3281        Ok(ProcessedEventsWithWaves {
3282            processed,
3283            wave_events,
3284        })
3285    }
3286
3287    /// Checks if output contains a completion event from Ralph.
3288    ///
3289    /// Completion must be emitted as an `<event>` tag, not plain text.
3290    pub fn check_ralph_completion(&self, output: &str) -> bool {
3291        let events = EventParser::new().parse(output);
3292        events
3293            .iter()
3294            .any(|event| event.topic.as_str() == self.config.event_loop.completion_promise)
3295    }
3296
3297    /// Publishes the loop.terminate system event to observers.
3298    ///
3299    /// Per spec: "Published by the orchestrator (not agents) when the loop exits."
3300    /// This is an observer-only event—hats cannot trigger on it.
3301    ///
3302    /// Returns the event for logging purposes.
3303    pub fn publish_terminate_event(&mut self, reason: &TerminationReason) -> Event {
3304        // Stop the robot service if it was running
3305        self.stop_robot_service();
3306
3307        let elapsed = self.state.elapsed();
3308        let duration_str = format_duration(elapsed);
3309
3310        let payload = format!(
3311            "## Reason\n{}\n\n## Status\n{}\n\n## Summary\n- Iterations: {}\n- Duration: {}\n- Exit code: {}",
3312            reason.as_str(),
3313            termination_status_text(reason),
3314            self.state.iteration,
3315            duration_str,
3316            reason.exit_code()
3317        );
3318
3319        let event = Event::new("loop.terminate", &payload);
3320
3321        // Publish to bus for observers (but no hat can trigger on this)
3322        self.bus.publish(event.clone());
3323
3324        info!(
3325            reason = %reason.as_str(),
3326            iterations = self.state.iteration,
3327            duration = %duration_str,
3328            "Wrapping up: {}. {} iterations in {}.",
3329            reason.as_str(),
3330            self.state.iteration,
3331            duration_str
3332        );
3333
3334        event
3335    }
3336
3337    /// Returns the robot service's shutdown flag, if active.
3338    ///
3339    /// Signal handlers can set this flag to interrupt `wait_for_response()`
3340    /// without waiting for the full timeout.
3341    pub fn robot_shutdown_flag(&self) -> Option<Arc<AtomicBool>> {
3342        self.robot_service.as_ref().map(|s| s.shutdown_flag())
3343    }
3344
3345    /// Stops the robot service if it's running.
3346    ///
3347    /// Called during loop termination to cleanly shut down the communication backend.
3348    fn stop_robot_service(&mut self) {
3349        if let Some(service) = self.robot_service.take() {
3350            service.stop();
3351        }
3352    }
3353
3354    // -------------------------------------------------------------------------
3355    // Human-in-the-loop planning support
3356    // -------------------------------------------------------------------------
3357
3358    /// Check if any event is a `user.prompt` event.
3359    ///
3360    /// Returns the first user prompt event found, or None.
3361    pub fn check_for_user_prompt(&self, events: &[Event]) -> Option<UserPrompt> {
3362        events
3363            .iter()
3364            .find(|e| e.topic.as_str() == "user.prompt")
3365            .map(|e| UserPrompt {
3366                id: Self::extract_prompt_id(&e.payload),
3367                text: e.payload.clone(),
3368            })
3369    }
3370
3371    /// Extract a prompt ID from the event payload.
3372    ///
3373    /// Supports both XML attribute format: `<event topic="user.prompt" id="q1">...</event>`
3374    /// and JSON format in payload.
3375    fn extract_prompt_id(payload: &str) -> String {
3376        // Try to extract id attribute from XML-like format first
3377        if let Some(start) = payload.find("id=\"")
3378            && let Some(end) = payload[start + 4..].find('"')
3379        {
3380            return payload[start + 4..start + 4 + end].to_string();
3381        }
3382
3383        // Fallback: generate a simple ID based on timestamp
3384        format!("q{}", Self::generate_prompt_id())
3385    }
3386
3387    /// Generate a simple unique ID for prompts.
3388    /// Uses timestamp-based generation since uuid crate isn't available.
3389    fn generate_prompt_id() -> String {
3390        use std::time::{SystemTime, UNIX_EPOCH};
3391        let nanos = SystemTime::now()
3392            .duration_since(UNIX_EPOCH)
3393            .unwrap()
3394            .as_nanos();
3395        format!("{:x}", nanos % 0xFFFF_FFFF)
3396    }
3397}
3398
3399/// A user prompt that requires human input.
3400///
3401/// Created when the agent emits a `user.prompt` event during planning.
3402#[derive(Debug, Clone)]
3403pub struct UserPrompt {
3404    /// Unique identifier for this prompt (e.g., "q1", "q2")
3405    pub id: String,
3406    /// The prompt/question text
3407    pub text: String,
3408}
3409
3410/// Formats a duration as human-readable string.
3411fn format_duration(d: Duration) -> String {
3412    let total_secs = d.as_secs();
3413    let hours = total_secs / 3600;
3414    let minutes = (total_secs % 3600) / 60;
3415    let seconds = total_secs % 60;
3416
3417    if hours > 0 {
3418        format!("{}h {}m {}s", hours, minutes, seconds)
3419    } else if minutes > 0 {
3420        format!("{}m {}s", minutes, seconds)
3421    } else {
3422        format!("{}s", seconds)
3423    }
3424}
3425
3426/// Returns a human-readable status based on termination reason.
3427fn termination_status_text(reason: &TerminationReason) -> &'static str {
3428    match reason {
3429        TerminationReason::CompletionPromise => "All tasks completed successfully.",
3430        TerminationReason::MaxIterations => "Stopped at iteration limit.",
3431        TerminationReason::MaxRuntime => "Stopped at runtime limit.",
3432        TerminationReason::MaxCost => "Stopped at cost limit.",
3433        TerminationReason::ConsecutiveFailures => "Too many consecutive failures.",
3434        TerminationReason::LoopThrashing => {
3435            "Loop thrashing detected - same hat repeatedly blocked."
3436        }
3437        TerminationReason::LoopStale => {
3438            "Stale loop detected - same topic emitted 3+ times consecutively."
3439        }
3440        TerminationReason::ValidationFailure => "Too many consecutive malformed JSONL events.",
3441        TerminationReason::Stopped => "Manually stopped.",
3442        TerminationReason::Interrupted => "Interrupted by signal.",
3443        TerminationReason::RestartRequested => "Restarting by human request.",
3444        TerminationReason::WorkspaceGone => "Workspace directory removed externally.",
3445        TerminationReason::Cancelled => "Cancelled gracefully (human rejection or timeout).",
3446    }
3447}