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/// The share of two wordings' distinguishing words they have in common.
38fn 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/// Two wordings of one task: at least half of their distinguishing words
50/// are shared (Jaccard ≥ 0.5). Measured on a real run, rewordings scored
51/// 0.65 and distinct tasks ≤ 0.24.
52#[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
59/// A scenario id as Spec Kit's converge cites it: `US1-3` → `US1/AC3`.
60fn 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
73/// The scenario ids a finding cites: `US1/AC3` → `US1-3`, and ids written
74/// as they are (`US1-3`).
75fn 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/// The task line speccoder appends for a finding (Spec Kit's format).
95#[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
106/// Assesses the feature in its worktree with `provider` and appends the
107/// actionable findings. Every task must be ticked first. Spec Kit's
108/// `before_converge` / `after_converge` hooks run around it.
109pub 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
126/// [`converge_with`] with explicit options: `in_place` assesses in the
127/// person's checkout on `speccoder/<feature>` (`converge --in-place`).
128pub 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
181/// Each requirement reworded, added or removed since a tick that cites it,
182/// as `T004 FR-001: was "…"; now "…"`: what the assessment must
183/// look at again.
184fn 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
210/// The assessment itself.
211fn 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    // The worktree, where the feature's progress lives.
235    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    // Evidence first: every finished story's oracle, written when it has
363    // none, run on the branch. Its red scenarios are this round's gaps,
364    // named without asking the model; what holds is no gap.
365    // With acceptance tests off there are no oracles to read.
366    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    // The assessment: read-only tools, everything it depends on journaled.
433    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    // In place: the checkout as the session found it, and the model's view
460    // of it confined as for an attempt.
461    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    // speccoder decides: unrequested → a person; repeats → no progress.
518    // An earlier convergence task's text without its ` per <ref> (<gap>)` tail.
519    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    // Done tasks of the plan; an earlier round's own repeat is "no progress".
530    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        // A done task said again: its wording, or its id with much of it.
554        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            // Not "converged" while a finding waits for a person: a model
611            // can misfile a requirement gap as unrequested (real run).
612            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
698/// The state directory of the project at `project`.
699fn 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        // From a real run (2026-10-21): T007 restated T005.
710        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}