jevhooks.git / web / src / hooks.rs
hooks.rsannotatedhooks.rssource289 lines · 11.2 KB · raw

Every point at which Claude Code lets a plugin in, and what jevhooks does there: the page's map.

The list of events and what is done with each is not written here. It is the plugin's own (jevhooks_events::HookEvent and its role), the same code the mod and the daemon are compiled from, so the page cannot say an event is judged when the plugin does not judge it. What is written here is where an event sits in a session, one sentence on what happens there today, and the ideas that have been written down for it. An idea is only ever one somebody wrote down: nothing here is a promise, and an event with no idea says so.

10use jevhooks_events::{HookEvent, Role};

Where in a session an event is raised, in the order a session meets them.

13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum Phase {
15    Start,
16    Prompt,
17    Loop,
18    TurnEnd,
19    Between,
20    Whenever,
21}
23impl Phase {
24    pub const ALL: [Phase; 6] = [Phase::Start, Phase::Prompt, Phase::Loop, Phase::TurnEnd, Phase::Between, Phase::Whenever];
25
26    pub fn title(self) -> &'static str {
27        match self {
28            Phase::Start => "A session starts",
29            Phase::Prompt => "You send a prompt",
30            Phase::Loop => "The assistant works (the loop)",
31            Phase::TurnEnd => "The turn ends",
32            Phase::Between => "Between turns, and at the end",
33            Phase::Whenever => "Whenever they happen",
34        }
35    }
36}

What jevhooks does with an event today. The first three are the plugin's own Role; the last two split "nothing" by whether an idea for it has been written down.

40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum State {

Jev is asked, and the answer changes what happens.

43    Judged,

Sent to the daemon, which needs it to judge something else.

45    Heard,

Nothing yet, and an idea is written down.

47    Idea,

Nothing, and nothing planned.

49    Nothing,
50}
52impl State {

The states an event is in. Idea is on the legend too, counted apart (ideas).

54    pub const ALL: [State; 4] = [State::Judged, State::Heard, State::Idea, State::Nothing];

The word on the chip.

57    pub fn word(self) -> &'static str {
58        match self {
59            State::Judged => "judged",
60            State::Heard => "heard",
61            State::Idea => "idea",
62            State::Nothing => "nothing yet",
63        }
64    }

The legend's sentence.

67    pub fn means(self) -> &'static str {
68        match self {
69            State::Judged => "Built. Jev is asked, and the answer changes what happens.",
70            State::Heard => "Built. The plugin listens, because it needs this to judge something else.",
71            State::Idea => "Not built. An addition somebody has written down for an event; it is not a promise.",
72            State::Nothing => "Not built, and nothing is planned.",
73        }
74    }

The chip's class in page.css.

77    pub fn class(self) -> &'static str {
78        match self {
79            State::Judged => "judged",
80            State::Heard => "heard",
81            State::Idea => "idea",
82            State::Nothing => "nothing",
83        }
84    }
85}

One event on the map.

88pub struct Hook {
89    pub event: HookEvent,
90    pub phase: Phase,

What happens there today; empty when nothing does.

92    pub today: &'static str,

Ideas written down for it, each a sentence.

94    pub ideas: &'static [&'static str],
95}
97impl Hook {

The event's name, as Claude Code spells it.

99    pub fn name(&self) -> String {
100        format!("{:?}", self.event)
101    }

From the plugin's own role for the event, so it is never claimed here and absent there.

104    pub fn state(&self) -> State {
105        match (self.event.role(), self.ideas.is_empty()) {
106            (Role::Decide, _) => State::Judged,
107            (Role::Observe, _) => State::Heard,
108            (Role::Ignore, false) => State::Idea,
109            (Role::Ignore, true) => State::Nothing,
110        }
111    }
112}
114const OUTCOME: &str = "What became of a command Jev judged is written to the log, and the memory booked for it is given back.";

Where event sits and what is said of it. No _ arm on purpose: the day Claude Code adds an event and it is added to HookEvent, this stops compiling until someone places it.

118pub fn hook(event: HookEvent) -> Hook {
119    use HookEvent as E;
120    let (phase, today, ideas): (Phase, &'static str, &'static [&'static str]) = match event {
121        E::Setup => (Phase::Start, "", &[]),
122        E::SessionStart => (Phase::Start, "", &[]),
123
124        E::UserPromptSubmit => (
125            Phase::Prompt,
126            "The last five prompts are kept: a turn's end is judged against what you asked for. A new prompt also settles any judged command that never ran.",
127            &[
128                "Attach the standing rule the prompt is about, chosen from the project's own rule files.",
129                "Notice \"note this\" and remind the assistant to write the note first.",
130                "Notice a correction and record it, so it is not needed twice.",
131                "Notice a request that does not say what done looks like.",
132            ],
133        ),
134        E::UserPromptExpansion => (Phase::Prompt, "", &[]),
135
136        E::PreToolUse => (
137            Phase::Loop,
138            "Every Bash command that is not plainly read-only: what kind of act it is, how hard to undo, how much memory it needs. It runs, is put to you, waits for memory, or is left to your own rules.",
139            &[],
140        ),
141        E::PermissionRequest => (Phase::Loop, "", &[]),
142        E::PermissionDenied => (Phase::Loop, OUTCOME, &[]),
143        E::Elicitation => (Phase::Loop, "", &[]),
144        E::ElicitationResult => (Phase::Loop, "", &[]),
145        E::PostToolUse => (Phase::Loop, OUTCOME, &[]),
146        E::PostToolUseFailure => (Phase::Loop, OUTCOME, &[]),
147        E::PostToolBatch => (Phase::Loop, "", &[]),
148        E::SubagentStart => (Phase::Loop, "", &[]),
149        E::SubagentStop => (Phase::Loop, "", &[]),
150        E::TaskCreated => (Phase::Loop, "", &[]),
151        E::TaskCompleted => (Phase::Loop, "", &[]),
152
153        E::Stop => (
154            Phase::TurnEnd,
155            "Which of five endings this is: finished, waiting on you, blocked, still running, or stopped early. A confident \"stopped early\" sends the turn back with the reason.",
156            &[],
157        ),
158        E::StopFailure => (Phase::TurnEnd, "", &[]),
159
160        E::TeammateIdle => (Phase::Between, "", &[]),
161        E::PreCompact => (Phase::Between, "", &[]),
162        E::PostCompact => (Phase::Between, "", &[]),
163        E::SessionEnd => (Phase::Between, "The session is forgotten: its prompts, its cost, and any memory still booked for it.", &[]),
164
165        E::Notification => (Phase::Whenever, "", &[]),
166        E::ConfigChange => (Phase::Whenever, "", &[]),
167        E::PreModelSwitch => (Phase::Whenever, "", &[]),
168        E::PostModelSwitch => (Phase::Whenever, "", &[]),
169        E::WorktreeCreate => (Phase::Whenever, "", &[]),
170        E::WorktreeRemove => (Phase::Whenever, "", &[]),
171        E::CwdChanged => (Phase::Whenever, "", &[]),
172        E::FileChanged => (Phase::Whenever, "", &[]),
173        E::DirectoryAdded => (Phase::Whenever, "", &[]),
174        E::InstructionsLoaded => (Phase::Whenever, "", &[]),
175        E::MessageDisplay => (Phase::Whenever, "", &[]),
176    };
177    Hook { event, phase, today, ideas }
178}

Every event, in the order a session meets them (the order the page shows): each phase's events as they follow one another, then the ones that can come at any time. A test holds this to HookEvent::ALL, so an event cannot be left off the page.

183pub const ORDER: [HookEvent; 33] = {
184    use HookEvent as E;
185    [
186        E::Setup,
187        E::SessionStart,
188        E::UserPromptSubmit,
189        E::UserPromptExpansion,
190        E::PreToolUse,
191        E::PermissionRequest,
192        E::PermissionDenied,
193        E::Elicitation,
194        E::ElicitationResult,
195        E::PostToolUse,
196        E::PostToolUseFailure,
197        E::PostToolBatch,
198        E::SubagentStart,
199        E::SubagentStop,
200        E::TaskCreated,
201        E::TaskCompleted,
202        E::Stop,
203        E::StopFailure,
204        E::TeammateIdle,
205        E::PreCompact,
206        E::PostCompact,
207        E::SessionEnd,
208        E::Notification,
209        E::ConfigChange,
210        E::PreModelSwitch,
211        E::PostModelSwitch,
212        E::WorktreeCreate,
213        E::WorktreeRemove,
214        E::CwdChanged,
215        E::FileChanged,
216        E::DirectoryAdded,
217        E::InstructionsLoaded,
218        E::MessageDisplay,
219    ]
220};
222pub fn all() -> Vec<Hook> {
223    ORDER.into_iter().map(hook).collect()
224}

How many events have an idea written down, and how many ideas there are in all. An event can be built and still have ideas for more, so this is not one of the four states.

228pub fn ideas() -> (usize, usize) {
229    let with: Vec<usize> = all().iter().map(|hook| hook.ideas.len()).filter(|&count| count > 0).collect();
230    (with.len(), with.iter().sum())
231}

How many events are in state.

234pub fn count(state: State) -> usize {
235    all().iter().filter(|hook| hook.state() == state).count()
236}

Two more places the plugin steps in that are not among Claude Code's hook events: they belong to the newer plugin interface the mod is written against. Both are for choosing a model, which is off unless switched on.

241pub const BEYOND: [(&str, &str); 2] = [
242    ("prompt.submit", "With model choice switched on: the prompt is scored for what it takes, on four levels."),
243    ("turn.step", "With model choice switched on: each request of the turn goes to the cheapest model that is 75% likely to be enough."),
244];
246#[cfg(test)]
247mod tests {
248    use super::*;

The map is the plugin's whole surface: every event Claude Code raises is on it, once.

251    #[test]
252    fn every_event_is_on_the_map_once() {
253        for event in HookEvent::ALL {
254            assert_eq!(ORDER.iter().filter(|&&placed| placed == event).count(), 1, "{event:?}");
255        }
256        // And a phase's events are together, in the order the phases come.
257        let phases: Vec<Phase> = all().iter().map(|hook| hook.phase).collect();
258        let mut seen: Vec<Phase> = phases.clone();
259        seen.dedup();
260        assert_eq!(seen, Phase::ALL);
261    }

What the page calls built is exactly what the plugin's own routing sends to the daemon, and a sentence about "today" is written for those events and for no other.

265    #[test]
266    fn built_is_what_the_plugin_says_is_built() {
267        for hook in all() {
268            let built = hook.event.role() != Role::Ignore;
269            assert_eq!(matches!(hook.state(), State::Judged | State::Heard), built, "{}", hook.name());
270            assert_eq!(!hook.today.is_empty(), built, "{}: a sentence about today exactly when something happens", hook.name());
271        }
272        assert_eq!(count(State::Judged), 2);
273        assert_eq!(count(State::Heard), 5);
274    }

The four states cover every event, so the legend's counts add up to the whole list.

277    #[test]
278    fn the_counts_add_up() {
279        assert_eq!(State::ALL.into_iter().map(count).sum::<usize>(), HookEvent::ALL.len());
280    }

Every phase has something in it; a phase with no event would be a heading over nothing.

283    #[test]
284    fn no_phase_is_empty() {
285        for phase in Phase::ALL {
286            assert!(all().iter().any(|hook| hook.phase == phase), "{phase:?}");
287        }
288    }
289}