Skip to main content

evolve_adapters/
claude_code.rs

1//! Claude Code adapter.
2
3use crate::signals::{ParsedSignal, SessionLog, SignalKind};
4use crate::traits::{Adapter, AdapterDetection, AdapterError};
5use async_trait::async_trait;
6use evolve_core::agent_config::AgentConfig;
7use evolve_core::ids::AdapterId;
8use serde_json::{Map, Value};
9use std::path::{Path, PathBuf};
10use tokio::fs;
11
12const MANAGED_START: &str = "<!-- evolve:start -->";
13const MANAGED_END: &str = "<!-- evolve:end -->";
14const HOOK_MARKER: &str = "evolve record-claude-code";
15
16/// Claude Code integration.
17#[derive(Debug, Clone, Default)]
18pub struct ClaudeCodeAdapter;
19
20impl ClaudeCodeAdapter {
21    /// Construct.
22    pub fn new() -> Self {
23        Self
24    }
25
26    fn settings_path(root: &Path) -> PathBuf {
27        root.join(".claude").join("settings.json")
28    }
29
30    fn claude_md_path(root: &Path) -> PathBuf {
31        root.join("CLAUDE.md")
32    }
33
34    /// Public for tests: build the `Stop` hook object inserted into settings.json.
35    pub fn stop_hook_entry() -> Value {
36        serde_json::json!({
37            "type": "command",
38            "command": HOOK_MARKER,
39        })
40    }
41
42    /// Render a config into the markdown snippet that goes inside
43    /// the managed section of CLAUDE.md.
44    pub fn render_managed_section(config: &AgentConfig) -> String {
45        let mut out = String::new();
46        out.push_str("# Evolve-managed configuration\n\n");
47        out.push_str("## System prompt prefix\n\n");
48        out.push_str(&config.system_prompt_prefix);
49        out.push_str("\n\n");
50        if !config.behavioral_rules.is_empty() {
51            out.push_str("## Behavioral rules\n\n");
52            for rule in &config.behavioral_rules {
53                out.push_str(&format!("- {rule}\n"));
54            }
55            out.push('\n');
56        }
57        out.push_str(&format!(
58            "## Response style\n\n{:?}\n\n",
59            config.response_style
60        ));
61        out.push_str(&format!("## Model preference\n\n{:?}\n", config.model_pref));
62        out
63    }
64}
65
66#[async_trait]
67impl Adapter for ClaudeCodeAdapter {
68    fn id(&self) -> AdapterId {
69        AdapterId::new("claude-code")
70    }
71
72    fn detect(&self, root: &Path) -> AdapterDetection {
73        if root.join(".claude").is_dir()
74            || root.join("CLAUDE.md").is_file()
75            || root.join(".claude").join("settings.json").is_file()
76        {
77            AdapterDetection::Detected
78        } else {
79            AdapterDetection::NotDetected
80        }
81    }
82
83    async fn install(&self, root: &Path, _config: &AgentConfig) -> Result<(), AdapterError> {
84        let settings_path = Self::settings_path(root);
85        if let Some(parent) = settings_path.parent() {
86            fs::create_dir_all(parent).await?;
87        }
88
89        let mut settings: Value = if settings_path.is_file() {
90            let raw = fs::read_to_string(&settings_path).await?;
91            if raw.trim().is_empty() {
92                Value::Object(Map::new())
93            } else {
94                serde_json::from_str(&raw)?
95            }
96        } else {
97            Value::Object(Map::new())
98        };
99
100        // Idempotency: scan hooks.Stop[] for an entry whose command contains
101        // our marker. If present, skip.
102        let hooks = settings
103            .as_object_mut()
104            .expect("settings is an object")
105            .entry("hooks".to_string())
106            .or_insert_with(|| Value::Object(Map::new()));
107        let hooks_obj = hooks
108            .as_object_mut()
109            .ok_or_else(|| AdapterError::Parse("hooks is not an object".into()))?;
110        let stop = hooks_obj
111            .entry("Stop".to_string())
112            .or_insert_with(|| Value::Array(Vec::new()));
113        let stop_arr = stop
114            .as_array_mut()
115            .ok_or_else(|| AdapterError::Parse("hooks.Stop is not an array".into()))?;
116
117        let already = stop_arr.iter().any(|entry| {
118            entry
119                .get("command")
120                .and_then(|c| c.as_str())
121                .map(|s| s.contains(HOOK_MARKER))
122                .unwrap_or(false)
123        });
124        if !already {
125            stop_arr.push(Self::stop_hook_entry());
126        }
127
128        let rendered = serde_json::to_string_pretty(&settings)?;
129        fs::write(&settings_path, rendered).await?;
130        Ok(())
131    }
132
133    async fn apply_config(&self, root: &Path, config: &AgentConfig) -> Result<(), AdapterError> {
134        let path = Self::claude_md_path(root);
135        let existing = if path.is_file() {
136            fs::read_to_string(&path).await?
137        } else {
138            String::new()
139        };
140        let new_section = Self::render_managed_section(config);
141        let updated = replace_managed_section(&existing, &new_section);
142        fs::write(&path, updated).await?;
143        Ok(())
144    }
145
146    async fn parse_session(&self, log: SessionLog) -> Result<Vec<ParsedSignal>, AdapterError> {
147        let path = match log {
148            SessionLog::Transcript(p) => p,
149            _ => return Err(AdapterError::Parse("expected Transcript log".into())),
150        };
151        let raw = fs::read_to_string(&path).await?;
152        Ok(parse_transcript_lines(&raw))
153    }
154
155    async fn forget(&self, root: &Path) -> Result<(), AdapterError> {
156        // Remove hook from settings.json (keep the file if other hooks exist).
157        let settings_path = Self::settings_path(root);
158        if settings_path.is_file() {
159            let raw = fs::read_to_string(&settings_path).await?;
160            if !raw.trim().is_empty() {
161                let mut settings: Value = serde_json::from_str(&raw)?;
162                if let Some(stop) = settings
163                    .get_mut("hooks")
164                    .and_then(|h| h.get_mut("Stop"))
165                    .and_then(|s| s.as_array_mut())
166                {
167                    stop.retain(|entry| {
168                        entry
169                            .get("command")
170                            .and_then(|c| c.as_str())
171                            .map(|s| !s.contains(HOOK_MARKER))
172                            .unwrap_or(true)
173                    });
174                }
175                fs::write(&settings_path, serde_json::to_string_pretty(&settings)?).await?;
176            }
177        }
178
179        // Strip managed section from CLAUDE.md.
180        let md_path = Self::claude_md_path(root);
181        if md_path.is_file() {
182            let raw = fs::read_to_string(&md_path).await?;
183            let stripped = strip_managed_section(&raw);
184            fs::write(&md_path, stripped).await?;
185        }
186        Ok(())
187    }
188}
189
190/// Replace (or insert) the managed section inside `existing`, returning the new content.
191fn replace_managed_section(existing: &str, new_body: &str) -> String {
192    let block = format!("{MANAGED_START}\n{}\n{MANAGED_END}", new_body.trim_end());
193    if let (Some(start), Some(end)) = (existing.find(MANAGED_START), existing.find(MANAGED_END)) {
194        if end > start {
195            let end_full = end + MANAGED_END.len();
196            let mut out = String::new();
197            out.push_str(&existing[..start]);
198            out.push_str(&block);
199            out.push_str(&existing[end_full..]);
200            return out;
201        }
202    }
203    // No existing markers — append.
204    let mut out = String::from(existing);
205    if !out.is_empty() && !out.ends_with('\n') {
206        out.push('\n');
207    }
208    if !out.is_empty() {
209        out.push('\n');
210    }
211    out.push_str(&block);
212    out.push('\n');
213    out
214}
215
216/// Remove the managed section entirely, leaving the rest of the file.
217fn strip_managed_section(existing: &str) -> String {
218    if let (Some(start), Some(end)) = (existing.find(MANAGED_START), existing.find(MANAGED_END)) {
219        if end > start {
220            let end_full = end + MANAGED_END.len();
221            let mut out = String::new();
222            out.push_str(&existing[..start]);
223            out.push_str(existing[end_full..].trim_start_matches('\n'));
224            return out;
225        }
226    }
227    existing.to_string()
228}
229
230/// Parse a Claude Code transcript (JSONL) into signals.
231///
232/// Each line is a JSON event. We recognize:
233/// - `{ "type": "user", "text": "/clear" }` → `user_clear` signal (0.0)
234/// - `{ "type": "user", "text": "<feedback>" }` matching regex → `user_feedback`
235/// - `{ "type": "tool_use", "tool": "bash", ..., "exit_code": 0 }` → `tests_passed` if test-like command
236/// - `{ "type": "subagent", "status": "completed"|"errored", "subagent_type": "..." }`
237///   → `subagent_ok`/`subagent_fail` signal tagged with the subagent name
238fn parse_transcript_lines(raw: &str) -> Vec<ParsedSignal> {
239    use regex::Regex;
240
241    let negative = Regex::new(r"(?i)\b(redo|wrong|no,? that|try again|undo)\b").unwrap();
242    let positive = Regex::new(r"(?i)\b(thanks|perfect|looks good|lgtm|nice)\b").unwrap();
243    let test_cmd =
244        Regex::new(r"(?i)\b(cargo test|pytest|npm test|jest|go test|cargo check|cargo clippy)\b")
245            .unwrap();
246
247    let mut signals = Vec::new();
248    for line in raw.lines() {
249        let line = line.trim();
250        if line.is_empty() {
251            continue;
252        }
253        let Ok(event): Result<Value, _> = serde_json::from_str(line) else {
254            continue;
255        };
256        let kind = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
257        match kind {
258            "user" => {
259                let text = event.get("text").and_then(|v| v.as_str()).unwrap_or("");
260                if text.trim() == "/clear" {
261                    signals.push(ParsedSignal {
262                        kind: SignalKind::Implicit,
263                        source: "user_clear".into(),
264                        value: 0.0,
265                        payload_json: None,
266                    });
267                    continue;
268                }
269                if negative.is_match(text) {
270                    signals.push(ParsedSignal {
271                        kind: SignalKind::Implicit,
272                        source: "user_feedback_negative".into(),
273                        value: 0.3,
274                        payload_json: None,
275                    });
276                }
277                if positive.is_match(text) {
278                    signals.push(ParsedSignal {
279                        kind: SignalKind::Implicit,
280                        source: "user_feedback_positive".into(),
281                        value: 0.9,
282                        payload_json: None,
283                    });
284                }
285            }
286            "tool_use" => {
287                let tool = event.get("tool").and_then(|v| v.as_str()).unwrap_or("");
288                if tool != "bash" {
289                    continue;
290                }
291                let cmd = event.get("command").and_then(|v| v.as_str()).unwrap_or("");
292                if !test_cmd.is_match(cmd) {
293                    continue;
294                }
295                let exit = event
296                    .get("exit_code")
297                    .and_then(|v| v.as_i64())
298                    .unwrap_or(-1);
299                signals.push(ParsedSignal {
300                    kind: SignalKind::Implicit,
301                    source: if exit == 0 {
302                        "tests_passed".into()
303                    } else {
304                        "tests_failed".into()
305                    },
306                    value: if exit == 0 { 1.0 } else { 0.0 },
307                    payload_json: None,
308                });
309            }
310            "subagent" => {
311                let status = event.get("status").and_then(|v| v.as_str()).unwrap_or("");
312                let agent = event
313                    .get("subagent_type")
314                    .and_then(|v| v.as_str())
315                    .unwrap_or("unknown");
316                let (src, val) = match status {
317                    "completed" | "success" => ("subagent_ok", 1.0),
318                    "errored" | "failed" | "timeout" => ("subagent_fail", 0.0),
319                    _ => continue,
320                };
321                signals.push(ParsedSignal {
322                    kind: SignalKind::Implicit,
323                    source: src.to_string(),
324                    value: val,
325                    payload_json: Some(format!("{{\"subagent\":\"{agent}\"}}")),
326                });
327            }
328            _ => {}
329        }
330    }
331    signals
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use tempfile::TempDir;
338
339    fn sample_config() -> AgentConfig {
340        AgentConfig::default_for("claude-code")
341    }
342
343    #[tokio::test]
344    async fn detect_recognizes_claude_md() {
345        let tmp = TempDir::new().unwrap();
346        std::fs::write(tmp.path().join("CLAUDE.md"), "# test").unwrap();
347        let adapter = ClaudeCodeAdapter::new();
348        assert_eq!(adapter.detect(tmp.path()), AdapterDetection::Detected);
349    }
350
351    #[tokio::test]
352    async fn detect_returns_not_detected_for_empty_dir() {
353        let tmp = TempDir::new().unwrap();
354        let adapter = ClaudeCodeAdapter::new();
355        assert_eq!(adapter.detect(tmp.path()), AdapterDetection::NotDetected);
356    }
357
358    #[tokio::test]
359    async fn install_adds_stop_hook_to_fresh_settings() {
360        let tmp = TempDir::new().unwrap();
361        let adapter = ClaudeCodeAdapter::new();
362        adapter.install(tmp.path(), &sample_config()).await.unwrap();
363        let raw =
364            std::fs::read_to_string(tmp.path().join(".claude").join("settings.json")).unwrap();
365        assert!(raw.contains(HOOK_MARKER));
366    }
367
368    #[tokio::test]
369    async fn install_is_idempotent() {
370        let tmp = TempDir::new().unwrap();
371        let adapter = ClaudeCodeAdapter::new();
372        adapter.install(tmp.path(), &sample_config()).await.unwrap();
373        let first =
374            std::fs::read_to_string(tmp.path().join(".claude").join("settings.json")).unwrap();
375        adapter.install(tmp.path(), &sample_config()).await.unwrap();
376        let second =
377            std::fs::read_to_string(tmp.path().join(".claude").join("settings.json")).unwrap();
378        assert_eq!(
379            first, second,
380            "second install must not change settings.json"
381        );
382    }
383
384    #[tokio::test]
385    async fn install_preserves_unrelated_settings() {
386        let tmp = TempDir::new().unwrap();
387        let dir = tmp.path().join(".claude");
388        std::fs::create_dir_all(&dir).unwrap();
389        let existing = r#"{"theme":"dark","permissions":{"allow":["Bash"]}}"#;
390        std::fs::write(dir.join("settings.json"), existing).unwrap();
391        let adapter = ClaudeCodeAdapter::new();
392        adapter.install(tmp.path(), &sample_config()).await.unwrap();
393        let raw = std::fs::read_to_string(dir.join("settings.json")).unwrap();
394        assert!(raw.contains("\"theme\""));
395        assert!(raw.contains("\"permissions\""));
396        assert!(raw.contains(HOOK_MARKER));
397    }
398
399    #[tokio::test]
400    async fn apply_config_writes_managed_section_between_markers() {
401        let tmp = TempDir::new().unwrap();
402        let adapter = ClaudeCodeAdapter::new();
403        adapter
404            .apply_config(tmp.path(), &sample_config())
405            .await
406            .unwrap();
407        let raw = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
408        assert!(raw.contains(MANAGED_START));
409        assert!(raw.contains(MANAGED_END));
410        assert!(raw.contains("System prompt prefix"));
411    }
412
413    #[tokio::test]
414    async fn apply_config_preserves_user_content_outside_markers() {
415        let tmp = TempDir::new().unwrap();
416        let user_content = "# My own CLAUDE.md\n\nImportant project notes.\n";
417        std::fs::write(tmp.path().join("CLAUDE.md"), user_content).unwrap();
418        let adapter = ClaudeCodeAdapter::new();
419        adapter
420            .apply_config(tmp.path(), &sample_config())
421            .await
422            .unwrap();
423        let raw = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
424        assert!(raw.contains("Important project notes."));
425        assert!(raw.contains(MANAGED_START));
426    }
427
428    #[tokio::test]
429    async fn apply_config_replaces_existing_managed_section() {
430        let tmp = TempDir::new().unwrap();
431        let initial =
432            format!("# Keep\n\n{MANAGED_START}\nold content\n{MANAGED_END}\n\n# Also keep\n",);
433        std::fs::write(tmp.path().join("CLAUDE.md"), &initial).unwrap();
434        let adapter = ClaudeCodeAdapter::new();
435        adapter
436            .apply_config(tmp.path(), &sample_config())
437            .await
438            .unwrap();
439        let raw = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
440        assert!(!raw.contains("old content"));
441        assert!(raw.contains("# Keep"));
442        assert!(raw.contains("# Also keep"));
443    }
444
445    #[tokio::test]
446    async fn forget_removes_hook_but_keeps_other_hooks() {
447        let tmp = TempDir::new().unwrap();
448        let adapter = ClaudeCodeAdapter::new();
449        // Seed with a foreign hook + evolve hook.
450        adapter.install(tmp.path(), &sample_config()).await.unwrap();
451        let path = tmp.path().join(".claude").join("settings.json");
452        let raw = std::fs::read_to_string(&path).unwrap();
453        let mut settings: Value = serde_json::from_str(&raw).unwrap();
454        settings["hooks"]["Stop"]
455            .as_array_mut()
456            .unwrap()
457            .push(serde_json::json!({"type":"command","command":"other-thing"}));
458        std::fs::write(&path, serde_json::to_string_pretty(&settings).unwrap()).unwrap();
459
460        adapter.forget(tmp.path()).await.unwrap();
461        let after = std::fs::read_to_string(&path).unwrap();
462        assert!(!after.contains(HOOK_MARKER));
463        assert!(after.contains("other-thing"));
464    }
465
466    #[tokio::test]
467    async fn forget_strips_managed_section_preserves_user_text() {
468        let tmp = TempDir::new().unwrap();
469        let path = tmp.path().join("CLAUDE.md");
470        let content = format!("# User\n\n{MANAGED_START}\nmanaged\n{MANAGED_END}\n\n# Tail\n",);
471        std::fs::write(&path, &content).unwrap();
472        ClaudeCodeAdapter::new().forget(tmp.path()).await.unwrap();
473        let after = std::fs::read_to_string(&path).unwrap();
474        assert!(after.contains("# User"));
475        assert!(after.contains("# Tail"));
476        assert!(!after.contains("managed"));
477    }
478
479    // ----- transcript parsing -----
480
481    fn jsonl(events: &[&str]) -> String {
482        events.join("\n")
483    }
484
485    #[tokio::test]
486    async fn parse_session_detects_user_clear() {
487        let tmp = TempDir::new().unwrap();
488        let path = tmp.path().join("t.jsonl");
489        std::fs::write(&path, jsonl(&[r#"{"type":"user","text":"/clear"}"#])).unwrap();
490        let signals = ClaudeCodeAdapter::new()
491            .parse_session(SessionLog::Transcript(path))
492            .await
493            .unwrap();
494        assert_eq!(signals.len(), 1);
495        assert_eq!(signals[0].source, "user_clear");
496        assert_eq!(signals[0].value, 0.0);
497    }
498
499    #[tokio::test]
500    async fn parse_session_detects_test_pass_and_fail() {
501        let tmp = TempDir::new().unwrap();
502        let path = tmp.path().join("t.jsonl");
503        std::fs::write(
504            &path,
505            jsonl(&[
506                r#"{"type":"tool_use","tool":"bash","command":"cargo test","exit_code":0}"#,
507                r#"{"type":"tool_use","tool":"bash","command":"cargo test","exit_code":1}"#,
508            ]),
509        )
510        .unwrap();
511        let signals = ClaudeCodeAdapter::new()
512            .parse_session(SessionLog::Transcript(path))
513            .await
514            .unwrap();
515        assert_eq!(signals.len(), 2);
516        assert_eq!(signals[0].source, "tests_passed");
517        assert_eq!(signals[0].value, 1.0);
518        assert_eq!(signals[1].source, "tests_failed");
519        assert_eq!(signals[1].value, 0.0);
520    }
521
522    #[tokio::test]
523    async fn parse_session_detects_positive_and_negative_feedback() {
524        let tmp = TempDir::new().unwrap();
525        let path = tmp.path().join("t.jsonl");
526        std::fs::write(
527            &path,
528            jsonl(&[
529                r#"{"type":"user","text":"perfect, thanks!"}"#,
530                r#"{"type":"user","text":"no, that's wrong, redo"}"#,
531            ]),
532        )
533        .unwrap();
534        let signals = ClaudeCodeAdapter::new()
535            .parse_session(SessionLog::Transcript(path))
536            .await
537            .unwrap();
538        let sources: Vec<&str> = signals.iter().map(|s| s.source.as_str()).collect();
539        assert!(sources.contains(&"user_feedback_positive"));
540        assert!(sources.contains(&"user_feedback_negative"));
541    }
542
543    #[tokio::test]
544    async fn parse_session_ignores_unrelated_bash_commands() {
545        let tmp = TempDir::new().unwrap();
546        let path = tmp.path().join("t.jsonl");
547        std::fs::write(
548            &path,
549            jsonl(&[r#"{"type":"tool_use","tool":"bash","command":"ls -la","exit_code":0}"#]),
550        )
551        .unwrap();
552        let signals = ClaudeCodeAdapter::new()
553            .parse_session(SessionLog::Transcript(path))
554            .await
555            .unwrap();
556        assert!(signals.is_empty());
557    }
558
559    #[tokio::test]
560    async fn parse_session_detects_subagent_completion() {
561        let tmp = TempDir::new().unwrap();
562        let path = tmp.path().join("t.jsonl");
563        std::fs::write(
564            &path,
565            jsonl(&[
566                r#"{"type":"subagent","status":"completed","subagent_type":"code-reviewer"}"#,
567                r#"{"type":"subagent","status":"errored","subagent_type":"debugger"}"#,
568            ]),
569        )
570        .unwrap();
571        let signals = ClaudeCodeAdapter::new()
572            .parse_session(SessionLog::Transcript(path))
573            .await
574            .unwrap();
575        assert_eq!(signals.len(), 2);
576        assert_eq!(signals[0].source, "subagent_ok");
577        assert_eq!(signals[0].value, 1.0);
578        assert!(
579            signals[0]
580                .payload_json
581                .as_deref()
582                .unwrap()
583                .contains("code-reviewer")
584        );
585        assert_eq!(signals[1].source, "subagent_fail");
586        assert_eq!(signals[1].value, 0.0);
587    }
588
589    #[tokio::test]
590    async fn parse_session_tolerates_invalid_json_lines() {
591        let tmp = TempDir::new().unwrap();
592        let path = tmp.path().join("t.jsonl");
593        std::fs::write(
594            &path,
595            "not json\n{\"type\":\"user\",\"text\":\"/clear\"}\nalso not json",
596        )
597        .unwrap();
598        let signals = ClaudeCodeAdapter::new()
599            .parse_session(SessionLog::Transcript(path))
600            .await
601            .unwrap();
602        assert_eq!(signals.len(), 1);
603        assert_eq!(signals[0].source, "user_clear");
604    }
605}