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
37fn overlap(a: &str, b: &str) -> f64 {
39 let (a, b) = (words(a), words(b));
40 let union = a.union(&b).count();
41 if union == 0 {
42 return 0.0;
43 }
44 #[allow(clippy::cast_precision_loss)]
45 let share = a.intersection(&b).count() as f64 / union as f64;
46 share
47}
48
49#[must_use]
53pub fn same_task(a: &str, b: &str) -> bool {
54 let (a, b) = (words(a), words(b));
55 let union = a.union(&b).count();
56 union > 0 && a.intersection(&b).count() * 2 >= union
57}
58
59fn cite(id: &str) -> String {
61 match id.split_once('-') {
62 Some((story, n))
63 if story.starts_with("US")
64 && !n.is_empty()
65 && n.chars().all(|c| c.is_ascii_digit()) =>
66 {
67 format!("{story}/AC{n}")
68 }
69 _ => format!("(scenarios {id})"),
70 }
71}
72
73fn scenario_cites(text: &str) -> Vec<String> {
76 let mut out: Vec<String> = text
77 .split(|c: char| !(c.is_alphanumeric() || c == '/' || c == '-'))
78 .filter_map(|w| {
79 let w = w.trim_matches('-').to_ascii_uppercase();
80 let (story, n) = w.split_once("/AC").or_else(|| w.split_once('-'))?;
81 let num = story.strip_prefix("US")?;
82 (!num.is_empty()
83 && num.chars().all(|c| c.is_ascii_digit())
84 && !n.is_empty()
85 && n.chars().all(|c| c.is_ascii_digit()))
86 .then(|| format!("{story}-{n}"))
87 })
88 .collect();
89 out.sort();
90 out.dedup();
91 out
92}
93
94#[must_use]
96pub fn task_line(finding: &Value) -> String {
97 let text = |k: &str| finding[k].as_str().unwrap_or_default().trim().to_owned();
98 format!(
99 "{} per {} ({})",
100 text("task").trim_end_matches('.'),
101 text("source_ref"),
102 text("gap_type")
103 )
104}
105
106pub fn converge_with(
110 path: &Path,
111 feature: Option<&str>,
112 state: &Path,
113 provider: Arc<dyn ModelProvider>,
114 secret: Option<&str>,
115) -> CommandResult {
116 converge_with_options(
117 path,
118 feature,
119 state,
120 provider,
121 secret,
122 &crate::RunOptions::default(),
123 )
124}
125
126pub fn converge_with_options(
129 path: &Path,
130 feature: Option<&str>,
131 state: &Path,
132 provider: Arc<dyn ModelProvider>,
133 secret: Option<&str>,
134 options: &crate::RunOptions,
135) -> CommandResult {
136 if options.in_place && options.session.is_none() {
137 return crate::pipeline::in_session("converge", path, feature, state, options, |o| {
138 converge_with_options(path, feature, state, Arc::clone(&provider), secret, o)
139 });
140 }
141 let before = match crate::hosting::run_hooks(
142 "before_converge",
143 "",
144 path,
145 state,
146 &provider,
147 secret,
148 options,
149 ) {
150 Ok(lines) => lines,
151 Err(stop) => return CommandResult::carried("converge", &stop),
152 };
153 let result = assess(path, feature, state, Arc::clone(&provider), secret, options);
154 let Some(CommandData::Converge(report)) = result.data() else {
155 return result;
156 };
157 let mut report = (**report).clone();
158 report.hooks = before;
159 let (status, message) = (result.status(), result.message().to_owned());
160 if status == CommandStatus::Ok {
161 match crate::hosting::run_hooks(
162 "after_converge",
163 "",
164 path,
165 state,
166 &provider,
167 secret,
168 options,
169 ) {
170 Ok(lines) => report.hooks.extend(lines),
171 Err(stop) => {
172 return CommandResult::carried("converge", &stop)
173 .with_data(CommandData::Converge(Box::new(report)));
174 }
175 }
176 }
177 CommandResult::new("converge", status, &message)
178 .with_data(CommandData::Converge(Box::new(report)))
179}
180
181fn changed_requirements(project: &Path, feature: &str) -> Vec<String> {
185 let report = crate::status::report(project, Some(feature));
186 let Some(CommandData::Report(r)) = report.data() else {
187 return Vec::new();
188 };
189 r.tasks
190 .iter()
191 .filter_map(|t| t.drift.as_ref().map(|d| (t, d)))
192 .flat_map(|(t, d)| {
193 d.requirements.iter().map(move |q| {
194 let quote = |l: &Option<String>| {
195 l.as_deref()
196 .map_or_else(|| "(absent)".to_owned(), |l| format!("\"{}\"", l.trim()))
197 };
198 format!(
199 "- {} {}: was {}; now {}",
200 t.id,
201 q.id,
202 quote(&q.then),
203 quote(&q.now)
204 )
205 })
206 })
207 .collect()
208}
209
210fn assess(
212 path: &Path,
213 feature: Option<&str>,
214 state: &Path,
215 provider: Arc<dyn ModelProvider>,
216 secret: Option<&str>,
217 options: &crate::RunOptions,
218) -> CommandResult {
219 let refuse = |status: CommandStatus, m: String| {
220 CommandResult::new("converge", status, &redact(&m, secret))
221 };
222 let s = match crate::setup(path, feature, state, "converge") {
223 Ok(s) => s,
224 Err(r) => return *r,
225 };
226 let Some(f) = s.snapshot.feature.as_ref() else {
227 return refuse(
228 CommandStatus::SelectionNeeded,
229 "no feature selected; pass --feature".to_owned(),
230 );
231 };
232 let feature_name = f.feature.name.clone();
233
234 let wt = crate::pipeline::workspace(path, Some(&feature_name), state, options);
236 let Some(CommandData::Worktree(w)) = wt.data() else {
237 return CommandResult::carried("converge", &wt);
238 };
239 if wt.status() != CommandStatus::Ok {
240 return CommandResult::carried("converge", &wt).with_data(CommandData::Worktree(w.clone()));
241 }
242 let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
243 return refuse(CommandStatus::Error, "worktree without a path".to_owned());
244 };
245 let wt_root = PathBuf::from(&wt_path);
246 let project = PathBuf::from(&project_dir);
247 if options.session.is_none()
248 && let Err(r) = worktree::require_clean(&wt_root)
249 {
250 return refuse(
251 CommandStatus::GitPrecondition,
252 format!(
253 "the worktree has uncommitted changes ({}); a failed attempt's work? Commit or undo it in {wt_path}",
254 r.paths
255 .iter()
256 .map(|(p, _)| p.as_str())
257 .collect::<Vec<_>>()
258 .join(", ")
259 ),
260 )
261 .with_code("WORKTREE-DIRTY");
262 }
263
264 let format = crate::format::at(&project);
265 let Some(snap) = format.inspect(&project, Some(&feature_name)) else {
266 return refuse(
267 CommandStatus::NotAProject,
268 format.messages().no_project_in_worktree.to_owned(),
269 );
270 };
271 let Some(tasks) = snap.feature.as_ref().and_then(|f| f.tasks.as_ref()) else {
272 return refuse(
273 CommandStatus::NotFound,
274 format!(
275 "{feature_name} has no {}; {}",
276 format.messages().tasks_file,
277 format.messages().make_tasks
278 ),
279 );
280 };
281 let open: Vec<&str> = tasks
282 .tasks
283 .iter()
284 .filter(|t| !t.done)
285 .map(|t| t.id.as_str())
286 .collect();
287 if !open.is_empty() {
288 return refuse(
289 CommandStatus::TaskNotReady,
290 format!(
291 "open tasks: {}; converge assesses a finished feature — run it first",
292 open.join(", ")
293 ),
294 );
295 }
296 let tasks_path = project.join(&tasks.path);
297 let feature_dir = tasks_path
298 .parent()
299 .map_or_else(|| project.clone(), Path::to_path_buf);
300 let read = |p: &Path| std::fs::read_to_string(p).unwrap_or_default();
301 let tasks_text = read(&tasks_path);
302 let tasks_hash = crate::integrity::sha256_hex(tasks_text.as_bytes());
303
304 let Some(config_root) = verify::find_config(&project) else {
305 return refuse(
306 CommandStatus::ConfigError,
307 "the worktree has no speccoder.toml (is it committed?)".to_owned(),
308 )
309 .with_code("CONFIG-MISSING");
310 };
311 let config_text =
312 std::fs::read_to_string(config_root.join(verify::config::FILE)).unwrap_or_default();
313 let run_config = match substrate::run_config::parse(&config_text) {
314 Ok(c) => c,
315 Err(e) => {
316 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
317 .with_code("CONFIG-INVALID");
318 }
319 };
320
321 let is_convergence = |phase: &str| format.is_convergence(phase);
322 let done_rounds = tasks
323 .phases
324 .iter()
325 .filter(|p| is_convergence(&p.title))
326 .count();
327 let round = u32::try_from(done_rounds)
328 .unwrap_or(u32::MAX)
329 .saturating_add(1);
330 let mut report = ConvergeReport {
331 feature: feature_name.clone(),
332 run_id: None,
333 round,
334 outcome: String::new(),
335 findings: Vec::new(),
336 appended: Vec::new(),
337 unrequested: Vec::new(),
338 repeated: Vec::new(),
339 dropped: Vec::new(),
340 stories: Vec::new(),
341 commit: None,
342 usage: None,
343 tool_calls: Vec::new(),
344 hooks: Vec::new(),
345 };
346 let done = |report: ConvergeReport, status: CommandStatus, message: String| {
347 CommandResult::new("converge", status, &redact(&message, secret))
348 .with_data(CommandData::Converge(Box::new(report)))
349 };
350 if round > run_config.max_converge_rounds {
351 report.outcome = "round_limit".to_owned();
352 return done(
353 report,
354 CommandStatus::NeedsHuman,
355 format!(
356 "{done_rounds} convergence rounds already ([converge] max_rounds = {}); a person decides what remains",
357 run_config.max_converge_rounds
358 ),
359 );
360 }
361
362 let stories: Vec<String> = if !run_config.acceptance {
367 Vec::new()
368 } else if format.kind() == speccoder_core::spec::FormatKind::Openspec {
369 vec![feature_name.clone()]
370 } else {
371 let mut keys: Vec<String> = tasks.tasks.iter().filter_map(|t| t.story.clone()).collect();
372 keys.sort();
373 keys.dedup();
374 keys
375 };
376 let mut holds = std::collections::BTreeSet::new();
377 let mut red = std::collections::BTreeSet::new();
378 let mut evidence: Vec<Value> = Vec::new();
379 for key in &stories {
380 let checked = crate::pipeline::story::checked(
381 path,
382 Some(&feature_name),
383 key,
384 state,
385 &provider,
386 options,
387 );
388 let c = match checked {
389 Ok(c) if c.finished => c,
390 Ok(_) => continue,
391 Err(r) => {
392 report
393 .stories
394 .push(format!("{key}: no oracle — {}", r.message()));
395 continue;
396 }
397 };
398 let green = c
399 .scenarios
400 .iter()
401 .filter(|s| c.states.get(&s.id) == Some(&speccoder_core::story::ScenarioState::Green))
402 .count();
403 report.usage = crate::pipeline::commit::add_usage(report.usage.as_ref(), c.usage.as_ref());
404 report.stories.push(format!(
405 "{key}: {green} of {} hold{}",
406 c.scenarios.len(),
407 if c.wrote { " (oracle written now)" } else { "" }
408 ));
409 for sc in &c.scenarios {
410 match c.states.get(&sc.id) {
411 Some(speccoder_core::story::ScenarioState::Green) => {
412 holds.insert(sc.id.clone());
413 }
414 Some(speccoder_core::story::ScenarioState::Red)
415 if !c.untestable.contains(&sc.id) =>
416 {
417 red.insert(sc.id.clone());
418 evidence.push(serde_json::json!({
419 "task": format!("Make {} hold through the story's interface", sc.id),
420 "source_ref": cite(&sc.id),
421 "gap_type": "scenario",
422 "severity": "HIGH",
423 "source": "oracle",
424 "evidence": sc.text,
425 }));
426 }
427 _ => {}
428 }
429 }
430 }
431
432 let tmp = s.plane_dir.join("tmp");
434 let files: Vec<String> = worktree::checkpoint(&wt_root, &tmp)
435 .and_then(|c| worktree::files(&wt_root, &c))
436 .unwrap_or_default()
437 .into_iter()
438 .take(400)
439 .collect();
440 let [constitution, spec, plan, _] = format.convergence_texts(&project, &feature_dir);
441 let changed = changed_requirements(&project, &feature_name);
442 let input = substrate::converge::input(
443 &feature_name,
444 [&constitution, &spec, &plan, &tasks_text],
445 &files,
446 &changed,
447 run_config.max_turns,
448 run_config.max_tool_calls,
449 );
450 let rt = match crate::tokio_runtime() {
451 Ok(rt) => rt,
452 Err(e) => return refuse(CommandStatus::RunFailed, e),
453 };
454 let runner = match crate::sandbox::runner_at(&wt_root) {
455 Ok((r, _)) => r,
456 Err(e) => return refuse(CommandStatus::ConfigError, e),
457 };
458 let policy = tools::policy::Policy::new(&[], &[]).rules(format.rules());
459 let (policy, runner) = match options.session.as_ref().and_then(|s| s.lock().ok()) {
462 Some(s) => {
463 if let Err(e) = s.check(&policy) {
464 return e.result("converge");
465 }
466 let (policy, runner, _) = s.confine(policy, runner);
467 (policy, runner)
468 }
469 None => (policy, runner),
470 };
471 let server = match tools::WorkspaceTools::new(&wt_root, policy) {
472 Ok(t) => Arc::new(t.with_runner(runner)),
473 Err(e) => return refuse(CommandStatus::Error, e.to_string()),
474 };
475 let outcome = rt.block_on(async {
476 let plane = substrate::Plane::open_with(
477 &s.plane_dir,
478 &s.model,
479 provider,
480 Some(server),
481 substrate::Hooks {
482 format: Some(format),
483 ..substrate::Hooks::default()
484 },
485 )?;
486 plane.converge(&feature_name, input).await
487 });
488 let outcome = match outcome {
489 Ok(o) => o,
490 Err(e) => return refuse(CommandStatus::RunFailed, e.0),
491 };
492 report.run_id = Some(outcome.run_id.to_string());
493 let output = outcome
494 .output
495 .as_ref()
496 .map(|o| o.peek().clone())
497 .unwrap_or(Value::Null);
498 report.usage = crate::pipeline::commit::add_usage(report.usage.as_ref(), output.get("usage"));
499 report.tool_calls = output["tool_calls"].as_array().cloned().unwrap_or_default();
500 if output["status"] != "completed" {
501 report.outcome = "failed".to_owned();
502 let why = output["reason"]
503 .as_str()
504 .map(str::to_owned)
505 .unwrap_or_else(|| format!("{:?}", outcome.status));
506 return done(
507 report,
508 CommandStatus::NeedsHuman,
509 format!("the assessment did not finish: {why}"),
510 );
511 }
512 report.findings = output["report"]["findings"]
513 .as_array()
514 .cloned()
515 .unwrap_or_default();
516
517 let earlier: Vec<String> = tasks
520 .tasks
521 .iter()
522 .filter(|t| t.phase.as_deref().is_some_and(is_convergence))
523 .map(|t| {
524 t.title
525 .rsplit_once(" per ")
526 .map_or(t.title.clone(), |(task, _)| task.to_owned())
527 })
528 .collect();
529 let ticked: Vec<&speccoder_core::snapshot::Task> = tasks
531 .tasks
532 .iter()
533 .filter(|t| t.done && !t.phase.as_deref().is_some_and(is_convergence))
534 .collect();
535 let mut actionable: Vec<Value> = Vec::new();
536 for finding in &evidence {
537 if earlier
538 .iter()
539 .any(|e| same_task(e, finding["task"].as_str().unwrap_or_default()))
540 {
541 report.repeated.push(finding.clone());
542 } else {
543 actionable.push(finding.clone());
544 }
545 }
546 for finding in &report.findings {
547 let text = format!(
548 "{} {}",
549 finding["task"].as_str().unwrap_or_default(),
550 finding["source_ref"].as_str().unwrap_or_default()
551 );
552 let cites = scenario_cites(&text);
553 let said = finding["task"].as_str().unwrap_or_default();
555 let restates = ticked
556 .iter()
557 .find(|t| {
558 same_task(said, &t.title)
559 || (text
560 .split(|c: char| !c.is_alphanumeric())
561 .any(|w| w == t.id)
562 && overlap(said, &t.title) >= 0.3)
563 })
564 .map(|t| t.id.as_str());
565 let why = if !cites.is_empty() && cites.iter().all(|c| holds.contains(c)) {
566 Some(format!(
567 "{} already hold{}",
568 cites.join(", "),
569 if cites.len() == 1 { "s" } else { "" }
570 ))
571 } else if cites.iter().any(|c| red.contains(c)) {
572 Some("its scenario's gap is appended from the oracle".to_owned())
573 } else {
574 restates
575 .filter(|_| !cites.iter().any(|c| red.contains(c)))
576 .map(|id| format!("restates {id}, which is done, with no red scenario"))
577 };
578 if let Some(why) = why {
579 let mut dropped = finding.clone();
580 dropped["why"] = Value::String(why);
581 report.dropped.push(dropped);
582 } else if finding["gap_type"] == "unrequested" {
583 report.unrequested.push(finding.clone());
584 } else if earlier
585 .iter()
586 .any(|e| same_task(e, finding["task"].as_str().unwrap_or_default()))
587 {
588 report.repeated.push(finding.clone());
589 } else {
590 actionable.push(finding.clone());
591 }
592 }
593 actionable.sort_by_key(|f| {
594 SEVERITY
595 .iter()
596 .position(|s| f["severity"] == *s)
597 .unwrap_or(SEVERITY.len())
598 });
599 let for_person = if report.unrequested.is_empty() {
600 String::new()
601 } else {
602 format!(
603 "; {} unrequested finding(s) for a person",
604 report.unrequested.len()
605 )
606 };
607
608 if actionable.is_empty() {
609 return if report.repeated.is_empty() && !report.unrequested.is_empty() {
610 report.outcome = "needs_review".to_owned();
613 let n = report.unrequested.len();
614 done(
615 report,
616 CommandStatus::NeedsHuman,
617 format!("no tasks appended; {n} unrequested finding(s) for a person to review"),
618 )
619 } else if report.repeated.is_empty() {
620 report.outcome = "converged".to_owned();
621 done(
622 report,
623 CommandStatus::Ok,
624 format!(
625 "converged: the implementation satisfies the spec, plan and tasks{for_person}"
626 ),
627 )
628 } else {
629 report.outcome = "no_progress".to_owned();
630 let n = report.repeated.len();
631 done(
632 report,
633 CommandStatus::NeedsHuman,
634 format!("no progress: {n} finding(s) repeat earlier convergence tasks{for_person}"),
635 )
636 };
637 }
638
639 let items: Vec<String> = actionable.iter().map(task_line).collect();
640 let tasks_before = std::fs::read(&tasks_path).unwrap_or_default();
641 let ids = match format.append(&tasks_path, &tasks_hash, &items) {
642 Ok(ids) => ids,
643 Err(e) => {
644 report.outcome = "failed".to_owned();
645 return done(report, CommandStatus::Error, e);
646 }
647 };
648 report.appended = ids
649 .iter()
650 .zip(&items)
651 .map(|(id, text)| format!("{id} {text}"))
652 .collect();
653 let message = format!(
654 "speccoder: converge round {round} ({} task{})\n\n{}\n\nSpec-Feature: {feature_name}\nRun: {}\n{}",
655 ids.len(),
656 if ids.len() == 1 { "" } else { "s" },
657 report.appended.join("\n"),
658 report.run_id.clone().unwrap_or_default(),
659 crate::attempts::usage_trailer(report.usage.as_ref())
660 );
661 let committed = match options.session.as_ref().and_then(|s| s.lock().ok()) {
662 None => crate::commit_attempt(&wt_root, &message),
663 Some(mut s) => {
664 let policy = tools::policy::Policy::new(&[], &[]).rules(format.rules());
665 let digest =
666 crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
667 s.commit_round(
668 &plane_dir_of(state, &project),
669 &tasks_path,
670 &tasks_before,
671 &message,
672 &digest,
673 )
674 }
675 };
676 match committed {
677 Ok(id) => report.commit = Some(id),
678 Err(e) => {
679 report.outcome = "failed".to_owned();
680 return done(report, CommandStatus::Error, format!("commit failed: {e}"));
681 }
682 }
683 report.outcome = "tasks_appended".to_owned();
684 let range = match (ids.first(), ids.last()) {
685 (Some(a), Some(b)) if a != b => format!("{a}–{b}"),
686 (Some(a), _) => a.clone(),
687 _ => String::new(),
688 };
689 done(
690 report,
691 CommandStatus::Ok,
692 format!(
693 "appended {range} as Phase: Convergence (round {round}); run the feature again{for_person}"
694 ),
695 )
696}
697
698fn plane_dir_of(state: &Path, project: &Path) -> PathBuf {
700 worktree::project_state_dir(state, project)
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706
707 #[test]
708 fn rewordings_are_the_same_task_and_distinct_tasks_are_not() {
709 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";
711 let t007 = "Tighten identifier validation so every pre-release and build identifier is non-empty and consists only of ASCII letters, digits, or hyphen";
712 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";
713 let t006 = "Add unit tests for every invalid-input category named in the specification and ensure compare returns None for each of them";
714 assert!(same_task(t005, t007));
715 assert!(!same_task(t003, t007));
716 assert!(!same_task(t006, t007));
717 assert!(!same_task("", ""));
718 }
719}