1use 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
17pub const CAPABILITY: &str = "speccoder.feature.converge";
19
20pub const READ_ONLY: [&str; 5] = [
22 "read_file",
23 "list_files",
24 "search_code",
25 "git_status",
26 "git_diff",
27];
28
29pub 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#[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#[must_use]
92pub fn input(
93 feature: &str,
94 artifacts: [&str; 4], 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 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 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#[derive(Debug)]
206pub struct Converge {
207 pub style: PromptStyle,
209 pub cache_ttl: CacheTtl,
211 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 reasoning: false,
247 tools,
248 policy: Policy::new(&[], &[]).rules(self.format.rules()),
249 max_turns: limit("max_turns", 30).max(1),
250 max_tool_calls: limit("max_tool_calls", 100),
251 schema: schema(),
252 ask: None,
253 run_approved: Vec::new(),
254 once: Vec::new(),
255 run: None,
256 asking: super::attempt::Asking::default(),
257 parts: Vec::new(),
258 mcp_digests: std::collections::BTreeMap::new(),
259 observer: None,
260 routes: Vec::new(),
261 root: None,
262 frozen_additions: false,
263 finish: |_| "completed",
264 },
265 )
266 .await
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 fn text(input: &Tainted<Value>) -> String {
275 use crate::format::Adapter as _;
276 let w = crate::format::speckit::SpecKit.wording();
277 prompt(
278 input.peek(),
279 CacheTtl::FiveMinutes,
280 PromptStyle::Anthropic,
281 w,
282 )
283 .to_string()
284 }
285
286 #[test]
287 fn changed_requirements_reach_the_assessment_only_when_there_are_some() {
288 let artifacts = ["c", "spec", "plan", "tasks"];
289 let none = input("001-x", artifacts, &[], &[], 10, 20);
290 assert!(none.peek().get("changed").is_none());
291 assert!(!text(&none).contains("Changed since their tasks were verified"));
292 let changed = vec!["- T004 FR-001: was \"password\"; now \"passkey\"".to_owned()];
293 let some = input("001-x", artifacts, &[], &changed, 10, 20);
294 let t = text(&some);
295 assert!(t.contains("Changed since their tasks were verified"), "{t}");
296 assert!(t.contains("T004 FR-001"), "{t}");
297 }
298}