Skip to main content

mars_schema/hooks/
targets.rs

1//! What each target root takes of a hook (D11): one [`Target`] row per root, holding where its
2//! hooks are placed (the merge file and its trust gate, or the file directory), the events it
3//! accepts, the harness version that table was last checked against, how `[hooks]` is
4//! rendered for it (for a TypeScript harness, its glue, limits and fragment contract), and
5//! what its generated files are called. A new target is one row here; the binary only places
6//! what the row describes. Nothing about a target is kept anywhere else.
7use super::typescript::{self, Harness};
8
9/// A hook event of one target and the input field its `matcher` tests.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct Event {
12    pub name: &'static str,
13    /// The input field the matcher tests (`tool_name`, `source`, …). `None` means no field is
14    /// recorded for this event; only the events a generated adapter renders need one.
15    pub matcher: Option<&'static str>,
16    /// Claude Code's default `timeout` for a `command` handler on this event, in
17    /// milliseconds. A generated TypeScript hook applies the Claude row's value so it times
18    /// out as it would on Claude; the other targets' rows carry the common default unread.
19    pub default_timeout_ms: u32,
20}
21
22/// The default of every event but the two below. Claude Code docs, "Hook handler fields",
23/// `timeout`: https://code.claude.com/docs/en/hooks (read 2026-10-28).
24const DEFAULT_TIMEOUT_MS: u32 = 600_000;
25
26impl Event {
27    /// The row with a shorter Claude default. The docs lower it to 30 s on
28    /// `UserPromptSubmit` (and on the `PreModelSwitch`/`PostModelSwitch`, 10 s on
29    /// `MessageDisplay`, which the Claude table below does not list) and give `SessionEnd`
30    /// a 1.5 s budget.
31    const fn timeout_ms(self, default_timeout_ms: u32) -> Self {
32        Self {
33            default_timeout_ms,
34            ..self
35        }
36    }
37
38    /// A tool event: its handlers can carry Claude's `if` filter.
39    pub fn is_tool_event(&self) -> bool {
40        self.matcher == Some("tool_name")
41    }
42}
43
44const fn event(name: &'static str) -> Event {
45    Event {
46        name,
47        matcher: None,
48        default_timeout_ms: DEFAULT_TIMEOUT_MS,
49    }
50}
51
52const fn matched(name: &'static str, field: &'static str) -> Event {
53    Event {
54        name,
55        matcher: Some(field),
56        default_timeout_ms: DEFAULT_TIMEOUT_MS,
57    }
58}
59
60/// The hook config file of a merge target, where its entries are appended.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct MergeFile {
63    /// Under the target root.
64    pub file: &'static str,
65    /// Keys set at the top of the file before appending (Cursor's `version`).
66    pub top_level: &'static [(&'static str, i64)],
67    /// The harness skips hooks it has not been told to trust, keyed on their position in
68    /// `file` (Codex): a sync that moves or adds one says so.
69    pub trust_gated: bool,
70}
71
72/// How a target root takes a hook.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum Placement {
75    /// The hook's events are merged into the target's hook config file.
76    Merge(MergeFile),
77    /// The hook is installed as whole files in `dir`, under the target root.
78    File { dir: &'static str },
79}
80
81/// How `[hooks]` becomes a target's entries.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum Rules {
84    /// Claude takes the table as written.
85    Claude,
86    /// Codex's parser: handlers and matchers it cannot run are left out (D17).
87    Codex,
88    /// Generated TypeScript for the harness (`hooks::typescript`).
89    TypeScript(&'static Harness),
90    /// Nothing is rendered for the target, as a whole; `reason` says why.
91    Withheld { reason: &'static str },
92}
93
94/// What a renderer needs of a target: the events it accepts and the rules its parser applies.
95/// The two vary independently: the plugin tree's `hooks.json` is Claude's events under
96/// Codex's rules.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct Profile {
99    /// Every event the target accepts.
100    pub events: &'static [Event],
101    /// The harness version `events` was last checked against; `None` while the rows are
102    /// unchecked.
103    pub verified_against: Option<&'static str>,
104    pub rules: Rules,
105}
106
107impl Profile {
108    /// The profile's row for `name`.
109    pub fn event(&self, name: &str) -> Option<&'static Event> {
110        self.events.iter().find(|e| e.name == name)
111    }
112
113    /// Whether the rules render `[hooks]` at all.
114    pub fn renders(&self) -> bool {
115        !matches!(self.rules, Rules::Withheld { .. })
116    }
117}
118
119/// One target root's hook rules.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct Target {
122    pub root: &'static str,
123    pub placement: Placement,
124    pub profile: Profile,
125    /// Events the harness reads that `profile.events` has no row for: they travel only in a
126    /// fragment marked `unchecked`.
127    pub other_events: &'static [&'static str],
128}
129
130/// Where a file-placement target's generated file gets its suffix: the hook name ends in
131/// neither, so a generated file never collides with an authored one.
132const GENERATED_SUFFIX: &str = super::RESERVED_NAME_SUFFIX;
133
134impl Target {
135    /// Whether the harness reads `event` from its hook file; it ignores any other key.
136    pub fn reads(&self, event: &str) -> bool {
137        self.profile.event(event).is_some() || self.other_events.contains(&event)
138    }
139
140    /// The file a merge target appends entries to; `None` for a file target.
141    pub fn merge_file(&self) -> Option<&MergeFile> {
142        match &self.placement {
143            Placement::Merge(merge) => Some(merge),
144            Placement::File { .. } => None,
145        }
146    }
147
148    /// What is wrong with a file target's fragment, if anything. Warning-only: a file
149    /// fragment is otherwise opaque, and the caller words the diagnostic.
150    pub fn fragment_problem(&self, content: &str) -> Option<&'static str> {
151        match self.profile.rules {
152            Rules::TypeScript(harness) => harness.fragment_problem(content),
153            _ => None,
154        }
155    }
156
157    /// Whether the target installs whole files rather than merging entries.
158    pub fn is_file(&self) -> bool {
159        matches!(self.placement, Placement::File { .. })
160    }
161
162    /// The path, relative to the target root, of the hook's own fragment file; `None` for a
163    /// merge target, which has no per-hook file.
164    pub fn fragment_path(&self, hook_name: &str) -> Option<String> {
165        let Placement::File { dir } = self.placement else {
166            return None;
167        };
168        Some(format!("{dir}/mars-{hook_name}.ts"))
169    }
170
171    /// The path, relative to the target root, of the file generated from the hook's
172    /// `[hooks]`; `None` for a merge target.
173    pub fn generated_path(&self, hook_name: &str) -> Option<String> {
174        let Placement::File { dir } = self.placement else {
175            return None;
176        };
177        Some(format!("{dir}/mars-{hook_name}{GENERATED_SUFFIX}.ts"))
178    }
179}
180
181// Claude Code 2.1.312 (`CLAUDE_VERSION`); event names from https://code.claude.com/docs/en/hooks (2026-07-24),
182// matcher fields from research/claude-codex-cursor-answers.md § Matchers per moment.
183const CLAUDE_EVENTS: &[Event] = &[
184    matched("SessionStart", "source"),
185    event("Setup"),
186    event("UserPromptSubmit").timeout_ms(30_000),
187    event("UserPromptExpansion"),
188    matched("PreToolUse", "tool_name"),
189    matched("PermissionRequest", "tool_name"),
190    matched("PermissionDenied", "tool_name"),
191    matched("PostToolUse", "tool_name"),
192    matched("PostToolUseFailure", "tool_name"),
193    event("PostToolBatch"),
194    matched("SubagentStart", "agent_type"),
195    matched("SubagentStop", "agent_type"),
196    event("TaskCreated"),
197    event("TaskCompleted"),
198    event("Stop"),
199    event("StopFailure"),
200    event("TeammateIdle"),
201    matched("PreCompact", "trigger"),
202    matched("PostCompact", "trigger"),
203    event("Elicitation"),
204    event("ElicitationResult"),
205    matched("Notification", "notification_type"),
206    event("ConfigChange"),
207    event("InstructionsLoaded"),
208    event("CwdChanged"),
209    event("FileChanged"),
210    event("WorktreeCreate"),
211    event("WorktreeRemove"),
212    matched("SessionEnd", "reason").timeout_ms(1_500),
213];
214
215// codex-cli 0.164.0: `HookEventsToml` (codex-rs/config/src/hook_config.rs L39-69) names
216// fourteen events. These are the ones Claude also has; the Codex-only ones (`PreModelRequest`,
217// `PostModelRequest`, `Interrupt`) stay in a Codex fragment. `SessionEnd` fires on 0.164.0
218// (`reason` is always `other`; research/live-unverified-rows.md); 0.144.4 did not.
219const CODEX_EVENTS: &[Event] = &[
220    matched("SessionStart", "source"),
221    event("UserPromptSubmit"),
222    matched("PreToolUse", "tool_name"),
223    matched("PermissionRequest", "tool_name"),
224    matched("PostToolUse", "tool_name"),
225    matched("PreCompact", "trigger"),
226    matched("PostCompact", "trigger"),
227    matched("SubagentStart", "agent_type"),
228    matched("SubagentStop", "agent_type"),
229    event("Stop"),
230    matched("SessionEnd", "reason"),
231];
232
233// cursor-agent 2026.07.16 installed-binary validator, extracted and
234// verified by p5714 on 2026-07-24; p5695 provided the docs baseline. Matcher fields are
235// recorded when Cursor renders generated hooks (0.26).
236const CURSOR_EVENTS: &[Event] = &[
237    event("beforeShellExecution"),
238    event("beforeMCPExecution"),
239    event("afterShellExecution"),
240    event("afterMCPExecution"),
241    event("beforeReadFile"),
242    event("afterFileEdit"),
243    event("beforeTabFileRead"),
244    event("afterTabFileEdit"),
245    event("stop"),
246    event("beforeSubmitPrompt"),
247    event("afterAgentResponse"),
248    event("afterAgentThought"),
249    event("sessionStart"),
250    event("sessionEnd"),
251    event("preCompact"),
252    event("subagentStart"),
253    event("subagentStop"),
254    event("preToolUse"),
255    event("postToolUse"),
256    event("postToolUseFailure"),
257    event("workspaceOpen"),
258];
259
260// What the generated OpenCode plugin registers (verified live on OpenCode 2.2.0, `matcher`
261// fields included). `PreCompact` has no matcher field: its compaction callback does not say
262// why it ran.
263const OPENCODE_EVENTS: &[Event] = &[
264    matched("PreToolUse", "tool_name"),
265    matched("PostToolUse", "tool_name"),
266    matched("PostToolUseFailure", "tool_name"),
267    event("UserPromptSubmit"),
268    event("PreCompact"),
269    matched("PermissionRequest", "tool_name"),
270];
271
272// What the generated Pi extension registers (Pi 1.4.0).
273const PI_EVENTS: &[Event] = &[
274    matched("PreToolUse", "tool_name"),
275    matched("PostToolUse", "tool_name"),
276    matched("PostToolUseFailure", "tool_name"),
277    event("UserPromptSubmit"),
278    matched("SessionStart", "source"),
279    event("Stop"),
280    matched("PreCompact", "trigger"),
281    matched("PostCompact", "trigger"),
282    matched("SessionEnd", "reason"),
283];
284
285const CLAUDE_VERSION: &str = "Claude Code 2.1.312";
286
287/// What the Claude plugin tree's `hooks/hooks.json` must satisfy: Claude's events under
288/// Codex's handler rules, because Claude, Codex and Grok all read that one file (D21).
289pub const PLUGIN_CLAUDE_TREE: Profile = Profile {
290    events: CLAUDE_EVENTS,
291    verified_against: Some(CLAUDE_VERSION),
292    rules: Rules::Codex,
293};
294
295const TARGETS: &[Target] = &[
296    Target {
297        root: ".claude",
298        // Hook commands carry machine-local cache paths, so they belong in the gitignored
299        // `settings.local.json` rather than the committed `settings.json`.
300        placement: Placement::Merge(MergeFile {
301            file: "settings.local.json",
302            top_level: &[],
303            trust_gated: false,
304        }),
305        profile: Profile {
306            events: CLAUDE_EVENTS,
307            verified_against: Some(CLAUDE_VERSION),
308            rules: Rules::Claude,
309        },
310        other_events: &[],
311    },
312    Target {
313        root: ".codex",
314        placement: Placement::Merge(MergeFile {
315            file: "hooks.json",
316            top_level: &[],
317            trust_gated: true,
318        }),
319        profile: Profile {
320            events: CODEX_EVENTS,
321            verified_against: Some("codex-cli 0.164.0"),
322            rules: Rules::Codex,
323        },
324        other_events: &["PreModelRequest", "PostModelRequest", "Interrupt"],
325    },
326    Target {
327        root: ".cursor",
328        // Cursor's `hooks.json` is a flat entry array and requires the version wrapper.
329        placement: Placement::Merge(MergeFile {
330            file: "hooks.json",
331            top_level: &[("version", 1)],
332            trust_gated: false,
333        }),
334        profile: Profile {
335            events: CURSOR_EVENTS,
336            verified_against: Some("cursor-agent 2026.07.16"),
337            rules: Rules::Withheld {
338                reason: "mars renders no generated hooks for Cursor before 0.26",
339            },
340        },
341        other_events: &[],
342    },
343    Target {
344        root: ".opencode",
345        placement: Placement::File { dir: "plugins" },
346        profile: Profile {
347            events: OPENCODE_EVENTS,
348            verified_against: Some("OpenCode 2.2.0"),
349            rules: Rules::TypeScript(&typescript::OPENCODE),
350        },
351        other_events: &[],
352    },
353    Target {
354        root: ".pi",
355        placement: Placement::File { dir: "extensions" },
356        profile: Profile {
357            events: PI_EVENTS,
358            verified_against: Some("Pi 1.4.0"),
359            rules: Rules::TypeScript(&typescript::PI),
360        },
361        other_events: &[],
362    },
363];
364
365/// The hook rules of `root`; `None` when it has no hook mechanism.
366pub fn target(root: &str) -> Option<&'static Target> {
367    TARGETS.iter().find(|target| target.root == root)
368}
369
370/// Every target that takes hooks.
371pub fn all() -> &'static [Target] {
372    TARGETS
373}
374
375/// Claude's event of that name: the set a `[hooks]` table may declare.
376pub fn claude_event(name: &str) -> Option<&'static Event> {
377    CLAUDE_EVENTS.iter().find(|event| event.name == name)
378}
379
380/// Claude's event names, for a message.
381pub fn claude_event_names() -> String {
382    CLAUDE_EVENTS
383        .iter()
384        .map(|event| event.name)
385        .collect::<Vec<_>>()
386        .join(", ")
387}