1use 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#[derive(Debug, Clone, Default)]
18pub struct ClaudeCodeAdapter;
19
20impl ClaudeCodeAdapter {
21 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 pub fn stop_hook_entry() -> Value {
36 serde_json::json!({
37 "type": "command",
38 "command": HOOK_MARKER,
39 })
40 }
41
42 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 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 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 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
190fn 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 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
216fn 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
230fn 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 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 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}