Skip to main content

speccoder_runtime/format/openspec/
mod.rs

1//! OpenSpec: a change in `openspec/changes/<id>/` is the unit of
2//! work, its spec deltas' scenarios are the acceptance examples, its
3//! `tasks.md` is a chain. Parsing and preflight are the `speccoder-openspec`
4//! crate's; this adapter is the only runtime code that knows OpenSpec.
5
6pub mod archive;
7mod bug;
8pub mod cli;
9mod texts;
10
11use std::path::{Path, PathBuf};
12
13use speccoder_core::ChecklistStatus;
14use speccoder_core::snapshot::{
15    Diagnostic, FeatureSnapshot, ParsedTasks, ProjectSnapshot, Severity,
16};
17use speccoder_core::spec::{FormatKind, ScenarioRef};
18
19pub use texts::{MESSAGES, PLANNING, RULES, WORDING};
20
21use crate::context::{Bundle, BundleError, DependencyContext, TaskContext, cap};
22use crate::format::{Adapter, CommitPrecondition, Detect, Messages, Planning, Rules, Wording};
23
24/// The OpenSpec adapter.
25#[derive(Debug)]
26pub struct OpenSpec;
27
28/// The one instance.
29pub static OPENSPEC: OpenSpec = OpenSpec;
30
31impl Detect for OpenSpec {
32    fn kind(&self) -> FormatKind {
33        FormatKind::Openspec
34    }
35
36    fn root(&self, start: &Path) -> Option<PathBuf> {
37        speccoder_openspec::discover::find_root(start)
38    }
39
40    fn adapter(&self) -> Option<&'static dyn Adapter> {
41        Some(&OPENSPEC)
42    }
43}
44
45/// The change's proposal and design, as the plan.
46fn plan_text(c: &speccoder_openspec::Change) -> String {
47    [("proposal.md", &c.proposal), ("design.md", &c.design)]
48        .iter()
49        .filter_map(|(name, text)| {
50            text.as_ref()
51                .map(|t| format!("## {}/{name}\n\n{}", c.dir, t.trim_end()))
52        })
53        .collect::<Vec<_>>()
54        .join("\n\n")
55}
56
57/// Every spec delta of the change, whole, as the intent of every task: the
58/// format does not link a task to a requirement.
59fn deltas_text(c: &speccoder_openspec::Change) -> String {
60    c.deltas
61        .iter()
62        .map(|d| format!("## {}\n\n{}", d.path, d.text.trim_end()))
63        .collect::<Vec<_>>()
64        .join("\n\n")
65}
66
67impl Adapter for OpenSpec {
68    fn init_steps(&self, choices: &crate::format::FormatInit) -> Vec<crate::format::InitStep> {
69        // `--tools claude`: the skills speccoder hosts live under
70        // `.claude/skills/`; without a tool OpenSpec asks, which no step can.
71        vec![crate::format::InitStep {
72            argv: [
73                "openspec",
74                "init",
75                "--tools",
76                "claude",
77                "--no-animation",
78                ".",
79            ]
80            .map(str::to_owned)
81            .to_vec(),
82            hosts: Vec::new(),
83            writes: "openspec/, .claude/skills/openspec-*/, .claude/commands/opsx/".to_owned(),
84            // OpenSpec keeps a settings file in the config directory: it
85            // goes to speccoder's private cache, not the person's.
86            env: vec![(
87                "XDG_CONFIG_HOME".to_owned(),
88                choices.cache.display().to_string(),
89            )],
90            marker: "openspec",
91            note: Some("keeps its settings in speccoder's cache, not ~/.config".to_owned()),
92            preset: false,
93        }]
94    }
95
96    fn tool(&self) -> Option<(&'static str, &'static str)> {
97        Some(("openspec", "npm install -g @fission-ai/openspec"))
98    }
99
100    fn kind(&self) -> FormatKind {
101        FormatKind::Openspec
102    }
103
104    fn detect(&self, start: &Path) -> Option<PathBuf> {
105        Detect::root(self, start)
106    }
107
108    fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
109        speccoder_openspec::inspect(start, feature).ok()
110    }
111
112    fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>) {
113        speccoder_openspec::tasks::parse(path, text)
114    }
115
116    fn rules(&self) -> &'static Rules {
117        &RULES
118    }
119
120    fn wording(&self) -> &'static Wording {
121        &WORDING
122    }
123
124    fn messages(&self) -> &'static Messages {
125        &MESSAGES
126    }
127
128    fn planning(&self) -> &'static Planning {
129        &PLANNING
130    }
131
132    fn bundle(&self, snapshot: &ProjectSnapshot, task_id: &str) -> Result<Bundle, BundleError> {
133        let feature: &FeatureSnapshot = snapshot.feature.as_ref().ok_or(BundleError::NoFeature)?;
134        let tasks = feature.tasks.as_ref().ok_or(BundleError::NoTasks)?;
135        let task = tasks
136            .tasks
137            .iter()
138            .find(|t| t.id == task_id)
139            .ok_or_else(|| BundleError::NotFound(task_id.to_owned()))?;
140        let root = Path::new(&snapshot.root);
141        let schema = snapshot
142            .openspec
143            .as_ref()
144            .map_or(speccoder_openspec::discover::SCHEMA, |m| m.schema.as_str());
145        let (change, _) = speccoder_openspec::load_change(root, &feature.feature.name, schema);
146        let context = snapshot
147            .openspec
148            .as_ref()
149            .and_then(|m| m.context.clone())
150            .unwrap_or_default();
151        let dependencies = feature
152            .schedule
153            .as_ref()
154            .and_then(|s| s.tasks.iter().find(|t| t.id == task.id))
155            .map(|st| {
156                st.depends_on
157                    .iter()
158                    .filter_map(|d| tasks.tasks.iter().find(|t| t.id == d.id))
159                    .map(|t| DependencyContext {
160                        id: t.id.clone(),
161                        title: t.title.clone(),
162                        done: t.done,
163                    })
164                    .collect()
165            })
166            .unwrap_or_default();
167        // The format links no task to a requirement: the change is the
168        // story, and every scenario is offered to every task.
169        let story_tasks = tasks
170            .tasks
171            .iter()
172            .filter(|t| t.id != task.id)
173            .map(|t| DependencyContext {
174                id: t.id.clone(),
175                title: t.title.clone(),
176                done: t.done,
177            })
178            .collect();
179        Ok(Bundle {
180            feature: feature.feature.name.clone(),
181            constitution: cap(&context),
182            plan: cap(&plan_text(&change)),
183            story: cap(&deltas_text(&change)),
184            task: TaskContext {
185                id: task.id.clone(),
186                title: task.title.clone(),
187                phase: task.phase.clone().unwrap_or_default(),
188                subsection: String::new(),
189                story: String::new(),
190                parallel: false,
191            },
192            dependencies,
193            story_tasks,
194        })
195    }
196
197    /// Every scenario of the change's ADDED and MODIFIED requirements: the
198    /// format does not link a task to a requirement.
199    fn scenarios(&self, snapshot: &ProjectSnapshot, _task: &str) -> Vec<ScenarioRef> {
200        let Some(f) = snapshot.feature.as_ref() else {
201            return Vec::new();
202        };
203        let schema = snapshot
204            .openspec
205            .as_ref()
206            .map_or(speccoder_openspec::discover::SCHEMA, |m| m.schema.as_str());
207        let (change, _) =
208            speccoder_openspec::load_change(Path::new(&snapshot.root), &f.feature.name, schema);
209        change
210            .deltas
211            .iter()
212            .flat_map(|d| {
213                d.requirements
214                    .iter()
215                    .filter(|r| {
216                        matches!(
217                            r.operation,
218                            speccoder_openspec::delta::Operation::Added
219                                | speccoder_openspec::delta::Operation::Modified
220                        )
221                    })
222                    .flat_map(move |r| {
223                        r.scenarios.iter().map(move |sc| ScenarioRef {
224                            id: format!("{}: {} / {}", d.capability, r.name, sc.name),
225                            text: sc.raw.clone(),
226                        })
227                    })
228            })
229            .collect()
230    }
231
232    /// Each ADDED or MODIFIED delta requirement with its scenarios, keyed
233    /// `<capability>: <requirement>`.
234    fn oracle_requirements(
235        &self,
236        snapshot: &ProjectSnapshot,
237    ) -> Vec<crate::format::OracleRequirement> {
238        let Some(f) = snapshot.feature.as_ref() else {
239            return Vec::new();
240        };
241        let schema = snapshot
242            .openspec
243            .as_ref()
244            .map_or(speccoder_openspec::discover::SCHEMA, |m| m.schema.as_str());
245        let (change, _) =
246            speccoder_openspec::load_change(Path::new(&snapshot.root), &f.feature.name, schema);
247        change
248            .deltas
249            .iter()
250            .flat_map(|d| {
251                d.requirements
252                    .iter()
253                    .filter(|r| {
254                        matches!(
255                            r.operation,
256                            speccoder_openspec::delta::Operation::Added
257                                | speccoder_openspec::delta::Operation::Modified
258                        )
259                    })
260                    .map(move |r| crate::format::OracleRequirement {
261                        key: format!("{}: {}", d.capability, r.name),
262                        title: r.name.clone(),
263                        text: r.text.clone(),
264                        scenarios: r
265                            .scenarios
266                            .iter()
267                            .map(|sc| ScenarioRef {
268                                id: format!("{}: {} / {}", d.capability, r.name, sc.name),
269                                text: sc.raw.clone(),
270                            })
271                            .collect(),
272                    })
273            })
274            .collect()
275    }
276
277    /// The standing bug-fix change.
278    fn standing_units(&self, _root: &Path) -> Vec<(String, speccoder_core::ladder::Rung)> {
279        vec![("bugfixes".to_owned(), speccoder_core::ladder::Rung::Bug)]
280    }
281
282    /// A delta with a `#### Scenario:`: the scenarios are the examples.
283    fn acceptance_testable(&self, bundle: &Bundle) -> bool {
284        bundle.story.contains("#### Scenario:")
285    }
286
287    /// A Convergence group, appended by converge.
288    fn is_convergence(&self, phase: &str) -> bool {
289        phase.to_lowercase().contains("convergence")
290    }
291
292    fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4] {
293        let id = unit_dir
294            .file_name()
295            .map(|n| n.to_string_lossy().into_owned())
296            .unwrap_or_default();
297        let mut diags = Vec::new();
298        let config = speccoder_openspec::discover::config(root, &mut diags);
299        let (change, _) = speccoder_openspec::load_change(root, &id, &config.schema);
300        [
301            config.context.unwrap_or_default(),
302            deltas_text(&change),
303            plan_text(&change),
304            std::fs::read_to_string(unit_dir.join("tasks.md")).unwrap_or_default(),
305        ]
306    }
307
308    /// Errors of the change itself — preflight, task list, configuration —
309    /// stop a run before any model call. A selection problem is the
310    /// selection's; a dependency problem blocks only the tasks involved.
311    fn blocking<'s>(&self, snapshot: &'s ProjectSnapshot) -> Vec<&'s Diagnostic> {
312        snapshot
313            .diagnostics
314            .iter()
315            .filter(|d| d.severity == Severity::Error)
316            .filter(|d| !d.code.starts_with("select.") && !d.code.starts_with("graph."))
317            .collect()
318    }
319
320    fn crosscheck(
321        &self,
322        snapshot: &ProjectSnapshot,
323        runner: &dyn crate::verify::process::CommandRunner,
324    ) -> Vec<Diagnostic> {
325        cli::crosscheck(snapshot, runner)
326    }
327
328    fn checklists(&self, _root: &Path, _unit_dir: &str) -> Vec<ChecklistStatus> {
329        Vec::new()
330    }
331
332    fn bug_unit(
333        &self,
334        _root: &Path,
335    ) -> Result<speccoder_core::bug::BugHome, crate::format::BugRefusal> {
336        Ok(bug::unit())
337    }
338
339    fn record_bug(
340        &self,
341        root: &Path,
342        _home: &speccoder_core::bug::BugHome,
343        triad: &speccoder_core::bug::Triad,
344    ) -> Result<crate::format::RecordedBug, crate::format::BugRefusal> {
345        bug::record(root, triad)
346    }
347
348    fn read_bug(
349        &self,
350        root: &Path,
351        _home: &speccoder_core::bug::BugHome,
352        key: &str,
353    ) -> Result<crate::format::ReadBug, crate::format::BugRefusal> {
354        bug::read(root, key)
355    }
356
357    fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String> {
358        speccoder_openspec::write::tick(tasks_file, task, line_hash)
359    }
360
361    fn append(
362        &self,
363        tasks_file: &Path,
364        file_hash: &str,
365        items: &[String],
366    ) -> Result<Vec<String>, String> {
367        speccoder_openspec::write::append_convergence(tasks_file, file_hash, items)
368    }
369
370    fn commit_precondition(
371        &self,
372        root: &Path,
373        join: &dyn Fn(&str) -> String,
374        unit_dir: &str,
375    ) -> CommitPrecondition {
376        // The main specs the change's deltas touch: what archive will rewrite,
377        // and what the tests' intent is measured against.
378        let touched = speccoder_openspec::discover::spec_files(&root.join(unit_dir).join("specs"))
379            .into_iter()
380            .map(|(cap, _)| join(&format!("openspec/specs/{cap}/spec.md")));
381        CommitPrecondition {
382            strict: vec![join(unit_dir)],
383            tracked: std::iter::once(join("openspec/config.yaml"))
384                .chain(touched)
385                .collect(),
386            runtime: |_| false,
387            essentials: vec![format!("{}/tasks.md", join(unit_dir))],
388            message: "the change's OpenSpec files are not committed, so a worktree would not contain them",
389            hint: "commit them (git add openspec && git commit), or run in place: speccoder run --in-place",
390            no_commits_hint: "commit the OpenSpec files first (git add openspec && git commit)",
391        }
392    }
393}