Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/ralph-loop.local.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 13 additions & 1 deletion chattermax-server/src/hooks/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<String, String>,
Expand All @@ -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()
Expand Down
109 changes: 109 additions & 0 deletions chattermax-server/src/hooks/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -79,9 +80,44 @@ pub fn matches_filter(message: &Element, filter: &HookFilter) -> Result<bool> {
}
}

// 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<bool> {
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::*;
Expand All @@ -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());
}
}
24 changes: 20 additions & 4 deletions chattermax-server/src/hooks/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -34,6 +35,7 @@ impl HookManager {
pub async fn process_message(&self, message: &Element) -> Result<Vec<String>> {
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 {
Expand All @@ -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);
}
}
}
}
}
Expand Down
32 changes: 28 additions & 4 deletions chattermax-server/src/hooks/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"<message type="groupchat">
<body>[stream] {}</body>
Expand All @@ -68,10 +69,24 @@ pub fn format_stream_message(
<complete>{}</complete>
</chibi>
</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#"<message type="groupchat">
<body>[stream-complete] Exit code: {}, Duration: {}s</body>
<chibi xmlns="urn:xmpp:chibi:0">
<type>stream</type>
<stream_id>{}</stream_id>
<complete>true</complete>
<exit_code>{}</exit_code>
<duration_secs>{}</duration_secs>
</chibi>
</message>"#,
exit_code, duration_secs, stream_id, exit_code, duration_secs
)
}

Expand All @@ -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"));
}
}
Loading