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 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 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 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 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 fn acceptance_testable(&self, bundle: &Bundle) -> bool {
271 bundle.story.contains("#### Scenario:")
272 }
273
274 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 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 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}