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        Ok(Bundle {
168            feature: feature.feature.name.clone(),
169            constitution: cap(&context),
170            plan: cap(&plan_text(&change)),
171            story: cap(&deltas_text(&change)),
172            task: TaskContext {
173                id: task.id.clone(),
174                title: task.title.clone(),
175                phase: task.phase.clone().unwrap_or_default(),
176                subsection: String::new(),
177                story: String::new(),
178                parallel: false,
179            },
180            dependencies,
181        })
182    }
183
184    /// Every scenario of the change's ADDED and MODIFIED requirements: the
185    /// format does not link a task to a requirement.
186    fn scenarios(&self, snapshot: &ProjectSnapshot, _task: &str) -> Vec<ScenarioRef> {
187        let Some(f) = snapshot.feature.as_ref() else {
188            return Vec::new();
189        };
190        let schema = snapshot
191            .openspec
192            .as_ref()
193            .map_or(speccoder_openspec::discover::SCHEMA, |m| m.schema.as_str());
194        let (change, _) =
195            speccoder_openspec::load_change(Path::new(&snapshot.root), &f.feature.name, schema);
196        change
197            .deltas
198            .iter()
199            .flat_map(|d| {
200                d.requirements
201                    .iter()
202                    .filter(|r| {
203                        matches!(
204                            r.operation,
205                            speccoder_openspec::delta::Operation::Added
206                                | speccoder_openspec::delta::Operation::Modified
207                        )
208                    })
209                    .flat_map(move |r| {
210                        r.scenarios.iter().map(move |sc| ScenarioRef {
211                            id: format!("{}: {} / {}", d.capability, r.name, sc.name),
212                            text: sc.raw.clone(),
213                        })
214                    })
215            })
216            .collect()
217    }
218
219    /// Each ADDED or MODIFIED delta requirement with its scenarios, keyed
220    /// `<capability>: <requirement>`.
221    fn oracle_requirements(
222        &self,
223        snapshot: &ProjectSnapshot,
224    ) -> Vec<crate::format::OracleRequirement> {
225        let Some(f) = snapshot.feature.as_ref() else {
226            return Vec::new();
227        };
228        let schema = snapshot
229            .openspec
230            .as_ref()
231            .map_or(speccoder_openspec::discover::SCHEMA, |m| m.schema.as_str());
232        let (change, _) =
233            speccoder_openspec::load_change(Path::new(&snapshot.root), &f.feature.name, schema);
234        change
235            .deltas
236            .iter()
237            .flat_map(|d| {
238                d.requirements
239                    .iter()
240                    .filter(|r| {
241                        matches!(
242                            r.operation,
243                            speccoder_openspec::delta::Operation::Added
244                                | speccoder_openspec::delta::Operation::Modified
245                        )
246                    })
247                    .map(move |r| crate::format::OracleRequirement {
248                        key: format!("{}: {}", d.capability, r.name),
249                        title: r.name.clone(),
250                        text: r.text.clone(),
251                        scenarios: r
252                            .scenarios
253                            .iter()
254                            .map(|sc| ScenarioRef {
255                                id: format!("{}: {} / {}", d.capability, r.name, sc.name),
256                                text: sc.raw.clone(),
257                            })
258                            .collect(),
259                    })
260            })
261            .collect()
262    }
263
264    /// The standing bug-fix change.
265    fn standing_units(&self, _root: &Path) -> Vec<(String, speccoder_core::ladder::Rung)> {
266        vec![("bugfixes".to_owned(), speccoder_core::ladder::Rung::Bug)]
267    }
268
269    /// A delta with a `#### Scenario:`: the scenarios are the examples.
270    fn acceptance_testable(&self, bundle: &Bundle) -> bool {
271        bundle.story.contains("#### Scenario:")
272    }
273
274    /// A Convergence group, appended by converge.
275    fn is_convergence(&self, phase: &str) -> bool {
276        phase.to_lowercase().contains("convergence")
277    }
278
279    fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4] {
280        let id = unit_dir
281            .file_name()
282            .map(|n| n.to_string_lossy().into_owned())
283            .unwrap_or_default();
284        let mut diags = Vec::new();
285        let config = speccoder_openspec::discover::config(root, &mut diags);
286        let (change, _) = speccoder_openspec::load_change(root, &id, &config.schema);
287        [
288            config.context.unwrap_or_default(),
289            deltas_text(&change),
290            plan_text(&change),
291            std::fs::read_to_string(unit_dir.join("tasks.md")).unwrap_or_default(),
292        ]
293    }
294
295    /// Errors of the change itself — preflight, task list, configuration —
296    /// stop a run before any model call. A selection problem is the
297    /// selection's; a dependency problem blocks only the tasks involved.
298    fn blocking<'s>(&self, snapshot: &'s ProjectSnapshot) -> Vec<&'s Diagnostic> {
299        snapshot
300            .diagnostics
301            .iter()
302            .filter(|d| d.severity == Severity::Error)
303            .filter(|d| !d.code.starts_with("select.") && !d.code.starts_with("graph."))
304            .collect()
305    }
306
307    fn crosscheck(
308        &self,
309        snapshot: &ProjectSnapshot,
310        runner: &dyn crate::verify::process::CommandRunner,
311    ) -> Vec<Diagnostic> {
312        cli::crosscheck(snapshot, runner)
313    }
314
315    fn checklists(&self, _root: &Path, _unit_dir: &str) -> Vec<ChecklistStatus> {
316        Vec::new()
317    }
318
319    fn bug_unit(
320        &self,
321        _root: &Path,
322    ) -> Result<speccoder_core::bug::BugHome, crate::format::BugRefusal> {
323        Ok(bug::unit())
324    }
325
326    fn record_bug(
327        &self,
328        root: &Path,
329        _home: &speccoder_core::bug::BugHome,
330        triad: &speccoder_core::bug::Triad,
331    ) -> Result<crate::format::RecordedBug, crate::format::BugRefusal> {
332        bug::record(root, triad)
333    }
334
335    fn read_bug(
336        &self,
337        root: &Path,
338        _home: &speccoder_core::bug::BugHome,
339        key: &str,
340    ) -> Result<crate::format::ReadBug, crate::format::BugRefusal> {
341        bug::read(root, key)
342    }
343
344    fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String> {
345        speccoder_openspec::write::tick(tasks_file, task, line_hash)
346    }
347
348    fn append(
349        &self,
350        tasks_file: &Path,
351        file_hash: &str,
352        items: &[String],
353    ) -> Result<Vec<String>, String> {
354        speccoder_openspec::write::append_convergence(tasks_file, file_hash, items)
355    }
356
357    fn commit_precondition(
358        &self,
359        root: &Path,
360        join: &dyn Fn(&str) -> String,
361        unit_dir: &str,
362    ) -> CommitPrecondition {
363        // The main specs the change's deltas touch: what archive will rewrite,
364        // and what the tests' intent is measured against.
365        let touched = speccoder_openspec::discover::spec_files(&root.join(unit_dir).join("specs"))
366            .into_iter()
367            .map(|(cap, _)| join(&format!("openspec/specs/{cap}/spec.md")));
368        CommitPrecondition {
369            strict: vec![join(unit_dir)],
370            tracked: std::iter::once(join("openspec/config.yaml"))
371                .chain(touched)
372                .collect(),
373            runtime: |_| false,
374            essentials: vec![format!("{}/tasks.md", join(unit_dir))],
375            message: "the change's OpenSpec files are not committed, so a worktree would not contain them",
376            hint: "commit them (git add openspec && git commit), or run in place: speccoder run --in-place",
377            no_commits_hint: "commit the OpenSpec files first (git add openspec && git commit)",
378        }
379    }
380}