Skip to main content

speccoder_runtime/format/speckit/
hooks.rs

1//! Spec Kit extension hooks around what speccoder runs.
2//!
3//! Extensions register hooks in `.specify/extensions.yml` (`hooks.<event>`:
4//! `command`, `enabled`, `optional`, `priority`, `condition`, `prompt`).
5//! Spec Kit's agents execute them; Spec Kit's engine only describes them.
6//! speccoder replaces the agent for `implement` and `converge` (and hosts
7//! planning skills), so it owns that step, deterministically:
8//! mandatory hooks (`optional: false`) run as hosted skills under the
9//! planning policy; optional ones are listed for the person; hooks with a
10//! `condition` are skipped (evaluating it is Spec Kit's executor's job).
11
12use std::path::Path;
13
14use serde::Deserialize;
15
16/// The hooks file, relative to the project root.
17pub const FILE: &str = ".specify/extensions.yml";
18
19/// One hook entry.
20#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
21pub struct Hook {
22    /// The extension that registered it.
23    #[serde(default)]
24    pub extension: String,
25    /// The command, e.g. `speckit.git.commit`.
26    #[serde(default)]
27    pub command: String,
28    /// Absent means enabled.
29    #[serde(default = "yes")]
30    pub enabled: bool,
31    /// Absent means optional (Spec Kit's default).
32    #[serde(default = "yes")]
33    pub optional: bool,
34    /// Lower runs first.
35    #[serde(default)]
36    pub priority: Option<i64>,
37    /// An expression for Spec Kit's executor.
38    #[serde(default)]
39    pub condition: Option<serde_json::Value>,
40    /// What an optional hook asks.
41    #[serde(default)]
42    pub prompt: Option<String>,
43    /// What it does.
44    #[serde(default)]
45    pub description: Option<String>,
46}
47
48fn yes() -> bool {
49    true
50}
51
52impl Hook {
53    /// The skill a command installs as (`speckit.git.commit` → `speckit-git-commit`).
54    #[must_use]
55    pub fn skill(&self) -> String {
56        self.command.replace('.', "-")
57    }
58}
59
60/// What to do for one event.
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct Plan {
63    /// Mandatory hooks to run, in priority order.
64    pub run: Vec<Hook>,
65    /// Optional hooks, offered to the person.
66    pub offer: Vec<Hook>,
67    /// Hooks skipped, with why.
68    pub skipped: Vec<(Hook, String)>,
69}
70
71#[derive(Debug, Deserialize)]
72struct RawFile {
73    #[serde(default)]
74    hooks: std::collections::BTreeMap<String, serde_json::Value>,
75}
76
77/// The plan for `event` in the project at `root`. A missing file is no
78/// hooks; an unreadable one is an error (it would silently skip mandatory
79/// hooks otherwise).
80pub fn plan(root: &Path, event: &str) -> Result<Plan, String> {
81    let path = root.join(FILE);
82    let text = match std::fs::read_to_string(&path) {
83        Ok(t) => t,
84        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Plan::default()),
85        Err(e) => return Err(format!("{}: {e}", path.display())),
86    };
87    plan_from(&text, event).map_err(|e| format!("{FILE}: {e} — no hooks were checked"))
88}
89
90/// [`plan`] from the file's text.
91pub fn plan_from(text: &str, event: &str) -> Result<Plan, String> {
92    let file: RawFile = if text.trim().is_empty() {
93        RawFile {
94            hooks: Default::default(),
95        }
96    } else {
97        serde_saphyr::from_str(text).map_err(|e| e.to_string())?
98    };
99    let entries = match file.hooks.get(event) {
100        None | Some(serde_json::Value::Null) => Vec::new(),
101        Some(serde_json::Value::Array(items)) => items.clone(),
102        Some(single @ serde_json::Value::Object(_)) => vec![single.clone()],
103        Some(_) => return Err(format!("hooks.{event} must be a list")),
104    };
105    let mut hooks: Vec<Hook> = entries
106        .into_iter()
107        .filter_map(|v| serde_json::from_value::<Hook>(v).ok())
108        .filter(|h| h.enabled && !h.command.is_empty())
109        .collect();
110    hooks.sort_by_key(|h| h.priority.unwrap_or(10));
111    let mut out = Plan::default();
112    for h in hooks {
113        let conditional = h
114            .condition
115            .as_ref()
116            .is_some_and(|c| !matches!(c, serde_json::Value::Null) && c.as_str() != Some(""));
117        if conditional {
118            out.skipped.push((
119                h,
120                "has a condition (evaluated by Spec Kit's hook executor)".to_owned(),
121            ));
122        } else if h.optional {
123            out.offer.push(h);
124        } else {
125            out.run.push(h);
126        }
127    }
128    Ok(out)
129}
130
131/// The line a person sees for an optional hook.
132#[must_use]
133pub fn offer_line(event: &str, h: &Hook) -> String {
134    format!(
135        "optional {event} hook ({}): {} — run it with `speccoder spec {}`",
136        h.extension,
137        h.prompt
138            .clone()
139            .or_else(|| h.description.clone())
140            .unwrap_or_default(),
141        h.skill()
142    )
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    const FILE_TEXT: &str = r#"
150installed: [git]
151settings:
152  auto_execute_hooks: true
153hooks:
154  before_implement:
155    - extension: git
156      command: speckit.git.commit
157      enabled: true
158      optional: true
159      priority: 10
160      prompt: Commit outstanding changes before implementation?
161    - extension: audit
162      command: speckit.audit.check
163      optional: false
164      priority: 5
165    - extension: x
166      command: speckit.x.conditional
167      optional: false
168      condition: "config.x.enabled"
169    - extension: off
170      command: speckit.off.never
171      enabled: false
172      optional: false
173  after_implement: []
174"#;
175
176    #[test]
177    fn mandatory_hooks_run_optional_ones_are_offered_conditional_ones_skipped() {
178        let p = plan_from(FILE_TEXT, "before_implement").unwrap_or_else(|e| panic!("{e}"));
179        assert_eq!(
180            p.run.iter().map(Hook::skill).collect::<Vec<_>>(),
181            ["speckit-audit-check"]
182        );
183        assert_eq!(
184            p.offer.iter().map(Hook::skill).collect::<Vec<_>>(),
185            ["speckit-git-commit"]
186        );
187        assert_eq!(p.skipped.len(), 1);
188        assert!(offer_line("before_implement", &p.offer[0]).contains("Commit outstanding changes"));
189        assert_eq!(
190            plan_from(FILE_TEXT, "after_implement").unwrap_or_default(),
191            Plan::default()
192        );
193        assert_eq!(
194            plan_from("", "before_implement").unwrap_or_default(),
195            Plan::default()
196        );
197        assert!(plan_from("hooks: [", "x").is_err());
198    }
199}