1//! Every point at which Claude Code lets a plugin in, and what jevhooks does there: the page's map. 2//! 3//! The list of events and what is done with each is not written here. It is the plugin's own 4//! (`jevhooks_events::HookEvent` and its `role`), the same code the mod and the daemon are compiled 5//! from, so the page cannot say an event is judged when the plugin does not judge it. What is 6//! written here is where an event sits in a session, one sentence on what happens there today, and 7//! the ideas that have been written down for it. An idea is only ever one somebody wrote down: 8//! nothing here is a promise, and an event with no idea says so. 9 10use jevhooks_events::{HookEvent, Role}; 11 12/// 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} 22 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} 37 38/// What jevhooks does with an event today. The first three are the plugin's own `Role`; the 39/// last two split "nothing" by whether an idea for it has been written down. 40#[derive(Clone, Copy, Debug, PartialEq, Eq)] 41pub enum State { 42 /// Jev is asked, and the answer changes what happens. 43 Judged, 44 /// Sent to the daemon, which needs it to judge something else. 45 Heard, 46 /// Nothing yet, and an idea is written down. 47 Idea, 48 /// Nothing, and nothing planned. 49 Nothing, 50} 51 52impl State { 53 /// 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]; 55 56 /// 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 } 65 66 /// 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 } 75 76 /// 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} 86 87/// One event on the map. 88pub struct Hook { 89 pub event: HookEvent, 90 pub phase: Phase, 91 /// What happens there today; empty when nothing does. 92 pub today: &'static str, 93 /// Ideas written down for it, each a sentence. 94 pub ideas: &'static [&'static str], 95} 96 97impl Hook { 98 /// The event's name, as Claude Code spells it. 99 pub fn name(&self) -> String { 100 format!("{:?}", self.event) 101 } 102 103 /// 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} 113 114const OUTCOME: &str = "What became of a command Jev judged is written to the log, and the memory booked for it is given back."; 115 116/// Where `event` sits and what is said of it. No `_` arm on purpose: the day Claude Code adds an 117/// 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} 179 180/// Every event, in the order a session meets them (the order the page shows): each phase's 181/// events as they follow one another, then the ones that can come at any time. A test holds 182/// 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}; 221 222pub fn all() -> Vec<Hook> { 223 ORDER.into_iter().map(hook).collect() 224} 225 226/// How many events have an idea written down, and how many ideas there are in all. An event can 227/// 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} 232 233/// How many events are in `state`. 234pub fn count(state: State) -> usize { 235 all().iter().filter(|hook| hook.state() == state).count() 236} 237 238/// Two more places the plugin steps in that are not among Claude Code's hook events: they belong 239/// to the newer plugin interface the mod is written against. Both are for choosing a model, which 240/// 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]; 245 246#[cfg(test)] 247mod tests { 248 use super::*; 249 250 /// 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 } 262 263 /// What the page calls built is exactly what the plugin's own routing sends to the daemon, and 264 /// 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 } 275 276 /// 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 } 281 282 /// 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}