pub mod openspec;
pub mod speckit;
mod stubs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use speccoder_core::snapshot::{Diagnostic, ParsedTasks, ProjectSnapshot, Severity};
use speccoder_core::spec::{FormatKind, ScenarioRef};
use speccoder_core::{ChecklistStatus, CommandStatus};
use crate::context::{Bundle, BundleError};
pub use speckit::Workspace;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Recorded {
pub feature: String,
pub dir: String,
pub task: String,
pub requirement: String,
pub title: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Extension {
pub id: String,
pub version: String,
pub enabled: bool,
pub source: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InventorySource {
Cli,
Files,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Inventory {
pub source: InventorySource,
pub entries: Vec<Extension>,
}
impl Inventory {
#[must_use]
pub fn enabled(&self, id: &str) -> Option<&Extension> {
self.entries.iter().find(|e| e.id == id && e.enabled)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BugRefusal {
pub code: &'static str,
pub message: String,
}
impl BugRefusal {
#[must_use]
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordedBug {
pub unit: String,
pub dir: String,
pub requirement: String,
pub task: String,
pub title: String,
pub files: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
pub struct BugStep {
pub skill: &'static str,
pub arguments: fn(&speccoder_core::bug::Triad, &str) -> String,
pub rules: &'static Rules,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadBug {
pub current: Vec<String>,
pub expected: Vec<String>,
pub unchanged: Vec<String>,
pub title: Option<String>,
pub file: String,
}
#[must_use]
pub fn bug_refusal_unsupported(start: &Path) -> Option<BugRefusal> {
Detect::root(&stubs::KIRO, start).map(|_| BugRefusal::new("BUG-NO-UNIT", stubs::KIRO_BUGS))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OracleRequirement {
pub key: String,
pub title: String,
pub text: String,
pub scenarios: Vec<ScenarioRef>,
}
impl OracleRequirement {
#[must_use]
pub fn digest(&self) -> String {
format!(
"sha256:{}",
crate::integrity::sha256_hex(format!("{}\n{}", self.title, self.text).as_bytes())
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormatInit {
pub extensions: Vec<speccoder_core::new_project::ExtensionChoice>,
pub preset: bool,
pub state: PathBuf,
pub cache: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InitStep {
pub argv: Vec<String>,
pub hosts: Vec<String>,
pub writes: String,
pub env: Vec<(String, String)>,
pub marker: &'static str,
pub note: Option<String>,
pub preset: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhaseKind {
Setup,
Foundational,
Story,
Polish,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlannedTask {
pub id: String,
pub title: String,
pub phase: PhaseKind,
pub story: Option<String>,
pub spans: Vec<String>,
pub done: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PlanStructure {
pub tasks: Vec<PlannedTask>,
pub stories: std::collections::BTreeMap<String, String>,
}
#[must_use]
pub fn code_spans(text: &str) -> Vec<String> {
text.split('`')
.enumerate()
.filter(|(i, _)| i % 2 == 1)
.map(|(_, s)| s.to_owned())
.filter(|s| !s.trim().is_empty())
.collect()
}
pub trait Adapter: Send + Sync + std::fmt::Debug {
fn kind(&self) -> FormatKind;
fn detect(&self, start: &Path) -> Option<PathBuf>;
fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot>;
fn open(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
self.inspect(start, feature)
}
fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>);
fn rules(&self) -> &'static Rules;
fn wording(&self) -> &'static Wording;
fn messages(&self) -> &'static Messages;
fn planning(&self) -> &'static Planning;
fn bundle(&self, snapshot: &ProjectSnapshot, task: &str) -> Result<Bundle, BundleError>;
fn scenarios(&self, snapshot: &ProjectSnapshot, task: &str) -> Vec<ScenarioRef>;
fn acceptance_testable(&self, bundle: &Bundle) -> bool;
fn is_convergence(&self, phase: &str) -> bool;
fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4];
fn blocking<'s>(&self, _snapshot: &'s ProjectSnapshot) -> Vec<&'s Diagnostic> {
Vec::new()
}
fn crosscheck(
&self,
_snapshot: &ProjectSnapshot,
_runner: &dyn crate::verify::process::CommandRunner,
) -> Vec<Diagnostic> {
Vec::new()
}
fn checklists(&self, root: &Path, unit_dir: &str) -> Vec<ChecklistStatus>;
fn plan_structure(&self, _snapshot: &ProjectSnapshot) -> Option<PlanStructure> {
None
}
fn doctor_checks(
&self,
_root: &Path,
_env: &dyn Fn(&str) -> Option<String>,
) -> Vec<speccoder_core::DoctorCheck> {
Vec::new()
}
fn tool(&self) -> Option<(&'static str, &'static str)> {
None
}
fn init_steps(&self, _choices: &FormatInit) -> Vec<InitStep> {
Vec::new()
}
fn prepare_init(&self, _choices: &FormatInit) -> Result<(), String> {
Ok(())
}
fn requirements_file(&self) -> Option<&'static str> {
None
}
fn requirements_in(&self, _text: &str) -> Vec<(String, String)> {
Vec::new()
}
fn record_change(
&self,
_root: &Path,
_request: &str,
_checks: &[String],
) -> Result<Recorded, String> {
Err("this spec format has no place for quick changes: write a spec for it".to_owned())
}
fn extensions(&self, _root: &Path) -> Option<Inventory> {
None
}
fn bug_unit(&self, _root: &Path) -> Result<speccoder_core::bug::BugHome, BugRefusal> {
Err(BugRefusal::new(
"BUG-NO-UNIT",
"this spec format has no place for a bug: write a spec for the fix, or `speccoder do` for a change without a reproduction",
))
}
fn record_bug(
&self,
_root: &Path,
_home: &speccoder_core::bug::BugHome,
_triad: &speccoder_core::bug::Triad,
) -> Result<RecordedBug, BugRefusal> {
Err(BugRefusal::new(
"BUG-NO-UNIT",
"this spec format has no standing unit for bugs",
))
}
fn bug_step(&self) -> Option<BugStep> {
None
}
fn bug_dir(
&self,
_home: &speccoder_core::bug::BugHome,
_slug: &str,
) -> Option<(String, String)> {
None
}
fn bug_slug(&self, _root: &Path, _home: &speccoder_core::bug::BugHome, _title: &str) -> String {
String::new()
}
fn read_bug(
&self,
_root: &Path,
_home: &speccoder_core::bug::BugHome,
_key: &str,
) -> Result<ReadBug, BugRefusal> {
Err(BugRefusal::new("BUG-NO-UNIT", "no bug home"))
}
fn oracle_requirements(&self, _snapshot: &ProjectSnapshot) -> Vec<OracleRequirement> {
Vec::new()
}
fn standing_units(&self, _root: &Path) -> Vec<(String, speccoder_core::ladder::Rung)> {
Vec::new()
}
fn requirement_ids(&self, root: &Path, unit_dir: &str) -> Vec<(String, String)> {
let Some(file) = self.requirements_file() else {
return Vec::new();
};
let text = std::fs::read_to_string(root.join(unit_dir).join(file)).unwrap_or_default();
self.requirements_in(&text)
.into_iter()
.map(|(id, line)| (id, crate::integrity::sha256_hex(line.as_bytes())))
.collect()
}
fn current_pointer(&self) -> Option<&'static str> {
None
}
fn set_current(&self, _root: &Path, _unit_dir: Option<&str>) -> Result<String, String> {
Err(self.messages().no_current.to_owned())
}
fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String>;
fn append(
&self,
tasks_file: &Path,
file_hash: &str,
items: &[String],
) -> Result<Vec<String>, String>;
fn commit_precondition(
&self,
root: &Path,
join: &dyn Fn(&str) -> String,
unit_dir: &str,
) -> CommitPrecondition;
}
pub type FoundEnv = fn(&dyn Fn(&str) -> Option<String>) -> Vec<(String, String)>;
#[must_use]
pub fn no_found_env(_: &dyn Fn(&str) -> Option<String>) -> Vec<(String, String)> {
Vec::new()
}
pub type CommandRule = fn(&[String], bool) -> Option<Result<(), String>>;
#[derive(Debug)]
pub struct Rules {
pub intent: &'static [&'static str],
pub intent_reason: &'static str,
pub authoring_path: fn(&str) -> bool,
pub command: CommandRule,
pub env: &'static [(&'static str, &'static str)],
pub found_env: FoundEnv,
pub authoring_refusal: fn(&str) -> String,
}
#[derive(Debug)]
pub struct Wording {
pub product: &'static str,
pub name: &'static str,
pub unit: &'static str,
pub intent_paths: &'static str,
pub intent_code: &'static str,
pub documents: &'static str,
pub authoring: &'static str,
pub authoring_tool: &'static str,
pub story: &'static str,
pub constitution: &'static str,
pub plan: &'static str,
pub plan_short: &'static str,
pub examples: &'static str,
pub convergence_task: &'static str,
pub files: [&'static str; 3],
pub assess: Assess,
}
#[derive(Debug)]
pub struct Assess {
pub rules: &'static str,
pub targets: &'static str,
pub must: &'static str,
pub sources: &'static str,
pub critical: &'static str,
pub refs: &'static str,
}
#[derive(Debug)]
pub struct Messages {
pub tasks_file: &'static str,
pub make_tasks: &'static str,
pub not_a_project: fn(&Path) -> String,
pub no_project: &'static str,
pub no_project_in_worktree: &'static str,
pub not_in_git: &'static str,
pub no_skills: &'static str,
pub checklists: fn(&str) -> String,
pub unit: &'static str,
pub no_current: &'static str,
pub current_shared: &'static str,
pub make_project: (&'static str, &'static str),
}
#[derive(Debug)]
pub struct Planning {
pub prefix: &'static str,
pub core: &'static [&'static str],
pub refused: fn(&str) -> Option<String>,
pub creates: &'static [&'static str],
pub current_env: Option<&'static str>,
pub toward_tasks: fn(bool) -> &'static [&'static str],
pub revises: Option<&'static str>,
pub checks: Option<&'static str>,
}
impl Planning {
#[must_use]
pub fn role_of(&self, short: &str) -> speccoder_core::actions::SkillRole {
use speccoder_core::actions::SkillRole;
let toward = |plan| (self.toward_tasks)(plan).contains(&short);
if self.creates.contains(&short) {
SkillRole::Create
} else if toward(false) || toward(true) {
SkillRole::TowardTasks
} else if self.revises == Some(short) {
SkillRole::Revise
} else if self.checks == Some(short) {
SkillRole::Check
} else {
SkillRole::Other
}
}
}
#[derive(Debug, Clone)]
pub struct CommitPrecondition {
pub strict: Vec<String>,
pub tracked: Vec<String>,
pub runtime: fn(&str) -> bool,
pub essentials: Vec<String>,
pub message: &'static str,
pub hint: &'static str,
pub no_commits_hint: &'static str,
}
impl CommitPrecondition {
#[must_use]
pub fn paths(&self) -> Vec<String> {
self.tracked.iter().chain(&self.strict).cloned().collect()
}
}
fn all() -> [&'static dyn Detect; 3] {
[&speckit::SPECKIT, &openspec::OPENSPEC, &stubs::KIRO]
}
pub trait Detect: Send + Sync {
fn kind(&self) -> FormatKind;
fn root(&self, start: &Path) -> Option<PathBuf>;
fn adapter(&self) -> Option<&'static dyn Adapter>;
}
#[must_use]
pub fn adapters() -> Vec<&'static dyn Adapter> {
all().into_iter().filter_map(|d| d.adapter()).collect()
}
#[must_use]
pub fn adapter(kind: FormatKind) -> Option<&'static dyn Adapter> {
all()
.into_iter()
.find(|d| d.kind() == kind)
.and_then(|d| d.adapter())
}
#[must_use]
pub fn reference() -> &'static dyn Adapter {
&speckit::SPECKIT
}
static REQUEST: OnceLock<FormatKind> = OnceLock::new();
pub fn request(kind: FormatKind) {
let _ = REQUEST.set(kind);
}
pub struct Opened {
pub adapter: &'static dyn Adapter,
pub snapshot: ProjectSnapshot,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refused {
pub status: CommandStatus,
pub message: String,
pub unmarked: bool,
}
pub fn open(start: &Path, feature: Option<&str>) -> Result<Opened, Refused> {
open_with(start, feature, REQUEST.get().copied())
}
pub fn open_with(
start: &Path,
feature: Option<&str>,
explicit: Option<FormatKind>,
) -> Result<Opened, Refused> {
let configured = match explicit {
Some(_) => None,
None => configured(start)?,
};
let (adapter, mut warnings) = resolve(start, explicit.or(configured), &all())?;
let mut snapshot = adapter.open(start, feature).ok_or_else(|| Refused {
status: CommandStatus::NotAProject,
message: (adapter.messages().not_a_project)(start),
unmarked: true,
})?;
snapshot.diagnostics.append(&mut warnings);
Ok(Opened { adapter, snapshot })
}
#[must_use]
pub fn at(root: &Path) -> &'static dyn Adapter {
let chosen = REQUEST
.get()
.copied()
.or_else(|| configured(root).ok().flatten());
resolve(root, chosen, &all()).map_or_else(|_| reference(), |(a, _)| a)
}
#[must_use]
pub fn of(snapshot: &ProjectSnapshot) -> &'static dyn Adapter {
at(Path::new(&snapshot.root))
}
fn resolve(
start: &Path,
chosen: Option<FormatKind>,
formats: &[&'static dyn Detect],
) -> Result<(&'static dyn Adapter, Vec<Diagnostic>), Refused> {
let refuse = |message: String| Refused {
status: CommandStatus::NotAProject,
message,
unmarked: false,
};
if let Some(kind) = chosen {
return formats
.iter()
.find(|d| d.kind() == kind)
.and_then(|d| d.adapter())
.map(|a| (a, Vec::new()))
.ok_or_else(|| refuse(not_yet(kind)));
}
let mut found: Vec<(&'static dyn Detect, PathBuf)> = formats
.iter()
.filter_map(|d| d.root(start).map(|r| (*d, r)))
.collect();
let nearest = found.iter().map(|(_, r)| r.components().count()).max();
found.retain(|(_, r)| Some(r.components().count()) == nearest);
let supported: Vec<&'static dyn Adapter> =
found.iter().filter_map(|(d, _)| d.adapter()).collect();
let others = || found.iter().filter(|(d, _)| d.adapter().is_none());
match supported.as_slice() {
[one] => {
let warnings = others()
.map(|(d, root)| {
Diagnostic::new(
Severity::Warning,
"format.unsupported-marker",
format!(
"{} is also present at {}; {}",
d.kind().title(),
root.display(),
not_yet(d.kind())
),
)
})
.collect();
Ok((*one, warnings))
}
[] => match others().next() {
Some((d, root)) => Err(refuse(format!(
"found {} project at {}; {}",
article(d.kind()),
root.display(),
not_yet(d.kind())
))),
None => Ok((reference(), Vec::new())),
},
many => Err(Refused {
unmarked: false,
status: CommandStatus::DiagnosticsError,
message: format!(
"this project is in more than one format ({}); choose one with --format",
many.iter()
.map(|a| a.kind().name())
.collect::<Vec<_>>()
.join(", ")
),
}),
}
}
fn not_yet(kind: FormatKind) -> String {
match kind {
FormatKind::Kiro => {
"speccoder runs Spec Kit and OpenSpec projects, not Kiro specs".to_owned()
}
k => format!("speccoder does not run {} projects", k.title()),
}
}
fn article(kind: FormatKind) -> String {
match kind {
FormatKind::Openspec => "an OpenSpec".to_owned(),
k => format!("a {}", k.title()),
}
}
fn configured(start: &Path) -> Result<Option<FormatKind>, Refused> {
let Some(dir) = crate::verify::find_config(start) else {
return Ok(None);
};
let Ok(text) = std::fs::read_to_string(dir.join(crate::verify::config::FILE)) else {
return Ok(None);
};
parse_config(&text).map_err(|e| Refused {
unmarked: false,
status: CommandStatus::ConfigError,
message: format!("speccoder.toml: {e}"),
})
}
pub fn parse_config(text: &str) -> Result<Option<FormatKind>, String> {
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Spec {
format: Option<String>,
}
let Ok(table) = toml::from_str::<toml::Table>(text) else {
return Ok(None);
};
let Some(spec) = table.get("spec") else {
return Ok(None);
};
let spec: Spec = spec
.clone()
.try_into()
.map_err(|e: toml::de::Error| format!("[spec]: {}", e.message()))?;
let [a, b, c] = FormatKind::ALL.map(FormatKind::name);
spec.format
.map(|f| {
f.parse()
.map_err(|_| format!("[spec]: format = \"{f}\" ({a}, {b} or {c})"))
})
.transpose()
}
#[cfg(test)]
mod tests {
use super::*;
struct Fake(FormatKind, bool);
impl Detect for Fake {
fn kind(&self) -> FormatKind {
self.0
}
fn root(&self, _: &Path) -> Option<PathBuf> {
Some(PathBuf::from("/p"))
}
fn adapter(&self) -> Option<&'static dyn Adapter> {
self.1.then(reference)
}
}
struct At(FormatKind, &'static str);
impl Detect for At {
fn kind(&self) -> FormatKind {
self.0
}
fn root(&self, _: &Path) -> Option<PathBuf> {
Some(PathBuf::from(self.1))
}
fn adapter(&self) -> Option<&'static dyn Adapter> {
Some(reference())
}
}
#[test]
fn the_nearest_project_decides() {
static OUTER: At = At(FormatKind::Speckit, "/repo");
static INNER: At = At(FormatKind::Openspec, "/repo/sub/project");
let (_, w) = resolve(Path::new("/repo/sub/project/x"), None, &[&OUTER, &INNER]).unwrap();
assert!(w.is_empty());
static TWIN: At = At(FormatKind::Speckit, "/repo/sub/project");
assert!(resolve(Path::new("/x"), None, &[&TWIN, &INNER]).is_err());
}
static SK: Fake = Fake(FormatKind::Speckit, true);
static OS: Fake = Fake(FormatKind::Openspec, false);
static OS_RUNS: Fake = Fake(FormatKind::Openspec, true);
static KIRO: Fake = Fake(FormatKind::Kiro, false);
#[test]
fn a_choice_wins_and_an_unsupported_one_is_refused() {
let p = Path::new("/p");
let (a, w) = resolve(p, Some(FormatKind::Speckit), &[&SK, &OS]).unwrap();
assert_eq!((a.kind(), w.len()), (FormatKind::Speckit, 0));
let r = resolve(p, Some(FormatKind::Openspec), &[&SK, &OS])
.err()
.unwrap();
assert_eq!(r.status, CommandStatus::NotAProject);
assert_eq!(r.message, "speccoder does not run OpenSpec projects");
let r = resolve(p, Some(FormatKind::Kiro), &[&SK, &KIRO])
.err()
.unwrap();
assert!(r.message.ends_with("not Kiro specs"));
}
#[test]
fn detection_warns_about_other_markers_and_refuses_ambiguity() {
let p = Path::new("/p");
let (a, w) = resolve(p, None, &[&SK, &OS]).unwrap();
assert_eq!(a.kind(), FormatKind::Speckit);
assert_eq!(w.len(), 1);
assert_eq!(w[0].code, "format.unsupported-marker");
let r = resolve(p, None, &[&OS]).err().unwrap();
assert_eq!(
r.message,
"found an OpenSpec project at /p; speccoder does not run OpenSpec projects"
);
let r = resolve(p, None, &[&SK, &OS_RUNS]).err().unwrap();
assert_eq!(r.status, CommandStatus::DiagnosticsError);
assert!(
r.message.contains("choose one with --format"),
"{}",
r.message
);
}
#[test]
fn the_spec_section_names_a_format() {
assert_eq!(parse_config(""), Ok(None));
assert_eq!(
parse_config("[spec]\nformat = \"openspec\"\n"),
Ok(Some(FormatKind::Openspec))
);
assert_eq!(
parse_config("[spec]\nformat = \"x\"\n"),
Err("[spec]: format = \"x\" (speckit, openspec or kiro)".to_owned())
);
assert!(
parse_config("[spec]\nkind = 1\n")
.unwrap_err()
.starts_with("[spec]: unknown field")
);
assert_eq!(parse_config("[format]\nargv = [\"x\"]\n"), Ok(None));
}
}