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.2.0";
12
13/// The spec version that introduced the seven events of the first
14/// published vocabulary. Kept apart from [`SPEC_VERSION`] so that moving
15/// the spec forward does not rewrite when an existing event arrived.
16const FIRST_SPEC_VERSION: &str = "0.1.0";
17
18/// Canonical Tuff hook events.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum HookEvent {
22    /// A session or subagent starts.
23    SessionStart,
24    /// A main session ends.
25    SessionEnd,
26    /// A harness is about to execute a tool.
27    PreToolUse,
28    /// A harness has finished executing a tool.
29    PostToolUse,
30    /// A harness is about to finish the current turn or task.
31    BeforeFinish,
32    /// A harness saved a file.
33    AfterSave,
34    /// A harness stop/continuation point.
35    Stop,
36}
37
38impl HookEvent {
39    /// Returns the canonical snake_case event name.
40    pub const fn as_str(self) -> &'static str {
41        match self {
42            Self::SessionStart => "session_start",
43            Self::SessionEnd => "session_end",
44            Self::PreToolUse => "pre_tool_use",
45            Self::PostToolUse => "post_tool_use",
46            Self::BeforeFinish => "before_finish",
47            Self::AfterSave => "after_save",
48            Self::Stop => "stop",
49        }
50    }
51}
52
53impl std::fmt::Display for HookEvent {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.write_str(self.as_str())
56    }
57}
58
59/// Payload type names used by [`PayloadField`].
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum PayloadValueType {
63    /// String value.
64    String,
65    /// Boolean value.
66    Boolean,
67    /// Number value.
68    Number,
69    /// Structured JSON object.
70    Object,
71    /// JSON array.
72    Array,
73}
74
75/// A field expected in a hook payload.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
77pub struct PayloadField {
78    /// Field name as it appears in the canonical payload descriptor.
79    pub name: &'static str,
80    /// Field value type.
81    pub value_type: PayloadValueType,
82    /// Whether the field is required for the canonical event.
83    pub required: bool,
84    /// Human-facing field description.
85    pub description: &'static str,
86}
87
88/// A structured descriptor for a canonical event payload.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
90pub struct PayloadSchema {
91    /// Common and event-specific fields.
92    pub fields: &'static [PayloadField],
93}
94
95impl PayloadSchema {
96    /// Returns true when the descriptor contains no declared fields.
97    pub fn is_empty(&self) -> bool {
98        self.fields.is_empty()
99    }
100}
101
102/// What a hook can block when a handler returns a blocking result.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum BlockingScope {
106    /// The event is not blocking.
107    NotBlocking,
108    /// The hook can block the action that has not yet run.
109    BlocksAction,
110    /// The hook can block continuation after an action or turn.
111    BlocksContinuation,
112    /// Harness-specific blocking semantics documented by the adapter.
113    Custom(&'static str),
114}
115
116/// Canonical event metadata.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
118pub struct HookEventSpec {
119    /// Canonical typed event.
120    pub event: HookEvent,
121    /// Event name used in Tuff-standard manifests.
122    pub canonical_name: &'static str,
123    /// Blocking behavior exposed by the canonical event.
124    pub blocking: BlockingScope,
125    /// First Tuff hook spec version that includes this event.
126    pub since_spec_version: &'static str,
127    /// Structured payload descriptor.
128    pub payload_schema: PayloadSchema,
129}
130
131/// Adapter support level for a canonical event.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "lowercase")]
134pub enum CoverageLevel {
135    /// The adapter supports the event for the declared scope.
136    Full,
137    /// The adapter supports the event only for the declared scope.
138    Partial,
139    /// The adapter does not support the event.
140    Unsupported,
141}
142
143impl CoverageLevel {
144    /// Returns whether this coverage level can render/run a hook.
145    pub const fn is_supported(self) -> bool {
146        !matches!(self, Self::Unsupported)
147    }
148}
149
150/// Compatibility for one canonical event on one adapter.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
152pub struct CompatibilityEntry {
153    /// Canonical Tuff hook event.
154    pub event: HookEvent,
155    /// Native harness event name emitted by the adapter, when supported.
156    pub native_event: Option<&'static str>,
157    /// Legacy or native names accepted as aliases in Tuff manifests.
158    pub aliases: &'static [&'static str],
159    /// Support level for this event.
160    pub coverage: CoverageLevel,
161    /// Scope labels that explain partial coverage.
162    pub scope: &'static [&'static str],
163    /// Adapter or harness caveat, if any.
164    pub caveat: Option<&'static str>,
165    /// Source note or URL for the compatibility row.
166    pub source: Option<&'static str>,
167    /// First harness version known to have this behavior.
168    pub since_harness_version: Option<&'static str>,
169    /// Last harness version known to have this behavior.
170    pub until_harness_version: Option<&'static str>,
171}
172
173impl CompatibilityEntry {
174    /// Returns true when this row matches a manifest event name.
175    pub fn matches_name(&self, raw_event: &str) -> bool {
176        self.event.as_str() == raw_event || self.aliases.contains(&raw_event)
177    }
178
179    /// Returns the native event to emit when this row is supported.
180    pub fn native_event_name(&self) -> Option<&'static str> {
181        self.coverage
182            .is_supported()
183            .then_some(self.native_event)
184            .flatten()
185    }
186}
187
188/// Static adapter compatibility data.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
190pub struct CompatibilityMatrix {
191    /// Tuff hook spec version this matrix targets.
192    pub spec_version: &'static str,
193    /// Adapter id, such as `open-agents` or `claude`.
194    pub adapter: &'static str,
195    /// Matrix rows.
196    pub events: &'static [CompatibilityEntry],
197}
198
199impl CompatibilityMatrix {
200    /// Finds the compatibility row for a manifest event name or alias.
201    pub fn find_event(&self, raw_event: &str) -> Option<&CompatibilityEntry> {
202        self.events
203            .iter()
204            .find(|entry| entry.event.as_str() == raw_event)
205            .or_else(|| {
206                self.events
207                    .iter()
208                    .find(|entry| entry.aliases.contains(&raw_event))
209            })
210    }
211
212    /// Returns supported native event names, each once, in matrix order.
213    ///
214    /// Two canonical events can render to the same native event (Cursor
215    /// maps both `before_finish` and `stop` to `stop`), so the list is
216    /// de-duplicated.
217    pub fn supported_native_events(&self) -> Vec<&'static str> {
218        let mut names: Vec<&'static str> = Vec::new();
219        for name in self
220            .events
221            .iter()
222            .filter_map(CompatibilityEntry::native_event_name)
223        {
224            if !names.contains(&name) {
225                names.push(name);
226            }
227        }
228        names
229    }
230
231    /// The event names a manifest may use for this adapter, for messages:
232    /// each supported canonical event, then any alias its row accepts, then
233    /// the native event it renders to when that is a different name.
234    ///
235    /// A refusal used to list only native names, which read as though a
236    /// manifest could write them; a manifest names the canonical event,
237    /// and a native name works only where the matrix lists it as an alias.
238    pub fn accepted_events_summary(&self) -> String {
239        let mut parts = Vec::new();
240        for entry in self
241            .events
242            .iter()
243            .filter(|entry| entry.coverage.is_supported())
244        {
245            let canonical = entry.event.as_str();
246            let mut seen: Vec<&str> = vec![canonical];
247            let mut notes: Vec<String> = Vec::new();
248            for alias in entry.aliases {
249                if !seen.contains(alias) {
250                    seen.push(alias);
251                    notes.push((*alias).to_string());
252                }
253            }
254            if let Some(native) = entry.native_event
255                && !seen.contains(&native)
256            {
257                notes.push(format!("renders as {native}"));
258            }
259            parts.push(if notes.is_empty() {
260                canonical.to_string()
261            } else {
262                format!("{canonical} ({})", notes.join(", "))
263            });
264        }
265        parts.join(", ")
266    }
267}
268
269/// Common payload fields available to most Tuff-standard hooks.
270pub const COMMON_PAYLOAD_FIELDS: &[PayloadField] = &[
271    PayloadField {
272        name: "session_id",
273        value_type: PayloadValueType::String,
274        required: false,
275        description: "Harness session identifier, when available.",
276    },
277    PayloadField {
278        name: "cwd",
279        value_type: PayloadValueType::String,
280        required: false,
281        description: "Working directory for the hook invocation.",
282    },
283];
284
285/// Current canonical event metadata.
286pub const EVENT_SPECS: &[HookEventSpec] = &[
287    HookEventSpec {
288        event: HookEvent::SessionStart,
289        canonical_name: "session_start",
290        blocking: BlockingScope::NotBlocking,
291        since_spec_version: FIRST_SPEC_VERSION,
292        payload_schema: PayloadSchema {
293            fields: COMMON_PAYLOAD_FIELDS,
294        },
295    },
296    HookEventSpec {
297        event: HookEvent::SessionEnd,
298        canonical_name: "session_end",
299        blocking: BlockingScope::NotBlocking,
300        since_spec_version: FIRST_SPEC_VERSION,
301        payload_schema: PayloadSchema {
302            fields: COMMON_PAYLOAD_FIELDS,
303        },
304    },
305    HookEventSpec {
306        event: HookEvent::PreToolUse,
307        canonical_name: "pre_tool_use",
308        blocking: BlockingScope::BlocksAction,
309        since_spec_version: FIRST_SPEC_VERSION,
310        payload_schema: PayloadSchema {
311            fields: COMMON_PAYLOAD_FIELDS,
312        },
313    },
314    HookEventSpec {
315        event: HookEvent::PostToolUse,
316        canonical_name: "post_tool_use",
317        blocking: BlockingScope::BlocksContinuation,
318        since_spec_version: FIRST_SPEC_VERSION,
319        payload_schema: PayloadSchema {
320            fields: COMMON_PAYLOAD_FIELDS,
321        },
322    },
323    HookEventSpec {
324        event: HookEvent::BeforeFinish,
325        canonical_name: "before_finish",
326        blocking: BlockingScope::BlocksContinuation,
327        since_spec_version: FIRST_SPEC_VERSION,
328        payload_schema: PayloadSchema {
329            fields: COMMON_PAYLOAD_FIELDS,
330        },
331    },
332    HookEventSpec {
333        event: HookEvent::AfterSave,
334        canonical_name: "after_save",
335        blocking: BlockingScope::NotBlocking,
336        since_spec_version: FIRST_SPEC_VERSION,
337        payload_schema: PayloadSchema {
338            fields: COMMON_PAYLOAD_FIELDS,
339        },
340    },
341    HookEventSpec {
342        event: HookEvent::Stop,
343        canonical_name: "stop",
344        blocking: BlockingScope::BlocksContinuation,
345        since_spec_version: FIRST_SPEC_VERSION,
346        payload_schema: PayloadSchema {
347            fields: COMMON_PAYLOAD_FIELDS,
348        },
349    },
350];
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    const ENTRY: CompatibilityEntry = CompatibilityEntry {
357        event: HookEvent::PreToolUse,
358        native_event: Some("PreToolUse"),
359        aliases: &["pre_tool_execution"],
360        coverage: CoverageLevel::Full,
361        scope: &["Bash"],
362        caveat: None,
363        source: None,
364        since_harness_version: None,
365        until_harness_version: None,
366    };
367
368    #[test]
369    fn supported_native_events_lists_each_name_once() {
370        const EVENTS: &[CompatibilityEntry] = &[
371            CompatibilityEntry {
372                event: HookEvent::BeforeFinish,
373                native_event: Some("stop"),
374                aliases: &[],
375                coverage: CoverageLevel::Partial,
376                scope: &[],
377                caveat: None,
378                source: None,
379                since_harness_version: None,
380                until_harness_version: None,
381            },
382            CompatibilityEntry {
383                event: HookEvent::Stop,
384                native_event: Some("stop"),
385                aliases: &[],
386                coverage: CoverageLevel::Full,
387                scope: &[],
388                caveat: None,
389                source: None,
390                since_harness_version: None,
391                until_harness_version: None,
392            },
393        ];
394        let matrix = CompatibilityMatrix {
395            spec_version: SPEC_VERSION,
396            adapter: "test",
397            events: EVENTS,
398        };
399        assert_eq!(matrix.supported_native_events(), vec!["stop"]);
400    }
401
402    #[test]
403    fn accepted_events_summary_leads_with_the_names_a_manifest_can_write() {
404        const EVENTS: &[CompatibilityEntry] = &[
405            CompatibilityEntry {
406                event: HookEvent::PreToolUse,
407                native_event: Some("preToolUse"),
408                aliases: &[],
409                coverage: CoverageLevel::Full,
410                scope: &[],
411                caveat: None,
412                source: None,
413                since_harness_version: None,
414                until_harness_version: None,
415            },
416            CompatibilityEntry {
417                event: HookEvent::PostToolUse,
418                native_event: Some("PostToolUse"),
419                aliases: &["PostToolUse"],
420                coverage: CoverageLevel::Full,
421                scope: &[],
422                caveat: None,
423                source: None,
424                since_harness_version: None,
425                until_harness_version: None,
426            },
427            CompatibilityEntry {
428                event: HookEvent::Stop,
429                native_event: Some("stop"),
430                aliases: &[],
431                coverage: CoverageLevel::Full,
432                scope: &[],
433                caveat: None,
434                source: None,
435                since_harness_version: None,
436                until_harness_version: None,
437            },
438            CompatibilityEntry {
439                event: HookEvent::AfterSave,
440                native_event: None,
441                aliases: &["FileChanged"],
442                coverage: CoverageLevel::Unsupported,
443                scope: &[],
444                caveat: None,
445                source: None,
446                since_harness_version: None,
447                until_harness_version: None,
448            },
449        ];
450        let matrix = CompatibilityMatrix {
451            spec_version: SPEC_VERSION,
452            adapter: "test",
453            events: EVENTS,
454        };
455        assert_eq!(
456            matrix.accepted_events_summary(),
457            "pre_tool_use (renders as preToolUse), post_tool_use (PostToolUse), stop"
458        );
459    }
460
461    #[test]
462    fn hook_event_display_uses_canonical_name() {
463        assert_eq!(HookEvent::PreToolUse.to_string(), "pre_tool_use");
464    }
465
466    #[test]
467    fn compatibility_entry_matches_canonical_name_and_alias() {
468        assert!(ENTRY.matches_name("pre_tool_use"));
469        assert!(ENTRY.matches_name("pre_tool_execution"));
470        assert!(!ENTRY.matches_name("before_finish"));
471    }
472
473    #[test]
474    fn unsupported_entries_have_no_native_event_name() {
475        let entry = CompatibilityEntry {
476            coverage: CoverageLevel::Unsupported,
477            ..ENTRY
478        };
479
480        assert_eq!(entry.native_event_name(), None);
481    }
482
483    #[test]
484    fn canonical_name_takes_precedence_over_an_earlier_alias() {
485        const EVENTS: &[CompatibilityEntry] = &[
486            CompatibilityEntry {
487                event: HookEvent::BeforeFinish,
488                native_event: Some("stop"),
489                aliases: &["stop"],
490                coverage: CoverageLevel::Partial,
491                scope: &[],
492                caveat: None,
493                source: None,
494                since_harness_version: None,
495                until_harness_version: None,
496            },
497            CompatibilityEntry {
498                event: HookEvent::Stop,
499                native_event: Some("stop"),
500                aliases: &[],
501                coverage: CoverageLevel::Full,
502                scope: &[],
503                caveat: None,
504                source: None,
505                since_harness_version: None,
506                until_harness_version: None,
507            },
508        ];
509        let matrix = CompatibilityMatrix {
510            spec_version: SPEC_VERSION,
511            adapter: "test",
512            events: EVENTS,
513        };
514
515        let matched = matrix.find_event("stop").expect("stop event");
516        assert_eq!(matched.event, HookEvent::Stop);
517        assert_eq!(matched.coverage, CoverageLevel::Full);
518    }
519
520    #[test]
521    fn aliases_remain_available_when_no_canonical_name_matches() {
522        const EVENTS: &[CompatibilityEntry] = &[CompatibilityEntry {
523            event: HookEvent::PreToolUse,
524            native_event: Some("PreToolUse"),
525            aliases: &["BeforeTool"],
526            coverage: CoverageLevel::Full,
527            scope: &[],
528            caveat: None,
529            source: None,
530            since_harness_version: None,
531            until_harness_version: None,
532        }];
533        let matrix = CompatibilityMatrix {
534            spec_version: SPEC_VERSION,
535            adapter: "test",
536            events: EVENTS,
537        };
538
539        assert_eq!(
540            matrix.find_event("BeforeTool").map(|entry| entry.event),
541            Some(HookEvent::PreToolUse)
542        );
543    }
544}