1use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13use agentplane::model::ModelProvider;
14use serde_json::Value;
15use speccoder_core::{CommandData, CommandResult, CommandStatus, ConvergeReport};
16
17use crate::{redact, substrate, tools, verify, worktree};
18
19const SEVERITY: [&str; 4] = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
21
22const STOP: [&str; 24] = [
24 "a", "an", "the", "and", "or", "so", "to", "of", "for", "in", "on", "is", "are", "be", "it",
25 "its", "every", "each", "must", "only", "per", "that", "this", "with",
26];
27
28fn words(text: &str) -> std::collections::BTreeSet<String> {
30 text.to_lowercase()
31 .split(|c: char| !c.is_alphanumeric())
32 .filter(|w| !w.is_empty() && !STOP.contains(w))
33 .map(str::to_owned)
34 .collect()
35}
36
37#[must_use]
41pub fn same_task(a: &str, b: &str) -> bool {
42 let (a, b) = (words(a), words(b));
43 let union = a.union(&b).count();
44 union > 0 && a.intersection(&b).count() * 2 >= union
45}
46
47#[must_use]
49pub fn task_line(finding: &Value) -> String {
50 let text = |k: &str| finding[k].as_str().unwrap_or_default().trim().to_owned();
51 format!(
52 "{} per {} ({})",
53 text("task").trim_end_matches('.'),
54 text("source_ref"),
55 text("gap_type")
56 )
57}
58
59pub fn converge_with(
63 path: &Path,
64 feature: Option<&str>,
65 state: &Path,
66 provider: Arc<dyn ModelProvider>,
67 secret: Option<&str>,
68) -> CommandResult {
69 converge_with_options(
70 path,
71 feature,
72 state,
73 provider,
74 secret,
75 &crate::RunOptions::default(),
76 )
77}
78
79pub fn converge_with_options(
82 path: &Path,
83 feature: Option<&str>,
84 state: &Path,
85 provider: Arc<dyn ModelProvider>,
86 secret: Option<&str>,
87 options: &crate::RunOptions,
88) -> CommandResult {
89 if options.in_place && options.session.is_none() {
90 return crate::pipeline::in_session("converge", path, feature, state, options, |o| {
91 converge_with_options(path, feature, state, Arc::clone(&provider), secret, o)
92 });
93 }
94 let before = match crate::hosting::run_hooks(
95 "before_converge",
96 "",
97 path,
98 state,
99 &provider,
100 secret,
101 options,
102 ) {
103 Ok(lines) => lines,
104 Err(stop) => return CommandResult::carried("converge", &stop),
105 };
106 let result = assess(path, feature, state, Arc::clone(&provider), secret, options);
107 let Some(CommandData::Converge(report)) = result.data() else {
108 return result;
109 };
110 let mut report = (**report).clone();
111 report.hooks = before;
112 let (status, message) = (result.status(), result.message().to_owned());
113 if status == CommandStatus::Ok {
114 match crate::hosting::run_hooks(
115 "after_converge",
116 "",
117 path,
118 state,
119 &provider,
120 secret,
121 options,
122 ) {
123 Ok(lines) => report.hooks.extend(lines),
124 Err(stop) => {
125 return CommandResult::carried("converge", &stop)
126 .with_data(CommandData::Converge(Box::new(report)));
127 }
128 }
129 }
130 CommandResult::new("converge", status, &message)
131 .with_data(CommandData::Converge(Box::new(report)))
132}
133
134fn changed_requirements(project: &Path, feature: &str) -> Vec<String> {
138 let report = crate::status::report(project, Some(feature));
139 let Some(CommandData::Report(r)) = report.data() else {
140 return Vec::new();
141 };
142 r.tasks
143 .iter()
144 .filter_map(|t| t.drift.as_ref().map(|d| (t, d)))
145 .flat_map(|(t, d)| {
146 d.requirements.iter().map(move |q| {
147 let quote = |l: &Option<String>| {
148 l.as_deref()
149 .map_or_else(|| "(absent)".to_owned(), |l| format!("\"{}\"", l.trim()))
150 };
151 format!(
152 "- {} {}: was {}; now {}",
153 t.id,
154 q.id,
155 quote(&q.then),
156 quote(&q.now)
157 )
158 })
159 })
160 .collect()
161}
162
163fn assess(
165 path: &Path,
166 feature: Option<&str>,
167 state: &Path,
168 provider: Arc<dyn ModelProvider>,
169 secret: Option<&str>,
170 options: &crate::RunOptions,
171) -> CommandResult {
172 let refuse = |status: CommandStatus, m: String| {
173 CommandResult::new("converge", status, &redact(&m, secret))
174 };
175 let s = match crate::setup(path, feature, state, "converge") {
176 Ok(s) => s,
177 Err(r) => return *r,
178 };
179 let Some(f) = s.snapshot.feature.as_ref() else {
180 return refuse(
181 CommandStatus::SelectionNeeded,
182 "no feature selected; pass --feature".to_owned(),
183 );
184 };
185 let feature_name = f.feature.name.clone();
186
187 let wt = crate::pipeline::workspace(path, Some(&feature_name), state, options);
189 let Some(CommandData::Worktree(w)) = wt.data() else {
190 return CommandResult::carried("converge", &wt);
191 };
192 if wt.status() != CommandStatus::Ok {
193 return CommandResult::carried("converge", &wt).with_data(CommandData::Worktree(w.clone()));
194 }
195 let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
196 return refuse(CommandStatus::Error, "worktree without a path".to_owned());
197 };
198 let wt_root = PathBuf::from(&wt_path);
199 let project = PathBuf::from(&project_dir);
200 if options.session.is_none()
201 && let Err(r) = worktree::require_clean(&wt_root)
202 {
203 return refuse(
204 CommandStatus::GitPrecondition,
205 format!(
206 "the worktree has uncommitted changes ({}); a failed attempt's work? Commit or undo it in {wt_path}",
207 r.paths
208 .iter()
209 .map(|(p, _)| p.as_str())
210 .collect::<Vec<_>>()
211 .join(", ")
212 ),
213 )
214 .with_code("WORKTREE-DIRTY");
215 }
216
217 let format = crate::format::at(&project);
218 let Some(snap) = format.inspect(&project, Some(&feature_name)) else {
219 return refuse(
220 CommandStatus::NotAProject,
221 format.messages().no_project_in_worktree.to_owned(),
222 );
223 };
224 let Some(tasks) = snap.feature.as_ref().and_then(|f| f.tasks.as_ref()) else {
225 return refuse(
226 CommandStatus::NotFound,
227 format!(
228 "{feature_name} has no {}; {}",
229 format.messages().tasks_file,
230 format.messages().make_tasks
231 ),
232 );
233 };
234 let open: Vec<&str> = tasks
235 .tasks
236 .iter()
237 .filter(|t| !t.done)
238 .map(|t| t.id.as_str())
239 .collect();
240 if !open.is_empty() {
241 return refuse(
242 CommandStatus::TaskNotReady,
243 format!(
244 "open tasks: {}; converge assesses a finished feature — run it first",
245 open.join(", ")
246 ),
247 );
248 }
249 let tasks_path = project.join(&tasks.path);
250 let feature_dir = tasks_path
251 .parent()
252 .map_or_else(|| project.clone(), Path::to_path_buf);
253 let read = |p: &Path| std::fs::read_to_string(p).unwrap_or_default();
254 let tasks_text = read(&tasks_path);
255 let tasks_hash = crate::integrity::sha256_hex(tasks_text.as_bytes());
256
257 let Some(config_root) = verify::find_config(&project) else {
258 return refuse(
259 CommandStatus::ConfigError,
260 "the worktree has no speccoder.toml (is it committed?)".to_owned(),
261 )
262 .with_code("CONFIG-MISSING");
263 };
264 let config_text =
265 std::fs::read_to_string(config_root.join(verify::config::FILE)).unwrap_or_default();
266 let run_config = match substrate::run_config::parse(&config_text) {
267 Ok(c) => c,
268 Err(e) => {
269 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
270 .with_code("CONFIG-INVALID");
271 }
272 };
273
274 let is_convergence = |phase: &str| format.is_convergence(phase);
275 let done_rounds = tasks
276 .phases
277 .iter()
278 .filter(|p| is_convergence(&p.title))
279 .count();
280 let round = u32::try_from(done_rounds)
281 .unwrap_or(u32::MAX)
282 .saturating_add(1);
283 let mut report = ConvergeReport {
284 feature: feature_name.clone(),
285 run_id: None,
286 round,
287 outcome: String::new(),
288 findings: Vec::new(),
289 appended: Vec::new(),
290 unrequested: Vec::new(),
291 repeated: Vec::new(),
292 commit: None,
293 usage: None,
294 tool_calls: Vec::new(),
295 hooks: Vec::new(),
296 };
297 let done = |report: ConvergeReport, status: CommandStatus, message: String| {
298 CommandResult::new("converge", status, &redact(&message, secret))
299 .with_data(CommandData::Converge(Box::new(report)))
300 };
301 if round > run_config.max_converge_rounds {
302 report.outcome = "round_limit".to_owned();
303 return done(
304 report,
305 CommandStatus::NeedsHuman,
306 format!(
307 "{done_rounds} convergence rounds already ([converge] max_rounds = {}); a person decides what remains",
308 run_config.max_converge_rounds
309 ),
310 );
311 }
312
313 let tmp = s.plane_dir.join("tmp");
315 let files: Vec<String> = worktree::checkpoint(&wt_root, &tmp)
316 .and_then(|c| worktree::files(&wt_root, &c))
317 .unwrap_or_default()
318 .into_iter()
319 .take(400)
320 .collect();
321 let [constitution, spec, plan, _] = format.convergence_texts(&project, &feature_dir);
322 let changed = changed_requirements(&project, &feature_name);
323 let input = substrate::converge::input(
324 &feature_name,
325 [&constitution, &spec, &plan, &tasks_text],
326 &files,
327 &changed,
328 run_config.max_turns,
329 run_config.max_tool_calls,
330 );
331 let rt = match crate::tokio_runtime() {
332 Ok(rt) => rt,
333 Err(e) => return refuse(CommandStatus::RunFailed, e),
334 };
335 let runner = match crate::sandbox::runner_at(&wt_root) {
336 Ok((r, _)) => r,
337 Err(e) => return refuse(CommandStatus::ConfigError, e),
338 };
339 let policy = tools::policy::Policy::new(&[], &[]).rules(format.rules());
340 let (policy, runner) = match options.session.as_ref().and_then(|s| s.lock().ok()) {
343 Some(s) => {
344 if let Err(e) = s.check(&policy) {
345 return e.result("converge");
346 }
347 let (policy, runner, _) = s.confine(policy, runner);
348 (policy, runner)
349 }
350 None => (policy, runner),
351 };
352 let server = match tools::WorkspaceTools::new(&wt_root, policy) {
353 Ok(t) => Arc::new(t.with_runner(runner)),
354 Err(e) => return refuse(CommandStatus::Error, e.to_string()),
355 };
356 let outcome = rt.block_on(async {
357 let plane = substrate::Plane::open_with(
358 &s.plane_dir,
359 &s.model,
360 provider,
361 Some(server),
362 substrate::Hooks {
363 format: Some(format),
364 ..substrate::Hooks::default()
365 },
366 )?;
367 plane.converge(&feature_name, input).await
368 });
369 let outcome = match outcome {
370 Ok(o) => o,
371 Err(e) => return refuse(CommandStatus::RunFailed, e.0),
372 };
373 report.run_id = Some(outcome.run_id.to_string());
374 let output = outcome
375 .output
376 .as_ref()
377 .map(|o| o.peek().clone())
378 .unwrap_or(Value::Null);
379 report.usage = output.get("usage").cloned();
380 report.tool_calls = output["tool_calls"].as_array().cloned().unwrap_or_default();
381 if output["status"] != "completed" {
382 report.outcome = "failed".to_owned();
383 let why = output["reason"]
384 .as_str()
385 .map(str::to_owned)
386 .unwrap_or_else(|| format!("{:?}", outcome.status));
387 return done(
388 report,
389 CommandStatus::NeedsHuman,
390 format!("the assessment did not finish: {why}"),
391 );
392 }
393 report.findings = output["report"]["findings"]
394 .as_array()
395 .cloned()
396 .unwrap_or_default();
397
398 let earlier: Vec<String> = tasks
401 .tasks
402 .iter()
403 .filter(|t| t.phase.as_deref().is_some_and(is_convergence))
404 .map(|t| {
405 t.title
406 .rsplit_once(" per ")
407 .map_or(t.title.clone(), |(task, _)| task.to_owned())
408 })
409 .collect();
410 let mut actionable: Vec<Value> = Vec::new();
411 for finding in &report.findings {
412 if finding["gap_type"] == "unrequested" {
413 report.unrequested.push(finding.clone());
414 } else if earlier
415 .iter()
416 .any(|e| same_task(e, finding["task"].as_str().unwrap_or_default()))
417 {
418 report.repeated.push(finding.clone());
419 } else {
420 actionable.push(finding.clone());
421 }
422 }
423 actionable.sort_by_key(|f| {
424 SEVERITY
425 .iter()
426 .position(|s| f["severity"] == *s)
427 .unwrap_or(SEVERITY.len())
428 });
429 let for_person = if report.unrequested.is_empty() {
430 String::new()
431 } else {
432 format!(
433 "; {} unrequested finding(s) for a person",
434 report.unrequested.len()
435 )
436 };
437
438 if actionable.is_empty() {
439 return if report.repeated.is_empty() && !report.unrequested.is_empty() {
440 report.outcome = "needs_review".to_owned();
443 let n = report.unrequested.len();
444 done(
445 report,
446 CommandStatus::NeedsHuman,
447 format!("no tasks appended; {n} unrequested finding(s) for a person to review"),
448 )
449 } else if report.repeated.is_empty() {
450 report.outcome = "converged".to_owned();
451 done(
452 report,
453 CommandStatus::Ok,
454 format!(
455 "converged: the implementation satisfies the spec, plan and tasks{for_person}"
456 ),
457 )
458 } else {
459 report.outcome = "no_progress".to_owned();
460 let n = report.repeated.len();
461 done(
462 report,
463 CommandStatus::NeedsHuman,
464 format!("no progress: {n} finding(s) repeat earlier convergence tasks{for_person}"),
465 )
466 };
467 }
468
469 let items: Vec<String> = actionable.iter().map(task_line).collect();
470 let tasks_before = std::fs::read(&tasks_path).unwrap_or_default();
471 let ids = match format.append(&tasks_path, &tasks_hash, &items) {
472 Ok(ids) => ids,
473 Err(e) => {
474 report.outcome = "failed".to_owned();
475 return done(report, CommandStatus::Error, e);
476 }
477 };
478 report.appended = ids
479 .iter()
480 .zip(&items)
481 .map(|(id, text)| format!("{id} {text}"))
482 .collect();
483 let message = format!(
484 "speccoder: converge round {round} ({} task{})\n\n{}\n\nSpec-Feature: {feature_name}\nRun: {}\n{}",
485 ids.len(),
486 if ids.len() == 1 { "" } else { "s" },
487 report.appended.join("\n"),
488 report.run_id.clone().unwrap_or_default(),
489 crate::attempts::usage_trailer(report.usage.as_ref())
490 );
491 let committed = match options.session.as_ref().and_then(|s| s.lock().ok()) {
492 None => crate::commit_attempt(&wt_root, &message),
493 Some(mut s) => {
494 let policy = tools::policy::Policy::new(&[], &[]).rules(format.rules());
495 let digest =
496 crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
497 s.commit_round(
498 &plane_dir_of(state, &project),
499 &tasks_path,
500 &tasks_before,
501 &message,
502 &digest,
503 )
504 }
505 };
506 match committed {
507 Ok(id) => report.commit = Some(id),
508 Err(e) => {
509 report.outcome = "failed".to_owned();
510 return done(report, CommandStatus::Error, format!("commit failed: {e}"));
511 }
512 }
513 report.outcome = "tasks_appended".to_owned();
514 let range = match (ids.first(), ids.last()) {
515 (Some(a), Some(b)) if a != b => format!("{a}–{b}"),
516 (Some(a), _) => a.clone(),
517 _ => String::new(),
518 };
519 done(
520 report,
521 CommandStatus::Ok,
522 format!(
523 "appended {range} as Phase: Convergence (round {round}); run the feature again{for_person}"
524 ),
525 )
526}
527
528fn plane_dir_of(state: &Path, project: &Path) -> PathBuf {
530 worktree::project_state_dir(state, project)
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 #[test]
538 fn rewordings_are_the_same_task_and_distinct_tasks_are_not() {
539 let t005 = "Add tests and tighten pre-release validation so every identifier is rejected unless it is non-empty ASCII letters, digits, or hyphen only";
541 let t007 = "Tighten identifier validation so every pre-release and build identifier is non-empty and consists only of ASCII letters, digits, or hyphen";
542 let t003 = "Tighten build metadata parsing so every dot-separated build identifier must be non-empty and valid, and reject inputs with empty build identifiers";
543 let t006 = "Add unit tests for every invalid-input category named in the specification and ensure compare returns None for each of them";
544 assert!(same_task(t005, t007));
545 assert!(!same_task(t003, t007));
546 assert!(!same_task(t006, t007));
547 assert!(!same_task("", ""));
548 }
549}