speccoder_runtime/format/openspec/
mod.rs1#[cfg(feature = "engine")]
7pub mod archive;
8mod bug;
9pub mod cli;
10mod texts;
11
12use std::path::{Path, PathBuf};
13
14use speccoder_core::ChecklistStatus;
15use speccoder_core::snapshot::{
16 Diagnostic, FeatureSnapshot, ParsedTasks, ProjectSnapshot, Severity,
17};
18use speccoder_core::spec::{FormatKind, ScenarioRef};
19
20pub use texts::{MESSAGES, PLANNING, RULES, WORDING};
21
22use crate::context::{Bundle, BundleError, DependencyContext, TaskContext, cap};
23use crate::format::{Adapter, CommitPrecondition, Detect, Messages, Planning, Rules, Wording};
24
25#[derive(Debug)]
27pub struct OpenSpec;
28
29pub static OPENSPEC: OpenSpec = OpenSpec;
31
32impl Detect for OpenSpec {
33 fn kind(&self) -> FormatKind {
34 FormatKind::Openspec
35 }
36
37 fn root(&self, start: &Path) -> Option<PathBuf> {
38 speccoder_openspec::discover::find_root(start)
39 }
40
41 fn adapter(&self) -> Option<&'static dyn Adapter> {
42 Some(&OPENSPEC)
43 }
44}
45
46fn plan_text(c: &speccoder_openspec::Change) -> String {
48 [("proposal.md", &c.proposal), ("design.md", &c.design)]
49 .iter()
50 .filter_map(|(name, text)| {
51 text.as_ref()
52 .map(|t| format!("## {}/{name}\n\n{}", c.dir, t.trim_end()))
53 })
54 .collect::<Vec<_>>()
55 .join("\n\n")
56}
57
58fn deltas_text(c: &speccoder_openspec::Change) -> String {
61 c.deltas
62 .iter()
63 .map(|d| format!("## {}\n\n{}", d.path, d.text.trim_end()))
64 .collect::<Vec<_>>()
65 .join("\n\n")
66}
67
68impl Adapter for OpenSpec {
69 fn tool(&self) -> Option<(&'static str, &'static str)> {
70 Some(("openspec", "npm install -g @fission-ai/openspec"))
71 }
72
73 fn kind(&self) -> FormatKind {
74 FormatKind::Openspec
75 }
76
77 fn detect(&self, start: &Path) -> Option<PathBuf> {
78 Detect::root(self, start)
79 }
80
81 fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
82 speccoder_openspec::inspect(start, feature).ok()
83 }
84
85 fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>) {
86 speccoder_openspec::tasks::parse(path, text)
87 }
88
89 fn rules(&self) -> &'static Rules {
90 &RULES
91 }
92
93 fn wording(&self) -> &'static Wording {
94 &WORDING
95 }
96
97 fn messages(&self) -> &'static Messages {
98 &MESSAGES
99 }
100
101 fn planning(&self) -> &'static Planning {
102 &PLANNING
103 }
104
105 fn bundle(&self, snapshot: &ProjectSnapshot, task_id: &str) -> Result<Bundle, BundleError> {
106 let feature: &FeatureSnapshot = snapshot.feature.as_ref().ok_or(BundleError::NoFeature)?;
107 let tasks = feature.tasks.as_ref().ok_or(BundleError::NoTasks)?;
108 let task = tasks
109 .tasks
110 .iter()
111 .find(|t| t.id == task_id)
112 .ok_or_else(|| BundleError::NotFound(task_id.to_owned()))?;
113 let root = Path::new(&snapshot.root);
114 let schema = snapshot
115 .openspec
116 .as_ref()
117 .map_or(speccoder_openspec::discover::SCHEMA, |m| m.schema.as_str());
118 let (change, _) = speccoder_openspec::load_change(root, &feature.feature.name, schema);
119 let context = snapshot
120 .openspec
121 .as_ref()
122 .and_then(|m| m.context.clone())
123 .unwrap_or_default();
124 let dependencies = feature
125 .schedule
126 .as_ref()
127 .and_then(|s| s.tasks.iter().find(|t| t.id == task.id))
128 .map(|st| {
129 st.depends_on
130 .iter()
131 .filter_map(|d| tasks.tasks.iter().find(|t| t.id == d.id))
132 .map(|t| DependencyContext {
133 id: t.id.clone(),
134 title: t.title.clone(),
135 done: t.done,
136 })
137 .collect()
138 })
139 .unwrap_or_default();
140 Ok(Bundle {
141 feature: feature.feature.name.clone(),
142 constitution: cap(&context),
143 plan: cap(&plan_text(&change)),
144 story: cap(&deltas_text(&change)),
145 task: TaskContext {
146 id: task.id.clone(),
147 title: task.title.clone(),
148 phase: task.phase.clone().unwrap_or_default(),
149 subsection: String::new(),
150 story: String::new(),
151 parallel: false,
152 },
153 dependencies,
154 })
155 }
156
157 fn scenarios(&self, snapshot: &ProjectSnapshot, _task: &str) -> Vec<ScenarioRef> {
160 let Some(f) = snapshot.feature.as_ref() else {
161 return Vec::new();
162 };
163 let schema = snapshot
164 .openspec
165 .as_ref()
166 .map_or(speccoder_openspec::discover::SCHEMA, |m| m.schema.as_str());
167 let (change, _) =
168 speccoder_openspec::load_change(Path::new(&snapshot.root), &f.feature.name, schema);
169 change
170 .deltas
171 .iter()
172 .flat_map(|d| {
173 d.requirements
174 .iter()
175 .filter(|r| {
176 matches!(
177 r.operation,
178 speccoder_openspec::delta::Operation::Added
179 | speccoder_openspec::delta::Operation::Modified
180 )
181 })
182 .flat_map(move |r| {
183 r.scenarios.iter().map(move |sc| ScenarioRef {
184 id: format!("{}: {} / {}", d.capability, r.name, sc.name),
185 text: sc.raw.clone(),
186 })
187 })
188 })
189 .collect()
190 }
191
192 fn oracle_requirements(
195 &self,
196 snapshot: &ProjectSnapshot,
197 ) -> Vec<crate::format::OracleRequirement> {
198 let Some(f) = snapshot.feature.as_ref() else {
199 return Vec::new();
200 };
201 let schema = snapshot
202 .openspec
203 .as_ref()
204 .map_or(speccoder_openspec::discover::SCHEMA, |m| m.schema.as_str());
205 let (change, _) =
206 speccoder_openspec::load_change(Path::new(&snapshot.root), &f.feature.name, schema);
207 change
208 .deltas
209 .iter()
210 .flat_map(|d| {
211 d.requirements
212 .iter()
213 .filter(|r| {
214 matches!(
215 r.operation,
216 speccoder_openspec::delta::Operation::Added
217 | speccoder_openspec::delta::Operation::Modified
218 )
219 })
220 .map(move |r| crate::format::OracleRequirement {
221 key: format!("{}: {}", d.capability, r.name),
222 title: r.name.clone(),
223 text: r.text.clone(),
224 scenarios: r
225 .scenarios
226 .iter()
227 .map(|sc| ScenarioRef {
228 id: format!("{}: {} / {}", d.capability, r.name, sc.name),
229 text: sc.raw.clone(),
230 })
231 .collect(),
232 })
233 })
234 .collect()
235 }
236
237 fn standing_units(&self, _root: &Path) -> Vec<(String, speccoder_core::ladder::Rung)> {
239 vec![("bugfixes".to_owned(), speccoder_core::ladder::Rung::Bug)]
240 }
241
242 fn acceptance_testable(&self, bundle: &Bundle) -> bool {
244 bundle.story.contains("#### Scenario:")
245 }
246
247 fn is_convergence(&self, phase: &str) -> bool {
249 phase.to_lowercase().contains("convergence")
250 }
251
252 fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4] {
253 let id = unit_dir
254 .file_name()
255 .map(|n| n.to_string_lossy().into_owned())
256 .unwrap_or_default();
257 let mut diags = Vec::new();
258 let config = speccoder_openspec::discover::config(root, &mut diags);
259 let (change, _) = speccoder_openspec::load_change(root, &id, &config.schema);
260 [
261 config.context.unwrap_or_default(),
262 deltas_text(&change),
263 plan_text(&change),
264 std::fs::read_to_string(unit_dir.join("tasks.md")).unwrap_or_default(),
265 ]
266 }
267
268 fn blocking<'s>(&self, snapshot: &'s ProjectSnapshot) -> Vec<&'s Diagnostic> {
272 snapshot
273 .diagnostics
274 .iter()
275 .filter(|d| d.severity == Severity::Error)
276 .filter(|d| !d.code.starts_with("select.") && !d.code.starts_with("graph."))
277 .collect()
278 }
279
280 fn crosscheck(
281 &self,
282 snapshot: &ProjectSnapshot,
283 runner: &dyn crate::verify::process::CommandRunner,
284 ) -> Vec<Diagnostic> {
285 cli::crosscheck(snapshot, runner)
286 }
287
288 fn checklists(&self, _root: &Path, _unit_dir: &str) -> Vec<ChecklistStatus> {
289 Vec::new()
290 }
291
292 fn bug_unit(
293 &self,
294 _root: &Path,
295 ) -> Result<speccoder_core::bug::BugHome, crate::format::BugRefusal> {
296 Ok(bug::unit())
297 }
298
299 fn record_bug(
300 &self,
301 root: &Path,
302 _home: &speccoder_core::bug::BugHome,
303 triad: &speccoder_core::bug::Triad,
304 ) -> Result<crate::format::RecordedBug, crate::format::BugRefusal> {
305 bug::record(root, triad)
306 }
307
308 fn read_bug(
309 &self,
310 root: &Path,
311 _home: &speccoder_core::bug::BugHome,
312 key: &str,
313 ) -> Result<crate::format::ReadBug, crate::format::BugRefusal> {
314 bug::read(root, key)
315 }
316
317 fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String> {
318 speccoder_openspec::write::tick(tasks_file, task, line_hash)
319 }
320
321 fn append(
322 &self,
323 tasks_file: &Path,
324 file_hash: &str,
325 items: &[String],
326 ) -> Result<Vec<String>, String> {
327 speccoder_openspec::write::append_convergence(tasks_file, file_hash, items)
328 }
329
330 fn commit_precondition(
331 &self,
332 root: &Path,
333 join: &dyn Fn(&str) -> String,
334 unit_dir: &str,
335 ) -> CommitPrecondition {
336 let touched = speccoder_openspec::discover::spec_files(&root.join(unit_dir).join("specs"))
339 .into_iter()
340 .map(|(cap, _)| join(&format!("openspec/specs/{cap}/spec.md")));
341 CommitPrecondition {
342 strict: vec![join(unit_dir)],
343 tracked: std::iter::once(join("openspec/config.yaml"))
344 .chain(touched)
345 .collect(),
346 runtime: |_| false,
347 essentials: vec![format!("{}/tasks.md", join(unit_dir))],
348 message: "the change's OpenSpec files are not committed, so a worktree would not contain them",
349 hint: "commit them (git add openspec && git commit), or run in place: speccoder run --in-place",
350 no_commits_hint: "commit the OpenSpec files first (git add openspec && git commit)",
351 }
352 }
353}