Skip to main content

speccoder_runtime/
shape.rs

1//! `speccoder shape`: a person's words made into a requirement, a bug line,
2//! a quick change or a note. One model call proposes; code decides what
3//! the proposal added, dropped and assumed (`speccoder_core::shape`).
4//! Nothing is written: the field's own sheet or command writes what the
5//! person accepts.
6
7use std::path::Path;
8use std::sync::Arc;
9
10use agentplane::model::ModelProvider;
11use serde_json::Value;
12use speccoder_core::shape::{Assumption, check};
13use speccoder_core::{CommandData, CommandResult, CommandStatus, ShapeReport};
14
15use crate::substrate;
16
17/// The fields Shape helps with: text that becomes the judge or an
18/// instruction. A person's own word on the record (a reason, a ruling) is
19/// not among them.
20pub const FIELDS: [&str; 8] = [
21    "requirement",
22    "feature",
23    "quick",
24    "bug_symptom",
25    "bug_expected",
26    "bug_unchanged",
27    "answer",
28    "note",
29];
30
31/// Whether `field`'s text is a requirement the writing check reads as one.
32#[must_use]
33pub fn is_requirement(field: &str) -> bool {
34    matches!(field, "requirement" | "feature")
35}
36
37/// The model-free writing check of `text` in `field`: what the app shows
38/// under the field as the person types, and `shape --check`.
39#[must_use]
40pub fn findings(field: &str, text: &str) -> Vec<speccoder_core::shape::Finding> {
41    let text: String = text
42        .chars()
43        .take(substrate::shape::MAX_WORDS_CHARS)
44        .collect();
45    check(&text, is_requirement(field))
46}
47
48/// What code makes of a proposal the person changed in the Shape sheet
49/// (kept, asked, removed, added back): the diff against their words, the
50/// added values no remaining assumption explains, where each sentence
51/// went, and the writing check — the same rules as the first proposal.
52#[must_use]
53pub fn judge(field: &str, original: &str, proposal: &str, assumptions: &[Assumption]) -> Value {
54    let cap = |t: &str| -> String { t.chars().take(substrate::shape::MAX_WORDS_CHARS).collect() };
55    let (original, proposal) = (cap(original), cap(proposal));
56    let (original, proposal) = (original.as_str(), proposal.as_str());
57    let diff = speccoder_core::shape::diff(original, proposal);
58    serde_json::json!({
59        "unexplained": speccoder_core::shape::unexplained(&diff, assumptions),
60        "coverage": speccoder_core::shape::coverage(original, proposal),
61        "findings": findings(field, proposal),
62        "diff": diff,
63    })
64}
65
66fn refuse(status: CommandStatus, code: &str, message: &str) -> CommandResult {
67    CommandResult::new("shape", status, message).with_code(code)
68}
69
70/// Shapes `text` for `field` in the project at `path`, with the model in
71/// its `speccoder.toml`.
72#[must_use]
73pub fn shape(path: &Path, field: &str, text: &str, state: Option<&Path>) -> CommandResult {
74    let Some(state) = state
75        .map(Path::to_path_buf)
76        .or_else(crate::worktree::state_dir)
77    else {
78        return refuse(
79            CommandStatus::ConfigError,
80            "NO-STATE-DIR",
81            "no state directory (set SPECCODER_STATE_DIR or HOME)",
82        );
83    };
84    let config = crate::verify::find_config(path)
85        .and_then(|root| std::fs::read_to_string(root.join(crate::verify::config::FILE)).ok())
86        .unwrap_or_default();
87    let model = match substrate::model_config::parse(&config) {
88        Ok(m) => m,
89        Err(e) => {
90            return refuse(
91                CommandStatus::ConfigError,
92                "CONFIG",
93                &format!("speccoder.toml: {e}"),
94            );
95        }
96    };
97    let Some(model) = model else {
98        return refuse(
99            CommandStatus::ConfigError,
100            "CONFIG",
101            "Shape needs a model ([model] in speccoder.toml); the writing check works without one",
102        );
103    };
104    match substrate::model_config::open_driver(&model) {
105        Ok((provider, _)) => shape_with(path, field, text, &state, &model, provider),
106        Err(e) => substrate::model_config::refused("shape", &e),
107    }
108}
109
110/// [`shape`] with `provider` answering the model call.
111#[must_use]
112pub fn shape_with(
113    path: &Path,
114    field: &str,
115    text: &str,
116    state: &Path,
117    model: &substrate::model_config::ModelConfig,
118    provider: Arc<dyn ModelProvider>,
119) -> CommandResult {
120    if !FIELDS.contains(&field) {
121        return refuse(
122            CommandStatus::UsageError,
123            "USAGE",
124            &format!(
125                "no such field to shape: {field} (one of {})",
126                FIELDS.join(", ")
127            ),
128        );
129    }
130    if !text.chars().any(char::is_alphanumeric) {
131        return refuse(
132            CommandStatus::UsageError,
133            "USAGE",
134            "write a few words first — any words",
135        );
136    }
137    let (original, cut) = match text.char_indices().nth(substrate::shape::MAX_WORDS_CHARS) {
138        Some((i, _)) => (text[..i].to_owned(), true),
139        None => (text.to_owned(), false),
140    };
141    let adapter = crate::format::at(path);
142    let format = adapter.kind().name();
143    let sample = sample(path, adapter);
144    let Some(project) = adapter.detect(path) else {
145        return refuse(
146            CommandStatus::NotAProject,
147            "NO-PROJECT",
148            "not a spec project here: Shape writes in the project's spec format",
149        );
150    };
151    let plane_dir = crate::worktree::project_state_dir(state, &project).join("shape");
152    let rt = match crate::pipeline::tokio_runtime() {
153        Ok(rt) => rt,
154        Err(e) => return refuse(CommandStatus::Error, "ERROR", &e),
155    };
156    let outcome = rt.block_on(async {
157        let plane = substrate::Plane::open_with(
158            &plane_dir,
159            model,
160            provider,
161            None,
162            substrate::Hooks::default(),
163        )?;
164        plane
165            .shape(substrate::shape::input(field, format, &sample, &original))
166            .await
167    });
168    let outcome = match outcome {
169        Ok(o) => o,
170        Err(e) => return refuse(CommandStatus::RunFailed, "RUN-FAILED", &e.0),
171    };
172    let output = outcome
173        .output
174        .as_ref()
175        .map(|o| o.peek().clone())
176        .unwrap_or_default();
177    let answer = &output["answer"];
178    if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
179        let status = format!("{:?}", outcome.status);
180        let why = substrate::provider_refusal(&status).map_or_else(
181            || substrate::why_not(&outcome.status),
182            |r| format!("the model provider refused the request: {r}"),
183        );
184        return refuse(
185            CommandStatus::RunFailed,
186            "RUN-FAILED",
187            &format!("Shape got no usable proposal: {why} — your words are unchanged"),
188        );
189    }
190    let report = report(
191        field,
192        format,
193        &original,
194        cut,
195        answer,
196        &output["usage"],
197        &outcome.run_id.to_string(),
198    );
199    let message = summary(&report);
200    CommandResult::new("shape", CommandStatus::Ok, &message)
201        .with_data(CommandData::Shape(Box::new(report)))
202}
203
204/// What code makes of the model's answer: the diff (against the back
205/// translation when the person wrote in another language), the coverage,
206/// unexplained additions and the writing check.
207#[must_use]
208pub fn report(
209    field: &str,
210    format: &str,
211    original: &str,
212    cut: bool,
213    answer: &Value,
214    usage: &Value,
215    run_id: &str,
216) -> ShapeReport {
217    let s = |v: &Value| v.as_str().unwrap_or_default().trim().to_owned();
218    let proposal = s(&answer["proposal"]);
219    let back = Some(s(&answer["back_translation"])).filter(|b| !b.is_empty());
220    let declared: Vec<Assumption> = answer["assumptions"]
221        .as_array()
222        .into_iter()
223        .flatten()
224        .filter_map(|a| serde_json::from_value(a.clone()).ok())
225        .filter(|a: &Assumption| !a.words.trim().is_empty())
226        .collect();
227    // An assumption is words in the proposal; one that is not there was
228    // left open, and is a question.
229    // Exactly as written (trimmed): the sheet finds them by these words.
230    let (assumptions, open): (Vec<Assumption>, Vec<Assumption>) = declared
231        .into_iter()
232        .map(|mut a| {
233            a.words = a.words.trim().to_owned();
234            a
235        })
236        .partition(|a| proposal.contains(&a.words));
237    let mut questions: Vec<String> = Vec::new();
238    let asked = answer["questions"]
239        .as_array()
240        .into_iter()
241        .flatten()
242        .filter_map(|q| q.as_str().map(|q| q.trim().to_owned()));
243    for q in asked.chain(open.into_iter().map(|a| a.question)) {
244        if !q.is_empty() && !questions.contains(&q) {
245            questions.push(q);
246        }
247    }
248    // Added values and the writing check are about the proposal itself,
249    // whatever its language: numbers and quoted terms carry across, and a
250    // back translation is the model's word, never ground truth.
251    let unexplained = speccoder_core::shape::unexplained(
252        &speccoder_core::shape::diff(original, &proposal),
253        &assumptions,
254    );
255    // In another language, the person checks the meaning in theirs.
256    let against = back.as_deref().unwrap_or(&proposal);
257    let diff = speccoder_core::shape::diff(original, against);
258    ShapeReport {
259        field: field.to_owned(),
260        format: format.to_owned(),
261        original: original.to_owned(),
262        cut,
263        coverage: speccoder_core::shape::coverage(original, against),
264        findings: findings(field, &proposal),
265        findings_before: findings(field, original),
266        language: back.as_ref().map(|_| s(&answer["language"])),
267        back_translation: back,
268        diff,
269        unexplained,
270        questions,
271        assumptions,
272        proposal,
273        run_id: (!run_id.is_empty()).then(|| run_id.to_owned()),
274        usage: (!usage.is_null()).then(|| usage.clone()),
275    }
276}
277
278/// `shaped: 2 assumptions, 1 unexplained addition, 1 sentence dropped`.
279fn summary(r: &ShapeReport) -> String {
280    let n = |count: usize, one: &str, many: &str| {
281        format!("{count} {}", if count == 1 { one } else { many })
282    };
283    let mut parts = Vec::new();
284    if !r.assumptions.is_empty() {
285        parts.push(n(r.assumptions.len(), "assumption", "assumptions"));
286    }
287    if !r.unexplained.is_empty() {
288        parts.push(n(
289            r.unexplained.len(),
290            "unexplained addition",
291            "unexplained additions",
292        ));
293    }
294    let dropped = r.coverage.iter().filter(|c| c.dropped).count();
295    if dropped > 0 {
296        parts.push(n(dropped, "sentence dropped", "sentences dropped"));
297    }
298    if !r.questions.is_empty() {
299        parts.push(n(r.questions.len(), "open question", "open questions"));
300    }
301    if parts.is_empty() {
302        return "shaped: nothing was added without saying so — read it, then use it".to_owned();
303    }
304    format!(
305        "shaped: {} — decide each before you use it",
306        parts.join(", ")
307    )
308}
309
310/// A sample of the project's spec, for its language and style: the
311/// selected feature's spec, else the newest feature's, else the rules.
312fn sample(path: &Path, adapter: &dyn crate::format::Adapter) -> String {
313    let text = adapter
314        .inspect(path, None)
315        .and_then(|snap| {
316            let root = Path::new(&snap.root).to_path_buf();
317            let dir = snap
318                .feature
319                .as_ref()
320                .map(|f| f.feature.dir.clone())
321                .or_else(|| snap.features.last().map(|f| f.dir.clone()))?;
322            let [rules, spec, _, _] = adapter.convergence_texts(&root, &root.join(dir));
323            Some(if spec.trim().is_empty() { rules } else { spec })
324        })
325        .unwrap_or_default();
326    let cut: String = text.chars().take(1500).collect();
327    if cut.trim().is_empty() {
328        "(no spec yet: write in the person's language, in plain English if unsure)".to_owned()
329    } else {
330        cut
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use serde_json::json;
338
339    #[test]
340    fn code_decides_what_the_proposal_added_dropped_and_assumed() {
341        let answer = json!({
342            "proposal": "The export MUST write every row as CSV within 2 seconds for up to 10 000 rows.",
343            "assumptions": [{ "words": "up to 10 000 rows", "from": "big tables", "question": "How many rows at most?" }],
344            "questions": [],
345            "language": "en",
346            "back_translation": "",
347        });
348        let original = "export every row as csv fast. it must never delete the source file.";
349        let r = report(
350            "requirement",
351            "speckit",
352            original,
353            false,
354            &answer,
355            &Value::Null,
356            "run_1",
357        );
358        assert_eq!(r.unexplained, ["2 seconds"]);
359        assert!(r.coverage[1].dropped, "{:?}", r.coverage);
360        assert!(r.findings_before.iter().any(|f| f.words == "fast"));
361        assert!(r.findings.iter().all(|f| f.words != "fast"));
362        assert_eq!(r.language, None);
363        assert_eq!(
364            summary(&r),
365            "shaped: 1 assumption, 1 unexplained addition, 1 sentence dropped — decide each before you use it"
366        );
367    }
368
369    #[test]
370    fn in_another_language_the_diff_is_against_the_back_translation() {
371        let answer = json!({
372            "proposal": "The export MUST write CSV.",
373            "assumptions": [],
374            "questions": [],
375            "language": "de",
376            "back_translation": "Der Export MUSS CSV schreiben.",
377        });
378        let r = report(
379            "requirement",
380            "speckit",
381            "Der Export soll CSV schreiben",
382            false,
383            &answer,
384            &Value::Null,
385            "",
386        );
387        assert_eq!(r.language.as_deref(), Some("de"));
388        let added: Vec<&str> = r
389            .diff
390            .proposal
391            .iter()
392            .filter(|s| s.origin == speccoder_core::shape::Origin::Added)
393            .map(|s| s.text.trim())
394            .collect();
395        assert_eq!(added, ["MUSS"]);
396        // The added value is found in the proposal, whatever the back
397        // translation says.
398        let sly = json!({
399            "proposal": "The export MUST write CSV and delete the source after 3 days.",
400            "assumptions": [], "questions": ["Which?", "Which?"], "language": "de",
401            "back_translation": "Der Export MUSS CSV schreiben.",
402        });
403        let r = report(
404            "requirement",
405            "speckit",
406            "Der Export soll CSV schreiben",
407            false,
408            &sly,
409            &Value::Null,
410            "",
411        );
412        assert_eq!(r.unexplained, ["3 days"]);
413        assert_eq!(r.questions, ["Which?"]);
414    }
415}