1use anyhow::{Context, Result};
6use colored::Colorize;
7use std::fs;
8use std::path::PathBuf;
9
10use crate::config::Config;
11use crate::paths::LOGS_DIR;
12use crate::pid;
13use crate::spec::{self, SpecStatus};
14use crate::worktree;
15
16pub struct TakeoverResult {
18 pub spec_id: String,
19 pub analysis: String,
20 pub log_tail: String,
21 pub suggestion: String,
22 pub worktree_path: Option<String>,
23}
24
25pub fn cmd_takeover(id: &str, force: bool) -> Result<TakeoverResult> {
27 let specs_dir = PathBuf::from(crate::paths::SPECS_DIR);
28 if !specs_dir.exists() {
29 anyhow::bail!("Not a chant project (no .chant/ directory found)");
30 }
31
32 let mut spec = spec::resolve_spec(&specs_dir, id)?;
34 let spec_id = spec.id.clone();
35 let spec_path = specs_dir.join(format!("{}.md", spec_id));
36
37 println!("{} Taking over spec {}", "→".cyan(), spec_id.cyan());
38
39 let pid = pid::read_pid_file(&spec_id)?;
41 let was_running = if let Some(pid) = pid {
42 if pid::is_process_running(pid) {
43 println!(" {} Stopping running process (PID: {})", "•".cyan(), pid);
44 pid::stop_process(pid)?;
45
46 let max_wait_secs = 5;
48 let mut waited_secs = 0;
49 while waited_secs < max_wait_secs && pid::is_process_running(pid) {
50 std::thread::sleep(std::time::Duration::from_millis(100));
51 waited_secs += 1;
52 if waited_secs % 10 == 0 {
53 println!(
54 " {} Waiting for process to exit... ({}/{}s)",
55 "•".cyan(),
56 waited_secs / 10,
57 max_wait_secs
58 );
59 }
60 }
61
62 if pid::is_process_running(pid) {
64 println!(
65 " {} Process did not exit gracefully, sending SIGKILL",
66 "⚠".yellow()
67 );
68 #[cfg(unix)]
69 {
70 use std::process::Command;
71 let _ = Command::new("kill")
72 .args(["-KILL", &pid.to_string()])
73 .output();
74 }
75 std::thread::sleep(std::time::Duration::from_millis(500));
77 }
78
79 pid::remove_pid_file(&spec_id)?;
80 println!(" {} Process stopped", "✓".green());
81 true
82 } else {
83 println!(" {} Cleaning up stale PID file", "•".cyan());
84 pid::remove_pid_file(&spec_id)?;
85 false
86 }
87 } else {
88 if !force {
89 anyhow::bail!(
90 "Spec {} is not currently running. Use --force to analyze anyway.",
91 spec_id
92 );
93 }
94 false
95 };
96
97 let log_path = PathBuf::from(LOGS_DIR).join(format!("{}.log", spec_id));
99 let (log_tail, analysis) = if log_path.exists() {
100 let log_content = fs::read_to_string(&log_path)
101 .with_context(|| format!("Failed to read log file: {}", log_path.display()))?;
102
103 let tail = get_log_tail(&log_content, 50);
104 let analysis = analyze_log(&log_content);
105
106 (tail, analysis)
107 } else {
108 (
109 "No log file found".to_string(),
110 "No execution log available for analysis".to_string(),
111 )
112 };
113
114 let suggestion = generate_suggestion(&spec, &analysis);
116
117 let config = Config::load().ok();
119 let project_name = config.as_ref().map(|c| c.project.name.as_str());
120 let worktree_path = worktree::get_active_worktree(&spec_id, project_name);
121 let worktree_exists = worktree_path.is_some();
122 let worktree_path_str = worktree_path
123 .as_ref()
124 .map(|p| p.to_string_lossy().to_string());
125
126 if spec.frontmatter.status == SpecStatus::InProgress {
128 let _ = spec.set_status(SpecStatus::Paused);
129 }
130
131 let worktree_info = if let Some(ref path) = worktree_path_str {
133 format!(
134 "\n\n### Worktree Location\n\nWork should be done in the isolated worktree:\n```\ncd {}\n```\n",
135 path
136 )
137 } else {
138 "\n\n### Worktree Location\n\nWorktree no longer exists (agent may have cleaned up). If you need to continue working, recreate the worktree with `chant work <spec-id>`.\n".to_string()
139 };
140
141 let takeover_section = format!(
142 "\n\n## Takeover Analysis\n\n{}\n\n### Recent Log Activity\n\n```\n{}\n```\n{}\n### Recommendation\n\n{}\n",
143 analysis,
144 log_tail,
145 worktree_info,
146 suggestion
147 );
148
149 spec.body.push_str(&takeover_section);
150 spec.save(&spec_path)?;
151
152 println!("{} Updated spec with takeover analysis", "✓".green());
153 if was_running {
154 println!(" {} Status set to: paused", "•".cyan());
155 }
156 println!(" {} Analysis appended to spec body", "•".cyan());
157 if worktree_exists {
158 println!(
159 " {} Worktree at: {}",
160 "•".cyan(),
161 worktree_path_str.as_ref().unwrap()
162 );
163 } else {
164 println!(" {} Worktree no longer exists", "⚠".yellow());
165 }
166
167 Ok(TakeoverResult {
168 spec_id,
169 analysis,
170 log_tail,
171 suggestion,
172 worktree_path: worktree_path_str,
173 })
174}
175
176fn get_log_tail(log_content: &str, lines: usize) -> String {
178 log_content
179 .lines()
180 .rev()
181 .take(lines)
182 .collect::<Vec<_>>()
183 .into_iter()
184 .rev()
185 .collect::<Vec<_>>()
186 .join("\n")
187}
188
189fn analyze_log(log_content: &str) -> String {
191 let lines: Vec<&str> = log_content.lines().collect();
192
193 if lines.is_empty() {
194 return "Log is empty - no execution activity recorded.".to_string();
195 }
196
197 let mut analysis = Vec::new();
198
199 let error_indicators = ["error:", "failed:", "ERROR", "FAIL", "exception", "panic"];
201 let errors: Vec<&str> = lines
202 .iter()
203 .filter(|line| {
204 error_indicators
205 .iter()
206 .any(|indicator| line.to_lowercase().contains(indicator))
207 })
208 .copied()
209 .collect();
210
211 if !errors.is_empty() {
212 analysis.push(format!("Found {} error indicator(s) in log:", errors.len()));
213 for error in errors.iter().take(3) {
214 analysis.push(format!(" - {}", error.trim()));
215 }
216 if errors.len() > 3 {
217 analysis.push(format!(" ... and {} more", errors.len() - 3));
218 }
219 }
220
221 let tool_indicators = [
223 "<function_calls>",
224 "tool_name",
225 "Bash",
226 "Read",
227 "Edit",
228 "Write",
229 ];
230 let tool_uses: Vec<&str> = lines
231 .iter()
232 .filter(|line| {
233 tool_indicators
234 .iter()
235 .any(|indicator| line.contains(indicator))
236 })
237 .copied()
238 .collect();
239
240 if !tool_uses.is_empty() {
241 analysis.push(format!("\nAgent made {} tool call(s)", tool_uses.len()));
242 }
243
244 let completion_indicators = ["completed", "finished", "done", "success"];
246 let has_completion = lines.iter().any(|line| {
247 completion_indicators
248 .iter()
249 .any(|indicator| line.to_lowercase().contains(indicator))
250 });
251
252 if has_completion {
253 analysis.push("\nLog contains completion indicators.".to_string());
254 }
255
256 let total_lines = lines.len();
258 analysis.push(format!("\nLog contains {} lines of output.", total_lines));
259
260 if errors.is_empty() && !has_completion {
261 analysis.push("\nNo errors detected, but work appears incomplete.".to_string());
262 }
263
264 if analysis.is_empty() {
265 "Agent execution started but no significant activity detected.".to_string()
266 } else {
267 analysis.join("\n")
268 }
269}
270
271fn generate_suggestion(spec: &spec::Spec, analysis: &str) -> String {
273 let mut suggestions: Vec<String> = Vec::new();
274
275 if analysis.to_lowercase().contains("error") {
277 suggestions
278 .push("Review the errors in the log and address them before resuming.".to_string());
279 }
280
281 let unchecked = spec.count_unchecked_checkboxes();
283 if unchecked > 0 {
284 suggestions.push(format!(
285 "{} acceptance criteria remain unchecked.",
286 unchecked
287 ));
288 }
289
290 suggestions.push("Continue working on this spec manually or adjust the approach.".to_string());
292 suggestions
293 .push("When ready to resume automated work, use `chant work <spec-id>`.".to_string());
294
295 suggestions.join("\n")
296}