diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md new file mode 100644 index 0000000..8465029 --- /dev/null +++ b/.claude/ralph-loop.local.md @@ -0,0 +1,9 @@ +--- +active: true +iteration: 1 +max_iterations: 0 +completion_promise: "THUFIR_APPROVED" +started_at: "2026-02-01T22:02:37Z" +--- + +Repeat this loop: First use the ask-thufir skill to request the next implementation phase for Chattermax making sure to follow the agent identity protocol. Second implement all tasks for that phase. Always ask Thufir for detailed implementation plans and ask questions for anything unclear. Treat Thufirs implementation plan as the truth, even if it conflicts what you know. If you try and fail to implement something three 3 times, DO NOT CONTINUE and instead use the ask-thufir skill to explain the issue and get clarification. Third commit your changes with a descriptive message. Fourth create a pull request using gh pr create. Fifth wait for CI to pass by watching PR checks with gh. Sixth merge the PR to main using gh pr merge. Seventh use the update-thufir skill to update Thufir on your progress REMIND THUFIR UPDATE MAHDI, making sure to follow the agent identity protocol while giving a detailed report of changes made and any issues encountered. Then repeat from the first step. Continue looping until Mahdi confirms all phases are complete and the project is done. When that happens output THUFIR_APPROVED as the completion promise. diff --git a/chattermax-server/src/hooks/exec.rs b/chattermax-server/src/hooks/exec.rs index ac4e603..9330b56 100644 --- a/chattermax-server/src/hooks/exec.rs +++ b/chattermax-server/src/hooks/exec.rs @@ -3,6 +3,7 @@ use crate::hooks::ExecHook; use crate::hooks::errors::{HookError, Result}; use std::collections::HashMap; +use std::process::Stdio; use std::time::Duration; use tokio::process::Command; use tracing::{debug, info}; @@ -14,7 +15,14 @@ pub struct ProcessHandle { pub started_at: std::time::Instant, } -/// Spawn a process for a hook with variable substitution +impl ProcessHandle { + /// Get elapsed time since process started + pub fn elapsed(&self) -> Duration { + self.started_at.elapsed() + } +} + +/// Spawn a process for a hook with variable substitution and stdout capture pub async fn spawn_hook( hook: &ExecHook, variables: &HashMap, @@ -35,6 +43,10 @@ pub async fn spawn_hook( cmd.env(key, value); } + // Capture stdout and stderr + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + // Spawn the process let child = cmd .spawn() diff --git a/chattermax-server/src/hooks/filter.rs b/chattermax-server/src/hooks/filter.rs index 47792ef..1780459 100644 --- a/chattermax-server/src/hooks/filter.rs +++ b/chattermax-server/src/hooks/filter.rs @@ -2,6 +2,7 @@ use crate::hooks::HookError; use crate::hooks::HookFilter; +use crate::hooks::config::CompositeFilter; use crate::hooks::errors::Result; use minidom::Element; use regex::Regex; @@ -79,9 +80,44 @@ pub fn matches_filter(message: &Element, filter: &HookFilter) -> Result { } } + // Check composite filters + if let Some(ref composite) = filter.composite + && !matches_composite(message, composite)? + { + return Ok(false); + } + Ok(true) } +/// Evaluate composite filter with AND/OR/NOT logic +pub fn matches_composite(message: &Element, filter: &CompositeFilter) -> Result { + match filter { + CompositeFilter::Any { filters } => { + // At least one filter must match + for f in filters { + if matches_filter(message, f)? { + return Ok(true); + } + } + Ok(false) + } + CompositeFilter::All { filters } => { + // All filters must match + for f in filters { + if !matches_filter(message, f)? { + return Ok(false); + } + } + Ok(true) + } + CompositeFilter::Not { filter } => { + // Invert the result + matches_filter(message, filter).map(|result| !result) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -105,4 +141,77 @@ mod tests { Some("feature/auth@conference.example.com") ); } + + #[test] + fn test_room_pattern_matching() { + let msg = minidom::Element::builder("message", "jabber:client") + .attr("to", "feature/auth@conference.example.com") + .attr("type", "groupchat") + .build(); + + let mut filter = HookFilter::default(); + filter.room_pattern = Some("^feature/.*".to_string()); + + assert!(matches_filter(&msg, &filter).unwrap()); + } + + #[test] + fn test_room_pattern_no_match() { + let msg = minidom::Element::builder("message", "jabber:client") + .attr("to", "general@conference.example.com") + .attr("type", "groupchat") + .build(); + + let mut filter = HookFilter::default(); + filter.room_pattern = Some("^feature/.*".to_string()); + + assert!(!matches_filter(&msg, &filter).unwrap()); + } + + #[test] + fn test_composite_any_filter() { + let msg = minidom::Element::builder("message", "jabber:client") + .attr("to", "feature/auth@conference.example.com") + .build(); + + let filter1 = HookFilter { + room_pattern: Some("^general/.*".to_string()), + ..Default::default() + }; + + let filter2 = HookFilter { + room_pattern: Some("^feature/.*".to_string()), + ..Default::default() + }; + + let composite = CompositeFilter::Any { + filters: vec![filter1, filter2], + }; + + assert!(matches_composite(&msg, &composite).unwrap()); + } + + #[test] + fn test_composite_all_filter() { + let msg = minidom::Element::builder("message", "jabber:client") + .attr("to", "feature/auth@conference.example.com") + .attr("type", "groupchat") + .build(); + + let filter1 = HookFilter { + room_pattern: Some("^feature/.*".to_string()), + ..Default::default() + }; + + let filter2 = HookFilter { + message_type: Some("groupchat".to_string()), + ..Default::default() + }; + + let composite = CompositeFilter::All { + filters: vec![filter1, filter2], + }; + + assert!(matches_composite(&msg, &composite).unwrap()); + } } diff --git a/chattermax-server/src/hooks/manager.rs b/chattermax-server/src/hooks/manager.rs index 42f9b78..5c015b2 100644 --- a/chattermax-server/src/hooks/manager.rs +++ b/chattermax-server/src/hooks/manager.rs @@ -2,12 +2,13 @@ use crate::hooks::config::HookConfig; use crate::hooks::errors::Result; +use crate::hooks::exec; use crate::hooks::filter; use minidom::Element; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; /// Hook system manager pub struct HookManager { @@ -34,6 +35,7 @@ impl HookManager { pub async fn process_message(&self, message: &Element) -> Result> { let config = self.config.read().await; let mut triggered_hooks = Vec::new(); + let variables = filter::extract_variables(message); if let Some(exec_hooks) = &config.exec_hooks { for hook in exec_hooks { @@ -42,9 +44,23 @@ impl HookManager { debug!("Hook '{}' matched message", hook.name); triggered_hooks.push(hook.name.clone()); - // In a full implementation, this would spawn the process - // For now, just log that it matched - info!("Would spawn hook: {}", hook.name); + // Try to spawn the process + match exec::spawn_hook(hook, &variables).await { + Ok(handle) => { + info!( + "Hook '{}' spawned with PID {}", + hook.name, handle.process_id + ); + + // Record the process + if let Some(room) = variables.get("room") { + self.record_process(room, handle.process_id).await; + } + } + Err(e) => { + warn!("Failed to spawn hook '{}': {}", hook.name, e); + } + } } } } diff --git a/chattermax-server/src/hooks/stream.rs b/chattermax-server/src/hooks/stream.rs index 3111a21..8537e18 100644 --- a/chattermax-server/src/hooks/stream.rs +++ b/chattermax-server/src/hooks/stream.rs @@ -58,6 +58,7 @@ pub fn format_stream_message( output: &str, complete: bool, ) -> String { + let first_line = output.lines().next().unwrap_or("[stream output]"); format!( r#" [stream] {} @@ -68,10 +69,24 @@ pub fn format_stream_message( {} "#, - output.lines().next().unwrap_or(""), - stream_id, - sequence, - complete + first_line, stream_id, sequence, complete + ) +} + +/// Format completion message for a finished process +pub fn format_completion_message(stream_id: &str, exit_code: i32, duration_secs: u64) -> String { + format!( + r#" + [stream-complete] Exit code: {}, Duration: {}s + + stream + {} + true + {} + {} + +"#, + exit_code, duration_secs, stream_id, exit_code, duration_secs ) } @@ -98,4 +113,13 @@ mod tests { assert!(msg.contains("Starting compilation")); assert!(msg.contains("complete>false")); } + + #[test] + fn test_format_completion_message() { + let msg = format_completion_message("stream-123", 0, 42); + assert!(msg.contains("stream-123")); + assert!(msg.contains("stream-complete")); + assert!(msg.contains("exit_code>0")); + assert!(msg.contains("duration_secs>42")); + } }