speccoder_runtime/format/openspec/
mod.rs1pub mod archive;
7pub mod cli;
8mod texts;
9
10use std::path::{Path, PathBuf};
11
12use speccoder_core::ChecklistStatus;
13use speccoder_core::snapshot::{
14 Diagnostic, FeatureSnapshot, ParsedTasks, ProjectSnapshot, Severity,
15};
16use speccoder_core::spec::{FormatKind, ScenarioRef};
17
18pub use texts::{MESSAGES, PLANNING, RULES, WORDING};
19
20use crate::context::{Bundle, BundleError, DependencyContext, TaskContext, cap};
21use crate::format::{Adapter, CommitPrecondition, Detect, Messages, Planning, Rules, Wording};
22
23#[derive(Debug)]
25pub struct OpenSpec;
26
27pub static OPENSPEC: OpenSpec = OpenSpec;
29
30impl Detect for OpenSpec {
31 fn kind(&self) -> FormatKind {
32 FormatKind::Openspec
33 }
34
35 fn root(&self, start: &Path) -> Option<PathBuf> {
36 speccoder_openspec::discover::find_root(start)
37 }
38
39 fn adapter(&self) -> Option<&'static dyn Adapter> {
40 Some(&OPENSPEC)
41 }
42}
43
44fn plan_text(c: &speccoder_openspec::Change) -> String {
46 [("proposal.md", &c.proposal), ("design.md", &c.design)]
47 .iter()
48 .filter_map(|(name, text)| {
49 text.as_ref()
50 .map(|t| format!("## {}/{name}\n\n{}", c.dir, t.trim_end()))
51 })
52 .collect::<Vec<_>>()
53 .join("\n\n")
54}
55
56fn deltas_text(c: &speccoder_openspec::Change) -> String {
59 c.deltas
60 .iter()
61 .map(|d| format!("## {}\n\n{}", d.path, d.text.trim_end()))
62 .collect::<Vec<_>>()
63 .join("\n\n")
64}
65
66impl Adapter for OpenSpec {
67 fn tool(&self) -> Option<(&'static str, &'static str)> {
68 Some(("openspec", "npm install -g @fission-ai/openspec"))
69 }
70
71 fn kind(&self) -> FormatKind {
72 FormatKind::Openspec
73 }
74
75 fn detect(&self, start: &Path) -> Option<PathBuf> {
76 Detect::root(self, start)
77 }
78
79 fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
80 speccoder_openspec::inspect(start, feature).ok()
81 }
82
83 fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>) {
84 speccoder_openspec::tasks::parse(path, text)
85 }
86
87 fn rules(&self) -> &'static Rules {
88 &RULES
89 }
90
91 fn wording(&self) -> &'static Wording {
92 &WORDING
93 }
94
95 fn messages(&self) -> &'static Messages {
96 &MESSAGES
97 }
98
99 fn planning(&self) -> &'static Planning {
100 &PLANNING
101 }
102
103 fn bundle(&self, snapshot: &ProjectSnapshot, task_id: &str) -> Result<Bundle, BundleError> {
104 let feature: &FeatureSnapshot = snapshot.feature.as_ref().ok_or(BundleError::NoFeature)?;
105 let tasks = feature.tasks.as_ref().ok_or(BundleError::NoTasks)?;
106 let task = tasks
107 .tasks
108 .iter()
109 .find(|t| t.id == task_id)
110 .ok_or_else(|| BundleError::NotFound(task_id.to_owned()))?;
111 let root = Path::new(&snapshot.root);
112 let schema = snapshot
113 .openspec
114 .as_ref()
115 .map_or(speccoder_openspec::discover::SCHEMA, |m| m.schema.as_str());
116 let (change, _) = speccoder_openspec::load_change(root, &feature.feature.name, schema);
117 let context = snapshot
118 .openspec
119 .as_ref()
120 .and_then(|m| m.context.clone())
121 .unwrap_or_default();
122 let dependencies = feature
123 .schedule
124 .as_ref()
125 .and_then(|s| s.tasks.iter().find(|t| t.id == task.id))
126 .map(|st| {
127 st.depends_on
128 .iter()
129 .filter_map(|d| tasks.tasks.iter().find(|t| t.id == d.id))
130 .map(|t| DependencyContext {
131 id: t.id.clone(),
132 title: t.title.clone(),
133 done: t.done,
134 })
135 .collect()
136 })
137 .unwrap_or_default();
138 Ok(Bundle {
139 feature: feature.feature.name.clone(),
140 constitution: cap(&context),
141 plan: cap(&plan_text(&change)),
142 story: cap(&deltas_text(&change)),
143 task: TaskContext {
144 id: task.id.clone(),
145 title: task.title.clone(),
146 phase: task.phase.clone().unwrap_or_default(),
147 subsection: String::new(),
148 story: String::new(),
149 parallel: false,
150 },
151 dependencies,
152 })
153 }
154
155 fn scenarios(&self, snapshot: &ProjectSnapshot, _task: &str) -> Vec<ScenarioRef> {
158 let Some(f) = snapshot.feature.as_ref() else {
159 return Vec::new();
160 };
161 let schema = snapshot
162 .openspec
163 .as_ref()
164 .map_or(speccoder_openspec::discover::SCHEMA, |m| m.schema.as_str());
165 let (change, _) =
166 speccoder_openspec::load_change(Path::new(&snapshot.root), &f.feature.name, schema);
167 change
168 .deltas
169 .iter()
170 .flat_map(|d| {
171 d.requirements
172 .iter()
173 .filter(|r| {
174 matches!(
175 r.operation,
176 speccoder_openspec::delta::Operation::Added
177 | speccoder_openspec::delta::Operation::Modified
178 )
179 })
180 .flat_map(move |r| {
181 r.scenarios.iter().map(move |sc| ScenarioRef {
182 id: format!("{}: {} / {}", d.capability, r.name, sc.name),
183 text: sc.raw.clone(),
184 })
185 })
186 })
187 .collect()
188 }
189
190 fn acceptance_testable(&self, bundle: &Bundle) -> bool {
192 bundle.story.contains("#### Scenario:")
193 }
194
195 fn is_convergence(&self, phase: &str) -> bool {
197 phase.to_lowercase().contains("convergence")
198 }
199
200 fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4] {
201 let id = unit_dir
202 .file_name()
203 .map(|n| n.to_string_lossy().into_owned())
204 .unwrap_or_default();
205 let mut diags = Vec::new();
206 let config = speccoder_openspec::discover::config(root, &mut diags);
207 let (change, _) = speccoder_openspec::load_change(root, &id, &config.schema);
208 [
209 config.context.unwrap_or_default(),
210 deltas_text(&change),
211 plan_text(&change),
212 std::fs::read_to_string(unit_dir.join("tasks.md")).unwrap_or_default(),
213 ]
214 }
215
216 fn blocking<'s>(&self, snapshot: &'s ProjectSnapshot) -> Vec<&'s Diagnostic> {
220 snapshot
221 .diagnostics
222 .iter()
223 .filter(|d| d.severity == Severity::Error)
224 .filter(|d| !d.code.starts_with("select.") && !d.code.starts_with("graph."))
225 .collect()
226 }
227
228 fn crosscheck(
229 &self,
230 snapshot: &ProjectSnapshot,
231 runner: &dyn crate::verify::process::CommandRunner,
232 ) -> Vec<Diagnostic> {
233 cli::crosscheck(snapshot, runner)
234 }
235
236 fn checklists(&self, _root: &Path, _unit_dir: &str) -> Vec<ChecklistStatus> {
237 Vec::new()
238 }
239
240 fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String> {
241 speccoder_openspec::write::tick(tasks_file, task, line_hash)
242 }
243
244 fn append(
245 &self,
246 tasks_file: &Path,
247 file_hash: &str,
248 items: &[String],
249 ) -> Result<Vec<String>, String> {
250 speccoder_openspec::write::append_convergence(tasks_file, file_hash, items)
251 }
252
253 fn commit_precondition(
254 &self,
255 root: &Path,
256 join: &dyn Fn(&str) -> String,
257 unit_dir: &str,
258 ) -> CommitPrecondition {
259 let touched = speccoder_openspec::discover::spec_files(&root.join(unit_dir).join("specs"))
262 .into_iter()
263 .map(|(cap, _)| join(&format!("openspec/specs/{cap}/spec.md")));
264 CommitPrecondition {
265 strict: vec![join(unit_dir)],
266 tracked: std::iter::once(join("openspec/config.yaml"))
267 .chain(touched)
268 .collect(),
269 runtime: |_| false,
270 essentials: vec![format!("{}/tasks.md", join(unit_dir))],
271 message: "the change's OpenSpec files are not committed, so a worktree would not contain them",
272 hint: "commit them (git add openspec && git commit), or run in place: speccoder run --in-place",
273 no_commits_hint: "commit the OpenSpec files first (git add openspec && git commit)",
274 }
275 }
276}