Skip to main content

speccoder_runtime/substrate/
converge.rs

1//! `speccoder.feature.converge`: assess a feature's code against its spec,
2//! plan and tasks, read-only.
3//!
4//! The model may only read; it returns structured findings. speccoder — not
5//! the model — turns actionable findings into a `## Phase N: Convergence`
6//! section, appended behind a prefix guard. `unrequested` findings go to a
7//! person and are never turned into tasks.
8
9use crate::format::Wording;
10use agentplane::prelude::*;
11use serde_json::{Value, json};
12
13use super::attempt::{LoopSpec, tool_loop};
14use super::model_config::{CacheTtl, PromptStyle};
15use crate::tools::policy::Policy;
16
17/// The capability the skill provides.
18pub const CAPABILITY: &str = "speccoder.feature.converge";
19
20/// The tools converge may use: reading only.
21pub const READ_ONLY: [&str; 5] = [
22    "read_file",
23    "list_files",
24    "search_code",
25    "git_status",
26    "git_diff",
27];
28
29/// What the assessor is told (after Spec Kit's converge command).
30pub fn policy(w: &Wording) -> String {
31    format!(
32        "You are the convergence step of speccoder. Assess the CURRENT code of a {product} \
33{unit} against its {documents} — the sole source of intent — with {rules} as \
34governing constraints. Ticked tasks are not evidence: read the code. You can only read \
35(read_file, list_files, search_code, git_status, git_diff); you change nothing. For \
36every {targets}, report a finding only where there is a gap, classified as `missing` (absent), \
37`partial` (exists but does not fully satisfy), `contradicts` (conflicts with stated \
38intent or {must}) or `unrequested` (code that exists although the {sources} \
39do not call for it). A requirement or rule the code does not meet is \
40ALWAYS missing, partial or contradicts — never unrequested. Severity: CRITICAL \
41({critical}), HIGH \
42(missing/partial on a core requirement), MEDIUM, LOW. For each finding give `task`: one \
43imperative sentence that a coding agent can complete (no task ID, no checkbox, no file \
44contents), `source_ref` (e.g. {refs}), and \
45`evidence` (the file or area you observed). Do not report what is already satisfied; do \
46not invent requirements. If nothing remains, return no findings. Finish with the \
47requested structured answer.",
48        product = w.product,
49        unit = w.unit,
50        documents = w.documents,
51        rules = w.assess.rules,
52        targets = w.assess.targets,
53        must = w.assess.must,
54        sources = w.assess.sources,
55        critical = w.assess.critical,
56        refs = w.assess.refs,
57    )
58}
59
60/// The answer's schema.
61#[must_use]
62pub fn schema() -> Value {
63    json!({
64        "type": "object",
65        "properties": {
66            "findings": {
67                "type": "array",
68                "items": {
69                    "type": "object",
70                    "properties": {
71                        "source_ref": { "type": "string" },
72                        "gap_type": { "type": "string", "enum": ["missing", "partial", "contradicts", "unrequested"] },
73                        "severity": { "type": "string", "enum": ["CRITICAL", "HIGH", "MEDIUM", "LOW"] },
74                        "description": { "type": "string" },
75                        "evidence": { "type": "string" },
76                        "task": { "type": "string" },
77                    },
78                    "required": ["source_ref", "gap_type", "severity", "description", "evidence", "task"],
79                    "additionalProperties": false,
80                },
81            },
82            "checked": { "type": "array", "items": { "type": "string" } },
83        },
84        "required": ["findings", "checked"],
85        "additionalProperties": false,
86    })
87}
88
89/// The run input: the feature's artifacts and limits — everything the
90/// skill's decisions depend on, journaled.
91#[must_use]
92pub fn input(
93    feature: &str,
94    artifacts: [&str; 4], // constitution, spec, plan, tasks
95    files: &[String],
96    changed: &[String],
97    max_turns: u32,
98    max_tool_calls: u32,
99) -> Tainted<Value> {
100    let [constitution, spec, plan, tasks] = artifacts;
101    // The artifacts are the person's; the file list is the worktree's.
102    let mut fields = vec![
103        ("feature", Tainted::trusted(json!(feature))),
104        ("constitution", Tainted::trusted(json!(constitution))),
105        ("spec", Tainted::trusted(json!(spec))),
106        ("plan", Tainted::trusted(json!(plan))),
107        ("tasks", Tainted::trusted(json!(tasks))),
108        (
109            "files",
110            Tainted::with_label(
111                json!(files),
112                super::provenance::from(super::provenance::WORKTREE),
113            ),
114        ),
115        (
116            "settings",
117            Tainted::trusted(json!({ "max_turns": max_turns, "max_tool_calls": max_tool_calls })),
118        ),
119    ];
120    // Requirements reworded since the ticks that cite them: the
121    // spec's own lines, then and now. Absent when nothing changed, so an
122    // assessment without drift is the one it always was.
123    if !changed.is_empty() {
124        fields.push(("changed", Tainted::trusted(json!(changed))));
125    }
126    Tainted::object(fields)
127}
128
129fn prompt_raw(input: &Value, ttl: CacheTtl, style: PromptStyle, w: &Wording) -> Value {
130    let system_policy = policy(w);
131    let text = |key: &str| {
132        let t = input[key].as_str().unwrap_or_default();
133        if t.trim().is_empty() {
134            "(none)".to_owned()
135        } else {
136            t.to_owned()
137        }
138    };
139    let files = input["files"]
140        .as_array()
141        .map(|f| {
142            f.iter()
143                .filter_map(Value::as_str)
144                .collect::<Vec<_>>()
145                .join("\n")
146        })
147        .unwrap_or_default();
148    let feature = input["feature"].as_str().unwrap_or_default();
149    let mut parts = vec![
150        format!("# {} ({feature})\n\n{}", w.files[0], text("spec")),
151        format!("# {}\n\n{}", w.files[1], text("plan")),
152        format!("# {}\n\n{}", w.files[2], text("tasks")),
153        format!("# Project files\n\n{files}"),
154    ];
155    if let Some(changed) = input["changed"].as_array().filter(|c| !c.is_empty()) {
156        let lines: Vec<&str> = changed.iter().filter_map(Value::as_str).collect();
157        parts.push(format!(
158            "# Changed since their tasks were verified\n\nThese requirements were reworded, added or removed after the tasks citing them were ticked: the code was judged against the earlier wording. Assess each against the wording now; a gap is `partial` or `contradicts`.\n\n{}",
159            lines.join("\n")
160        ));
161    }
162    let system = format!(
163        "{system_policy}\n\n# {}\n\n{}",
164        w.constitution,
165        text("constitution")
166    );
167    match style {
168        PromptStyle::OpenAi => json!({
169            "system": system,
170            "input": [{ "role": "user", "content": parts.iter().map(|t| json!({ "type": "input_text", "text": t })).collect::<Vec<_>>() }],
171        }),
172        PromptStyle::Anthropic | PromptStyle::Chat => {
173            let breakpoint = match ttl {
174                CacheTtl::FiveMinutes => json!({ "type": "ephemeral" }),
175                CacheTtl::OneHour => json!({ "type": "ephemeral", "ttl": "1h" }),
176            };
177            let content: Vec<Value> = parts
178                .iter()
179                .enumerate()
180                .map(|(i, t)| {
181                    if i == 1 {
182                        json!({ "type": "text", "text": t, "cache_control": breakpoint })
183                    } else {
184                        json!({ "type": "text", "text": t })
185                    }
186                })
187                .collect();
188            json!({
189                "system": system,
190                "messages": [{ "role": "user", "content": content }],
191            })
192        }
193    }
194}
195
196fn prompt(input: &Value, ttl: CacheTtl, style: PromptStyle, w: &Wording) -> Value {
197    let mut out = prompt_raw(input, ttl, style, w);
198    if style == PromptStyle::Chat {
199        super::model_config::strip_cache_control(&mut out);
200    }
201    out
202}
203
204/// The coded skill.
205#[derive(Debug)]
206pub struct Converge {
207    /// Prompt dialect.
208    pub style: PromptStyle,
209    /// Cache breakpoint lifetime.
210    pub cache_ttl: CacheTtl,
211    /// The project's spec format.
212    pub format: &'static dyn crate::format::Adapter,
213}
214
215#[async_trait]
216impl Skill for Converge {
217    fn descriptor(&self) -> SkillDescriptor {
218        SkillDescriptor::new("speccoder.feature.converge").provides(CAPABILITY)
219    }
220
221    async fn invoke(
222        &self,
223        cx: &mut StepCtx<'_>,
224        input: Tainted<Value>,
225    ) -> Result<Outcome, SkillError> {
226        let (ttl, style) = (self.cache_ttl, self.style);
227        let settings = input.peek()["settings"].clone();
228        let limit = |key: &str, default: u32| {
229            settings[key]
230                .as_u64()
231                .map_or(default, |n| u32::try_from(n).unwrap_or(u32::MAX))
232        };
233        let prompt = super::provenance::labelled_prompt(
234            prompt(input.peek(), ttl, style, self.format.wording()),
235            &super::provenance::from(super::provenance::WORKTREE),
236        );
237        let tools = crate::tools::declarations()
238            .into_iter()
239            .filter(|d| READ_ONLY.contains(&d.name.as_str()))
240            .collect();
241        tool_loop(
242            cx,
243            &prompt,
244            LoopSpec {
245                tools,
246                policy: Policy::new(&[], &[]).rules(self.format.rules()),
247                max_turns: limit("max_turns", 30).max(1),
248                max_tool_calls: limit("max_tool_calls", 100),
249                schema: schema(),
250                ask: None,
251                run_approved: Vec::new(),
252                observer: None,
253                routes: Vec::new(),
254                root: None,
255                frozen_additions: false,
256                finish: |_| "completed",
257            },
258        )
259        .await
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    fn text(input: &Tainted<Value>) -> String {
268        use crate::format::Adapter as _;
269        let w = crate::format::speckit::SpecKit.wording();
270        prompt(
271            input.peek(),
272            CacheTtl::FiveMinutes,
273            PromptStyle::Anthropic,
274            w,
275        )
276        .to_string()
277    }
278
279    #[test]
280    fn changed_requirements_reach_the_assessment_only_when_there_are_some() {
281        let artifacts = ["c", "spec", "plan", "tasks"];
282        let none = input("001-x", artifacts, &[], &[], 10, 20);
283        assert!(none.peek().get("changed").is_none());
284        assert!(!text(&none).contains("Changed since their tasks were verified"));
285        let changed = vec!["- T004 FR-001: was \"password\"; now \"passkey\"".to_owned()];
286        let some = input("001-x", artifacts, &[], &changed, 10, 20);
287        let t = text(&some);
288        assert!(t.contains("Changed since their tasks were verified"), "{t}");
289        assert!(t.contains("T004 FR-001"), "{t}");
290    }
291}