Skip to main content

tuff_hooks_spec/
lib.rs

1//! Canonical hook metadata for Tuff-standard hooks.
2//!
3//! This crate models the stable vocabulary Tuff uses when a hook is authored in
4//! Tuff's manifest format and then rendered by an adapter into a native harness
5//! format. It does not model native harness hook fragments; those remain
6//! adapter-owned passthrough data.
7
8use serde::{Deserialize, Serialize};
9
10/// Current version of the Tuff hook specification.
11pub const SPEC_VERSION: &str = "0.1.0";
12
13/// Canonical Tuff hook events.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum HookEvent {
17    /// A session or subagent starts.
18    SessionStart,
19    /// A main session ends.
20    SessionEnd,
21    /// A harness is about to execute a tool.
22    PreToolUse,
23    /// A harness has finished executing a tool.
24    PostToolUse,
25    /// A harness is about to finish the current turn or task.
26    BeforeFinish,
27    /// A harness saved a file.
28    AfterSave,
29    /// A harness stop/continuation point.
30    Stop,
31}
32
33impl HookEvent {
34    /// Returns the canonical snake_case event name.
35    pub const fn as_str(self) -> &'static str {
36        match self {
37            Self::SessionStart => "session_start",
38            Self::SessionEnd => "session_end",
39            Self::PreToolUse => "pre_tool_use",
40            Self::PostToolUse => "post_tool_use",
41            Self::BeforeFinish => "before_finish",
42            Self::AfterSave => "after_save",
43            Self::Stop => "stop",
44        }
45    }
46}
47
48impl std::fmt::Display for HookEvent {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.write_str(self.as_str())
51    }
52}
53
54/// Payload type names used by [`PayloadField`].
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum PayloadValueType {
58    /// String value.
59    String,
60    /// Boolean value.
61    Boolean,
62    /// Number value.
63    Number,
64    /// Structured JSON object.
65    Object,
66    /// JSON array.
67    Array,
68}
69
70/// A field expected in a hook payload.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72pub struct PayloadField {
73    /// Field name as it appears in the canonical payload descriptor.
74    pub name: &'static str,
75    /// Field value type.
76    pub value_type: PayloadValueType,
77    /// Whether the field is required for the canonical event.
78    pub required: bool,
79    /// Human-facing field description.
80    pub description: &'static str,
81}
82
83/// A structured descriptor for a canonical event payload.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct PayloadSchema {
86    /// Common and event-specific fields.
87    pub fields: &'static [PayloadField],
88}
89
90impl PayloadSchema {
91    /// Returns true when the descriptor contains no declared fields.
92    pub fn is_empty(&self) -> bool {
93        self.fields.is_empty()
94    }
95}
96
97/// What a hook can block when a handler returns a blocking result.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum BlockingScope {
101    /// The event is not blocking.
102    NotBlocking,
103    /// The hook can block the action that has not yet run.
104    BlocksAction,
105    /// The hook can block continuation after an action or turn.
106    BlocksContinuation,
107    /// Harness-specific blocking semantics documented by the adapter.
108    Custom(&'static str),
109}
110
111/// Canonical event metadata.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113pub struct HookEventSpec {
114    /// Canonical typed event.
115    pub event: HookEvent,
116    /// Event name used in Tuff-standard manifests.
117    pub canonical_name: &'static str,
118    /// Blocking behavior exposed by the canonical event.
119    pub blocking: BlockingScope,
120    /// First Tuff hook spec version that includes this event.
121    pub since_spec_version: &'static str,
122    /// Structured payload descriptor.
123    pub payload_schema: PayloadSchema,
124}
125
126/// Adapter support level for a canonical event.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum CoverageLevel {
130    /// The adapter supports the event for the declared scope.
131    Full,
132    /// The adapter supports the event only for the declared scope.
133    Partial,
134    /// The adapter does not support the event.
135    Unsupported,
136}
137
138impl CoverageLevel {
139    /// Returns whether this coverage level can render/run a hook.
140    pub const fn is_supported(self) -> bool {
141        !matches!(self, Self::Unsupported)
142    }
143}
144
145/// Compatibility for one canonical event on one adapter.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
147pub struct CompatibilityEntry {
148    /// Canonical Tuff hook event.
149    pub event: HookEvent,
150    /// Native harness event name emitted by the adapter, when supported.
151    pub native_event: Option<&'static str>,
152    /// Legacy or native names accepted as aliases in Tuff manifests.
153    pub aliases: &'static [&'static str],
154    /// Support level for this event.
155    pub coverage: CoverageLevel,
156    /// Scope labels that explain partial coverage.
157    pub scope: &'static [&'static str],
158    /// Adapter or harness caveat, if any.
159    pub caveat: Option<&'static str>,
160    /// Source note or URL for the compatibility row.
161    pub source: Option<&'static str>,
162    /// First harness version known to have this behavior.
163    pub since_harness_version: Option<&'static str>,
164    /// Last harness version known to have this behavior.
165    pub until_harness_version: Option<&'static str>,
166}
167
168impl CompatibilityEntry {
169    /// Returns true when this row matches a manifest event name.
170    pub fn matches_name(&self, raw_event: &str) -> bool {
171        self.event.as_str() == raw_event || self.aliases.contains(&raw_event)
172    }
173
174    /// Returns the native event to emit when this row is supported.
175    pub fn native_event_name(&self) -> Option<&'static str> {
176        self.coverage
177            .is_supported()
178            .then_some(self.native_event)
179            .flatten()
180    }
181}
182
183/// Static adapter compatibility data.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
185pub struct CompatibilityMatrix {
186    /// Tuff hook spec version this matrix targets.
187    pub spec_version: &'static str,
188    /// Adapter id, such as `open-agents` or `claude`.
189    pub adapter: &'static str,
190    /// Matrix rows.
191    pub events: &'static [CompatibilityEntry],
192}
193
194impl CompatibilityMatrix {
195    /// Finds the compatibility row for a manifest event name or alias.
196    pub fn find_event(&self, raw_event: &str) -> Option<&CompatibilityEntry> {
197        self.events
198            .iter()
199            .find(|entry| entry.matches_name(raw_event))
200    }
201
202    /// Returns supported native event names for user-facing error messages.
203    pub fn supported_native_events(&self) -> Vec<&'static str> {
204        self.events
205            .iter()
206            .filter_map(CompatibilityEntry::native_event_name)
207            .collect()
208    }
209}
210
211/// Common payload fields available to most Tuff-standard hooks.
212pub const COMMON_PAYLOAD_FIELDS: &[PayloadField] = &[
213    PayloadField {
214        name: "session_id",
215        value_type: PayloadValueType::String,
216        required: false,
217        description: "Harness session identifier, when available.",
218    },
219    PayloadField {
220        name: "cwd",
221        value_type: PayloadValueType::String,
222        required: false,
223        description: "Working directory for the hook invocation.",
224    },
225];
226
227/// Current canonical event metadata.
228pub const EVENT_SPECS: &[HookEventSpec] = &[
229    HookEventSpec {
230        event: HookEvent::SessionStart,
231        canonical_name: "session_start",
232        blocking: BlockingScope::NotBlocking,
233        since_spec_version: SPEC_VERSION,
234        payload_schema: PayloadSchema {
235            fields: COMMON_PAYLOAD_FIELDS,
236        },
237    },
238    HookEventSpec {
239        event: HookEvent::SessionEnd,
240        canonical_name: "session_end",
241        blocking: BlockingScope::NotBlocking,
242        since_spec_version: SPEC_VERSION,
243        payload_schema: PayloadSchema {
244            fields: COMMON_PAYLOAD_FIELDS,
245        },
246    },
247    HookEventSpec {
248        event: HookEvent::PreToolUse,
249        canonical_name: "pre_tool_use",
250        blocking: BlockingScope::BlocksAction,
251        since_spec_version: SPEC_VERSION,
252        payload_schema: PayloadSchema {
253            fields: COMMON_PAYLOAD_FIELDS,
254        },
255    },
256    HookEventSpec {
257        event: HookEvent::PostToolUse,
258        canonical_name: "post_tool_use",
259        blocking: BlockingScope::BlocksContinuation,
260        since_spec_version: SPEC_VERSION,
261        payload_schema: PayloadSchema {
262            fields: COMMON_PAYLOAD_FIELDS,
263        },
264    },
265    HookEventSpec {
266        event: HookEvent::BeforeFinish,
267        canonical_name: "before_finish",
268        blocking: BlockingScope::BlocksContinuation,
269        since_spec_version: SPEC_VERSION,
270        payload_schema: PayloadSchema {
271            fields: COMMON_PAYLOAD_FIELDS,
272        },
273    },
274    HookEventSpec {
275        event: HookEvent::AfterSave,
276        canonical_name: "after_save",
277        blocking: BlockingScope::NotBlocking,
278        since_spec_version: SPEC_VERSION,
279        payload_schema: PayloadSchema {
280            fields: COMMON_PAYLOAD_FIELDS,
281        },
282    },
283    HookEventSpec {
284        event: HookEvent::Stop,
285        canonical_name: "stop",
286        blocking: BlockingScope::BlocksContinuation,
287        since_spec_version: SPEC_VERSION,
288        payload_schema: PayloadSchema {
289            fields: COMMON_PAYLOAD_FIELDS,
290        },
291    },
292];
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    const ENTRY: CompatibilityEntry = CompatibilityEntry {
299        event: HookEvent::PreToolUse,
300        native_event: Some("PreToolUse"),
301        aliases: &["pre_tool_execution"],
302        coverage: CoverageLevel::Full,
303        scope: &["Bash"],
304        caveat: None,
305        source: None,
306        since_harness_version: None,
307        until_harness_version: None,
308    };
309
310    #[test]
311    fn hook_event_display_uses_canonical_name() {
312        assert_eq!(HookEvent::PreToolUse.to_string(), "pre_tool_use");
313    }
314
315    #[test]
316    fn compatibility_entry_matches_canonical_name_and_alias() {
317        assert!(ENTRY.matches_name("pre_tool_use"));
318        assert!(ENTRY.matches_name("pre_tool_execution"));
319        assert!(!ENTRY.matches_name("before_finish"));
320    }
321
322    #[test]
323    fn unsupported_entries_have_no_native_event_name() {
324        let entry = CompatibilityEntry {
325            coverage: CoverageLevel::Unsupported,
326            ..ENTRY
327        };
328
329        assert_eq!(entry.native_event_name(), None);
330    }
331}