1pub 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#[derive(Debug)]
26pub struct OpenSpec;
27
28pub 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
45fn 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
57fn 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 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 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 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 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 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 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 fn acceptance_testable(&self, bundle: &Bundle) -> bool {
284 bundle.story.contains("#### Scenario:")
285 }
286
287 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 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 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}