1pub mod bench;
7pub mod bug;
8pub mod extensions;
9pub mod hooks;
10pub mod preset;
11pub mod python;
12mod quick;
13mod texts;
14pub mod workflow;
15
16use std::path::{Path, PathBuf};
17
18use speccoder_core::ChecklistStatus;
19use speccoder_core::snapshot::{Diagnostic, FeatureSnapshot, ParsedTasks, ProjectSnapshot};
20use speccoder_core::spec::{FormatKind, ScenarioRef};
21
22pub use speccoder_speckit::stories::story_section;
23pub use speccoder_speckit::workspace::Workspace;
24pub use texts::{MESSAGES, PLANNING, RULES, WORDING};
25
26use crate::context::{Bundle, BundleError, DependencyContext, TaskContext, cap};
27use crate::format::{Adapter, CommitPrecondition, Detect, Messages, Planning, Rules, Wording};
28
29#[derive(Debug)]
31pub struct SpecKit;
32
33pub static SPECKIT: SpecKit = SpecKit;
35
36impl Detect for SpecKit {
37 fn kind(&self) -> FormatKind {
38 FormatKind::Speckit
39 }
40
41 fn root(&self, start: &Path) -> Option<PathBuf> {
42 speccoder_speckit::workspace::find_root(start).map(|ws| ws.root().to_path_buf())
43 }
44
45 fn adapter(&self) -> Option<&'static dyn Adapter> {
46 Some(&SPECKIT)
47 }
48}
49
50impl Adapter for SpecKit {
51 fn plan_structure(&self, snapshot: &ProjectSnapshot) -> Option<crate::format::PlanStructure> {
52 use crate::format::{PhaseKind, PlannedTask, code_spans};
53 let feature = snapshot.feature.as_ref()?;
54 let tasks = feature.tasks.as_ref()?;
55 let kind = |phase: &str, story: bool| {
56 let p = phase.to_ascii_lowercase();
57 if p.contains("setup") {
58 PhaseKind::Setup
59 } else if p.contains("foundational") {
60 PhaseKind::Foundational
61 } else if story || p.contains("user story") {
62 PhaseKind::Story
63 } else if p.contains("polish") || p.contains("cross-cutting") {
64 PhaseKind::Polish
65 } else {
66 PhaseKind::Other
67 }
68 };
69 let mut stories = std::collections::BTreeMap::new();
70 let planned = tasks
71 .tasks
72 .iter()
73 .map(|t| {
74 if let Some(st) = &t.story
75 && !stories.contains_key(st)
76 && let Ok(b) = self.bundle(snapshot, &t.id)
77 {
78 stories.insert(st.clone(), b.story);
79 }
80 PlannedTask {
81 id: t.id.clone(),
82 title: t.title.clone(),
83 phase: kind(t.phase.as_deref().unwrap_or_default(), t.story.is_some()),
84 story: t.story.clone(),
85 spans: code_spans(&t.title),
86 done: t.done,
87 }
88 })
89 .collect();
90 Some(crate::format::PlanStructure {
91 tasks: planned,
92 stories,
93 })
94 }
95
96 fn init_steps(&self, choices: &crate::format::FormatInit) -> Vec<crate::format::InitStep> {
97 use speccoder_core::new_project::ExtensionKind;
98 let script = if cfg!(windows) { "ps" } else { "sh" };
99 let mut argv: Vec<String> = [
100 "specify",
101 "init",
102 "--here",
103 "--force",
104 "--non-interactive",
105 "--integration",
106 "claude",
107 "--script",
108 script,
109 "--ignore-agent-tools",
110 ]
111 .map(str::to_owned)
112 .to_vec();
113 if choices.preset {
114 argv.push("--preset".to_owned());
115 argv.push(preset::dir(&choices.state).display().to_string());
116 }
117 for e in &choices.extensions {
118 argv.push("--extension".to_owned());
119 argv.push(e.source.clone());
120 }
121 let hosts: Vec<String> = choices.extensions.iter().filter_map(|e| e.host()).collect();
122 if choices
123 .extensions
124 .iter()
125 .any(|e| e.kind == ExtensionKind::Url)
126 {
127 argv.push("--trust-extension-urls".to_owned());
128 }
129 vec![crate::format::InitStep {
130 argv,
131 hosts,
132 writes: ".specify/, .claude/skills/speckit-*/".to_owned(),
133 env: python::command_env(&|k| std::env::var(k).ok()),
134 marker: ".specify",
135 note: choices
136 .preset
137 .then(|| "with the speccoder preset: tasks that plan for evidence".to_owned()),
138 preset: choices.preset,
139 }]
140 }
141
142 fn prepare_init(&self, choices: &crate::format::FormatInit) -> Result<(), String> {
143 if choices.preset {
144 preset::extract(&choices.state)?;
145 }
146 Ok(())
147 }
148
149 fn doctor_checks(
150 &self,
151 root: &Path,
152 env: &dyn Fn(&str) -> Option<String>,
153 ) -> Vec<speccoder_core::DoctorCheck> {
154 python::warning(root, env)
155 .map(|detail| speccoder_core::DoctorCheck {
156 check: "spec tool python".to_owned(),
157 level: speccoder_core::DoctorLevel::Warn,
158 detail,
159 code: None,
160 fix: python::FIX.iter().map(|f| (*f).to_owned()).collect(),
161 remedy: None,
162 tasks: Vec::new(),
163 commands: Vec::new(),
164 })
165 .into_iter()
166 .collect()
167 }
168
169 fn tool(&self) -> Option<(&'static str, &'static str)> {
170 Some((
171 "specify",
172 "uv tool install specify-cli --from git+https://github.com/github/spec-kit.git",
173 ))
174 }
175
176 fn kind(&self) -> FormatKind {
177 FormatKind::Speckit
178 }
179
180 fn detect(&self, start: &Path) -> Option<PathBuf> {
181 Detect::root(self, start)
182 }
183
184 fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
185 speccoder_speckit::inspect(start, feature).ok()
186 }
187
188 fn open(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
189 let env = std::env::var(texts::FEATURE_ENV)
190 .ok()
191 .filter(|v| !v.is_empty());
192 speccoder_speckit::inspect_with(start, feature, env.as_deref()).ok()
193 }
194
195 fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>) {
196 speccoder_speckit::tasks::parse(path, text)
197 }
198
199 fn rules(&self) -> &'static Rules {
200 &RULES
201 }
202
203 fn wording(&self) -> &'static Wording {
204 &WORDING
205 }
206
207 fn messages(&self) -> &'static Messages {
208 &MESSAGES
209 }
210
211 fn planning(&self) -> &'static Planning {
212 &PLANNING
213 }
214
215 fn current_pointer(&self) -> Option<&'static str> {
216 Some(texts::POINTER)
217 }
218
219 fn set_current(&self, root: &Path, unit_dir: Option<&str>) -> Result<String, String> {
220 let file = root.join(texts::POINTER);
222 match unit_dir {
223 Some(dir) => {
224 let text =
225 serde_json::to_string_pretty(&serde_json::json!({ "feature_directory": dir }))
226 .map_err(|e| e.to_string())?;
227 std::fs::write(&file, format!("{text}\n"))
228 .map_err(|e| format!("{}: {e}", file.display()))?;
229 }
230 None => match std::fs::remove_file(&file) {
231 Ok(()) => {}
232 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
233 Err(e) => return Err(format!("{}: {e}", file.display())),
234 },
235 }
236 Ok(texts::POINTER.to_owned())
237 }
238
239 fn bundle(&self, snapshot: &ProjectSnapshot, task_id: &str) -> Result<Bundle, BundleError> {
240 let feature: &FeatureSnapshot = snapshot.feature.as_ref().ok_or(BundleError::NoFeature)?;
241 let tasks = feature.tasks.as_ref().ok_or(BundleError::NoTasks)?;
242 let task = tasks
243 .tasks
244 .iter()
245 .find(|t| t.id == task_id)
246 .ok_or_else(|| BundleError::NotFound(task_id.to_owned()))?;
247 let ws = Workspace::at(Path::new(&snapshot.root));
248 let read = |rel: &str| {
249 ws.as_ref()
250 .and_then(|ws| ws.read_text(rel).ok())
251 .map(|t| cap(&t))
252 .unwrap_or_default()
253 };
254 let dir = &feature.feature.dir;
255 let spec = read(&format!("{dir}/spec.md"));
256
257 let dependencies = feature
258 .schedule
259 .as_ref()
260 .and_then(|s| s.tasks.iter().find(|t| t.id == task.id))
261 .map(|st| {
262 st.depends_on
263 .iter()
264 .filter_map(|d| tasks.tasks.iter().find(|t| t.id == d.id))
265 .map(|t| DependencyContext {
266 id: t.id.clone(),
267 title: t.title.clone(),
268 done: t.done,
269 })
270 .collect()
271 })
272 .unwrap_or_default();
273 let convergence =
276 task.story.is_none() && self.is_convergence(task.phase.as_deref().unwrap_or_default());
277 let story_tasks = if task.story.is_some() || convergence {
278 tasks
279 .tasks
280 .iter()
281 .filter(|t| {
282 t.id != task.id
283 && match task.story.as_deref() {
284 Some(label) => t.story.as_deref() == Some(label),
285 None => t.story.is_none() && t.phase == task.phase,
286 }
287 })
288 .map(|t| DependencyContext {
289 id: t.id.clone(),
290 title: t.title.clone(),
291 done: t.done,
292 })
293 .collect()
294 } else {
295 Vec::new()
296 };
297
298 Ok(Bundle {
299 feature: feature.feature.name.clone(),
300 constitution: read(texts::CONSTITUTION),
301 plan: read(&format!("{dir}/plan.md")),
302 story: match task.story.as_deref() {
305 Some(label) => {
309 let section = story_section(&spec, label);
310 if section.trim().is_empty() {
311 spec.clone()
312 } else {
313 section
314 }
315 }
316 None if self.is_convergence(task.phase.as_deref().unwrap_or_default()) => {
317 spec.clone()
318 }
319 None => String::new(),
320 },
321 task: TaskContext {
322 id: task.id.clone(),
323 title: task.title.clone(),
324 phase: task.phase.clone().unwrap_or_default(),
325 subsection: task.subsection.clone().unwrap_or_default(),
326 story: task.story.clone().unwrap_or_default(),
327 parallel: task.parallel,
328 },
329 dependencies,
330 story_tasks,
331 })
332 }
333
334 fn scenarios(&self, snapshot: &ProjectSnapshot, task: &str) -> Vec<ScenarioRef> {
335 let Some(f) = snapshot.feature.as_ref() else {
336 return Vec::new();
337 };
338 let label = f
339 .tasks
340 .as_ref()
341 .and_then(|t| t.tasks.iter().find(|t| t.id == task))
342 .and_then(|t| t.story.clone());
343 let spec = Workspace::at(Path::new(&snapshot.root))
344 .and_then(|ws| ws.read_text(&format!("{}/spec.md", f.feature.dir)).ok())
345 .unwrap_or_default();
346 let own = speccoder_speckit::stories::scenario_refs(&spec, label.as_deref());
349 if own.is_empty() && label.is_some() {
350 speccoder_speckit::stories::scenario_refs(&spec, None)
351 } else {
352 own
353 }
354 }
355
356 fn acceptance_testable(&self, bundle: &Bundle) -> bool {
357 bundle.story.to_lowercase().contains("acceptance")
358 }
359
360 fn oracle_requirements(
362 &self,
363 snapshot: &ProjectSnapshot,
364 ) -> Vec<crate::format::OracleRequirement> {
365 let Some(f) = snapshot.feature.as_ref() else {
366 return Vec::new();
367 };
368 let spec = Workspace::at(Path::new(&snapshot.root))
369 .and_then(|ws| ws.read_text(&format!("{}/spec.md", f.feature.dir)).ok())
370 .unwrap_or_default();
371 let (stories, _) = speccoder_speckit::stories::requirements("spec.md", &spec);
372 stories
373 .into_iter()
374 .map(|r| crate::format::OracleRequirement {
375 scenarios: speccoder_speckit::stories::scenario_refs(&spec, Some(&r.key)),
376 key: r.key,
377 title: r.title,
378 text: r.text,
379 })
380 .collect()
381 }
382
383 fn standing_units(&self, root: &Path) -> Vec<(String, speccoder_core::ladder::Rung)> {
385 use speccoder_core::ladder::Rung;
386 let mut out: Vec<(String, Rung)> =
387 std::fs::read_dir(root.join(speccoder_speckit::features::SPECS))
388 .into_iter()
389 .flatten()
390 .flatten()
391 .map(|e| e.file_name().to_string_lossy().into_owned())
392 .filter(|n| speccoder_speckit::features::prefix(n).is_some())
393 .filter_map(|n| {
394 let rung = if n.ends_with("-quick-changes") {
395 Rung::Quick
396 } else if n.ends_with("-bugfixes") {
397 Rung::Bug
398 } else {
399 return None;
400 };
401 Some((n, rung))
402 })
403 .collect();
404 out.push((".specify/bugs".to_owned(), Rung::Bug));
405 out
406 }
407
408 fn is_convergence(&self, phase: &str) -> bool {
410 phase.to_lowercase().contains("convergence")
411 }
412
413 fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4] {
414 let read = |p: PathBuf| std::fs::read_to_string(p).unwrap_or_default();
415 [
416 read(root.join(texts::CONSTITUTION)),
417 read(unit_dir.join("spec.md")),
418 read(unit_dir.join("plan.md")),
419 read(unit_dir.join("tasks.md")),
420 ]
421 }
422
423 fn checklists(&self, root: &Path, unit_dir: &str) -> Vec<ChecklistStatus> {
424 speccoder_speckit::checklists::status(root, unit_dir)
425 }
426
427 fn requirements_file(&self) -> Option<&'static str> {
428 Some("spec.md")
429 }
430
431 fn record_change(
432 &self,
433 root: &Path,
434 request: &str,
435 checks: &[String],
436 ) -> Result<super::Recorded, String> {
437 quick::record(root, request, checks)
438 }
439
440 fn extensions(&self, root: &Path) -> Option<crate::format::Inventory> {
441 extensions::inventory(root)
442 }
443
444 fn bug_unit(
445 &self,
446 root: &Path,
447 ) -> Result<speccoder_core::bug::BugHome, crate::format::BugRefusal> {
448 bug::unit(root)
449 }
450
451 fn record_bug(
452 &self,
453 root: &Path,
454 home: &speccoder_core::bug::BugHome,
455 triad: &speccoder_core::bug::Triad,
456 ) -> Result<crate::format::RecordedBug, crate::format::BugRefusal> {
457 bug::record(root, home, triad)
458 }
459
460 fn bug_step(&self) -> Option<crate::format::BugStep> {
461 Some(bug::STEP)
462 }
463
464 fn bug_dir(&self, home: &speccoder_core::bug::BugHome, slug: &str) -> Option<(String, String)> {
465 bug::dir(home, slug)
466 }
467
468 fn bug_slug(&self, root: &Path, _home: &speccoder_core::bug::BugHome, title: &str) -> String {
469 bug::slug(root, title)
470 }
471
472 fn read_bug(
473 &self,
474 root: &Path,
475 home: &speccoder_core::bug::BugHome,
476 key: &str,
477 ) -> Result<crate::format::ReadBug, crate::format::BugRefusal> {
478 bug::read(root, home, key)
479 }
480
481 fn requirements_in(&self, text: &str) -> Vec<(String, String)> {
482 text.lines()
484 .filter_map(|line| {
485 let rest = line.trim_start().strip_prefix("- **")?;
486 let id = rest.split("**").next()?;
487 let (kind, number) = id.split_once('-')?;
488 (matches!(kind, "FR" | "SC")
489 && !number.is_empty()
490 && number.chars().all(|c| c.is_ascii_digit()))
491 .then(|| (id.to_owned(), line.trim_end().to_owned()))
492 })
493 .collect()
494 }
495
496 fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String> {
497 speccoder_speckit::mark_task_complete(tasks_file, task, line_hash)
498 }
499
500 fn append(
501 &self,
502 tasks_file: &Path,
503 file_hash: &str,
504 items: &[String],
505 ) -> Result<Vec<String>, String> {
506 speccoder_speckit::append_convergence(tasks_file, file_hash, items)
507 }
508
509 fn commit_precondition(
510 &self,
511 _root: &Path,
512 join: &dyn Fn(&str) -> String,
513 unit_dir: &str,
514 ) -> CommitPrecondition {
515 precondition(&join(texts::MARKER), &join(unit_dir))
516 }
517}
518
519#[must_use]
522pub fn precondition(specify_dir: &str, feature_dir: &str) -> CommitPrecondition {
523 CommitPrecondition {
524 strict: vec![feature_dir.to_owned()],
526 tracked: vec![specify_dir.to_owned()],
529 runtime,
530 essentials: vec![
531 format!("{specify_dir}/init-options.json"),
532 format!("{feature_dir}/spec.md"),
533 ],
534 message: "the feature's Spec Kit artifacts are not committed, so a worktree would not contain them",
535 hint: "commit them (git add .specify specs/<feature> && git commit), or run in place: speccoder run --in-place",
536 no_commits_hint: "commit the Spec Kit artifacts first (git add .specify specs && git commit)",
537 }
538}
539
540fn runtime(p: &str) -> bool {
544 p.contains("/workflows/runs/")
545 || p.starts_with(".specify/workflows/runs/")
546 || p.split('/').any(|c| c == "__pycache__")
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 #[test]
554 fn requirement_ids_are_the_bold_fr_and_sc_items() {
555 let dir = tempfile::tempdir().unwrap();
556 std::fs::create_dir_all(dir.path().join("specs/001-x")).unwrap();
557 std::fs::write(
558 dir.path().join("specs/001-x/spec.md"),
559 "## Requirements\n\n- **FR-001**: System MUST parse.\n- **FR-002**: System MUST render.\n - **FR-X**: not one\n- **Key**: not one\n\n## Success\n\n- **SC-001**: Under a second.\n",
560 )
561 .unwrap();
562 let ids: Vec<String> = SpecKit
563 .requirement_ids(dir.path(), "specs/001-x")
564 .into_iter()
565 .map(|(id, hash)| {
566 assert_eq!(hash.len(), 64);
567 id
568 })
569 .collect();
570 assert_eq!(ids, ["FR-001", "FR-002", "SC-001"]);
571 }
572}