Skip to main content

speccoder_runtime/
convergence.rs

1//! `speccoder converge`: assess a
2//! finished feature against its artifacts and append what remains.
3//!
4//! The model reads and reports; speccoder decides what becomes a task.
5//! Actionable findings (`missing`, `partial`, `contradicts`) are appended as
6//! one `## Phase N: Convergence` section behind a prefix guard and committed
7//! on `speccoder/<feature>`; `unrequested` findings go to a person; a finding
8//! that repeats an earlier convergence task is no progress and stops the loop.
9
10use 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
19/// Severity order: the appended section lists the gravest first.
20const SEVERITY: [&str; 4] = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
21
22/// Words too common to tell two tasks apart.
23const 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
28/// A task's distinguishing words: lowercase alphanumeric runs, stop words out.
29fn 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/// Two wordings of one task: at least half of their distinguishing words
38/// are shared (Jaccard ≥ 0.5). Measured on a real run, rewordings scored
39/// 0.65 and distinct tasks ≤ 0.24.
40#[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/// The task line speccoder appends for a finding (Spec Kit's format).
48#[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
59/// Assesses the feature in its worktree with `provider` and appends the
60/// actionable findings. Every task must be ticked first. Spec Kit's
61/// `before_converge` / `after_converge` hooks run around it.
62pub 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
79/// [`converge_with`] with explicit options: `in_place` assesses in the
80/// person's checkout on `speccoder/<feature>` (`converge --in-place`).
81pub 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
134/// Each requirement reworded, added or removed since a tick that cites it,
135/// as `T004 FR-001: was "…"; now "…"`: what the assessment must
136/// look at again.
137fn 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
163/// The assessment itself.
164fn 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    // The worktree, where the feature's progress lives.
188    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    // The assessment: read-only tools, everything it depends on journaled.
314    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    // In place: the checkout as the session found it, and the model's view
341    // of it confined as for an attempt.
342    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    // speccoder decides: unrequested → a person; repeats → no progress.
399    // An earlier convergence task's text without its ` per <ref> (<gap>)` tail.
400    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            // Not "converged" while a finding waits for a person: a model
441            // can misfile a requirement gap as unrequested (real run).
442            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
528/// The state directory of the project at `project`.
529fn 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        // From a real run (2026-10-21): T007 restated T005.
540        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}