Skip to main content

speccoder_runtime/format/speckit/
mod.rs

1//! GitHub Spec Kit, the reference format: `.specify/` and `specs/NNN-name/`
2//! with `spec.md`, `plan.md` and `tasks.md`. Parsing is the
3//! `speccoder-speckit` crate's; this adapter is the only runtime code that
4//! knows Spec Kit's paths and words.
5
6pub 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/// The Spec Kit adapter.
30#[derive(Debug)]
31pub struct SpecKit;
32
33/// The one instance.
34pub 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        // Spec Kit's own per-checkout pointer (git-ignored by `.specify/.gitignore`).
221        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        // A Convergence task's others are its phase's: each closes its own
274        // gap, so its tests leave theirs to them.
275        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            // A Convergence task closes a gap found against the whole spec; it
303            // gets the whole spec, so its acceptance tests have a source.
304            story: match task.story.as_deref() {
305                // A labelled task whose `### User Story N` section is missing
306                // (a spec that left the template's headings) still has the spec
307                // as its intent: acceptance tests are derived from all of it.
308                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        // A labelled task whose story is missing has the whole spec as its
347        // intent, as in its bundle.
348        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    /// Each user story with its scenarios, keyed `USn`, citing `USn-k`.
361    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    /// `NNN-quick-changes` and `NNN-bugfixes`.
384    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    /// A Convergence phase, appended by converge.
409    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        // `- **FR-001**: …` and `- **SC-001**: …` define the identifiers.
483        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/// What a Spec Kit worktree needs committed, given the repository-relative
520/// `.specify` and feature directories.
521#[must_use]
522pub fn precondition(specify_dir: &str, feature_dir: &str) -> CommitPrecondition {
523    CommitPrecondition {
524        // The feature directory: nothing untracked, ignored or modified.
525        strict: vec![feature_dir.to_owned()],
526        // .specify: Spec Kit ignores its own per-checkout files (feature.json);
527        // those are fine. Untracked or modified files are not.
528        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
540/// Runtime state is not an artifact: Spec Kit's own workflow runs
541/// (`.specify/workflows/runs/`, dirty while `specify workflow run` drives
542/// speccoder) and Python bytecode caches of installed step types.
543fn 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}