1use edda_bridge_claude::{render, state};
2
3use crate::parse::CursorEnvelope;
4
5#[derive(Debug, Default, Clone)]
6pub struct HookResult {
7 pub stdout: Option<String>,
8 pub stderr: Option<String>,
9}
10
11impl HookResult {
12 fn output(stdout: String) -> Self {
13 Self {
14 stdout: Some(stdout),
15 stderr: None,
16 }
17 }
18}
19
20fn empty_json() -> HookResult {
21 HookResult::output("{}".to_string())
22}
23
24fn continue_json() -> HookResult {
25 HookResult::output(r#"{"continue":true}"#.to_string())
26}
27
28fn allow_json() -> HookResult {
29 HookResult::output(r#"{"permission":"allow"}"#.to_string())
30}
31
32fn pre_tool_warning(warning: &str) -> anyhow::Result<HookResult> {
33 let output = serde_json::json!({
34 "permission": "allow",
35 "agent_message": warning,
36 });
37 Ok(HookResult::output(serde_json::to_string(&output)?))
38}
39
40fn additional_context(context: &str) -> anyhow::Result<HookResult> {
41 let output = serde_json::json!({"additional_context": context});
42 Ok(HookResult::output(serde_json::to_string(&output)?))
43}
44
45pub fn hook_entrypoint_from_stdin(stdin: &str) -> anyhow::Result<HookResult> {
46 if stdin.trim().is_empty() {
47 return Ok(HookResult::default());
48 }
49 let envelope = match crate::parse::parse_hook_stdin(stdin) {
50 Ok(envelope) => envelope,
51 Err(_) => return Ok(empty_json()),
52 };
53 let cwd = envelope.cwd();
54 let project_id = edda_store::project_id(&cwd);
55 let _ = edda_store::ensure_dirs(&project_id);
56 let _ = crate::parse::append_to_session_ledger(&envelope);
57 if !envelope.session_id().is_empty() {
58 edda_bridge_claude::peers::touch_heartbeat(&project_id, envelope.session_id());
59 }
60
61 let result = match envelope.event_name() {
62 "sessionStart" => dispatch_session_start(&project_id, &envelope),
63 "beforeSubmitPrompt" => Ok(continue_json()),
64 "preToolUse" => dispatch_pre_tool_use(&project_id, &envelope),
65 "postToolUse" => dispatch_post_tool_use(&project_id, &envelope),
66 "preCompact" => {
67 state::set_compact_pending(&project_id);
68 Ok(empty_json())
69 }
70 "sessionEnd" | "stop" => dispatch_session_end(&project_id, &envelope),
71 "subagentStart" => Ok(dispatch_subagent_start(&project_id, &envelope)),
72 "subagentStop" => Ok(dispatch_subagent_stop(&project_id, &envelope)),
73 _ => Ok(empty_json()),
74 };
75 Ok(result.unwrap_or_else(|_| empty_json()))
76}
77
78fn dispatch_subagent_start(project_id: &str, envelope: &CursorEnvelope) -> HookResult {
79 if !envelope.subagent_id.is_empty() {
80 let label = if envelope.subagent_type.is_empty() {
81 "subagent"
82 } else {
83 &envelope.subagent_type
84 };
85 edda_bridge_claude::peers::write_heartbeat_minimal(
86 project_id,
87 &envelope.subagent_id,
88 label,
89 &envelope.cwd().to_string_lossy(),
90 );
91 }
92 allow_json()
93}
94
95fn dispatch_subagent_stop(project_id: &str, envelope: &CursorEnvelope) -> HookResult {
96 if !envelope.subagent_id.is_empty() {
97 edda_bridge_claude::peers::remove_heartbeat(project_id, &envelope.subagent_id);
98 }
99 empty_json()
100}
101
102fn dispatch_session_end(project_id: &str, envelope: &CursorEnvelope) -> anyhow::Result<HookResult> {
103 let session_id = envelope.session_id();
104 if !session_id.is_empty() {
105 let cwd = envelope.cwd();
106 let _ = edda_bridge_claude::digest::digest_session_manual(
107 project_id,
108 session_id,
109 &cwd.to_string_lossy(),
110 true,
111 );
112 }
113 let peers_active = !session_id.is_empty()
114 && !edda_bridge_claude::peers::discover_active_peers(project_id, session_id).is_empty();
115 cleanup_session_state(project_id, session_id, peers_active);
116 Ok(empty_json())
117}
118
119fn cleanup_session_state(project_id: &str, session_id: &str, peers_active: bool) {
120 let state_dir = edda_store::project_dir(project_id).join("state");
121 for name in [
122 "inject_hash",
123 "nudge_ts",
124 "nudge_count",
125 "decide_count",
126 "signal_count",
127 "peer_count",
128 "coord_offset",
129 ] {
130 let _ = std::fs::remove_file(state_dir.join(format!("{name}.{session_id}")));
131 }
132 let _ = std::fs::remove_file(state_dir.join(format!("phase.{session_id}.json")));
133 let _ = std::fs::remove_file(state_dir.join("compact_pending"));
134 edda_bridge_claude::peers::remove_heartbeat(project_id, session_id);
135 if peers_active {
136 edda_bridge_claude::peers::write_unclaim(project_id, session_id);
137 }
138}
139
140fn dispatch_post_tool_use(
141 project_id: &str,
142 envelope: &CursorEnvelope,
143) -> anyhow::Result<HookResult> {
144 let session_id = envelope.session_id();
145 let tool_name = if envelope.tool_name == "Shell" {
146 "Bash"
147 } else {
148 &envelope.tool_name
149 };
150 let raw = serde_json::json!({
151 "tool_name": tool_name,
152 "tool_input": envelope.tool_input,
153 });
154 let signal = match edda_bridge_claude::nudge::detect_signal(&raw) {
155 Some(signal) => signal,
156 None => return Ok(empty_json()),
157 };
158
159 state::increment_counter(project_id, session_id, "signal_count");
160 if signal == edda_bridge_claude::nudge::NudgeSignal::SelfRecord {
161 state::increment_counter(project_id, session_id, "decide_count");
162 return Ok(empty_json());
163 }
164 if !state::should_nudge(project_id, session_id) {
165 return Ok(empty_json());
166 }
167
168 let decide_count = state::read_counter(project_id, session_id, "decide_count");
169 let nudge = edda_bridge_claude::nudge::format_nudge(&signal, decide_count);
170 if nudge.is_empty() {
171 return Ok(empty_json());
172 }
173 state::mark_nudge_sent(project_id, session_id);
174 state::increment_counter(project_id, session_id, "nudge_count");
175 additional_context(&nudge)
176}
177
178fn dispatch_pre_tool_use(
179 project_id: &str,
180 envelope: &CursorEnvelope,
181) -> anyhow::Result<HookResult> {
182 if std::env::var("EDDA_POSTMORTEM").unwrap_or_else(|_| "1".to_string()) == "0" {
183 return Ok(empty_json());
184 }
185
186 let mut rules_store = edda_postmortem::RulesStore::load_project(project_id);
187 if rules_store.active_rules().is_empty() {
188 return Ok(empty_json());
189 }
190
191 let files_touched = ["file_path", "path", "filePath"]
192 .iter()
193 .find_map(|key| {
194 envelope
195 .tool_input
196 .get(key)
197 .and_then(|value| value.as_str())
198 })
199 .map(|path| vec![path.to_string()])
200 .unwrap_or_default();
201 let command = envelope
202 .tool_input
203 .get("command")
204 .and_then(|value| value.as_str())
205 .map(str::to_string);
206 let context = edda_postmortem::hooks::HookContext {
207 hook_event: "PreToolUse".to_string(),
208 tool_name: envelope.tool_name.clone(),
209 files_touched,
210 cwd: envelope.cwd().to_string_lossy().into_owned(),
211 command,
212 };
213
214 let evaluation = edda_postmortem::hooks::evaluate_rules(&rules_store, &context);
215 if !evaluation.matched_rule_ids.is_empty() {
216 edda_postmortem::hooks::record_matched_hits(&mut rules_store, &evaluation.matched_rule_ids);
217 let _ = rules_store.save_project(project_id);
218 }
219 match edda_postmortem::hooks::format_warnings(&evaluation) {
220 Some(warning) => pre_tool_warning(&warning),
221 None => Ok(empty_json()),
222 }
223}
224
225fn dispatch_session_start(
226 project_id: &str,
227 envelope: &CursorEnvelope,
228) -> anyhow::Result<HookResult> {
229 let cwd = envelope.cwd();
230 let cwd_text = cwd.to_string_lossy();
231 let session_id = envelope.session_id();
232
233 if !session_id.is_empty() {
234 let label = std::env::var("EDDA_SESSION_LABEL").unwrap_or_default();
235 edda_bridge_claude::peers::write_heartbeat_minimal(
236 project_id, session_id, &label, &cwd_text,
237 );
238 }
239
240 let mut body_parts = Vec::new();
241 let doctrine_budget = env_usize("EDDA_DOCTRINE_BUDGET_CHARS", 4000);
242 if let Some(doctrine) = edda_pack::read_doctrine_pack(&cwd, doctrine_budget) {
243 body_parts.push(doctrine);
244 }
245
246 let workspace_budget = env_usize("EDDA_WORKSPACE_BUDGET_CHARS", 2500);
247 if let Some(workspace) = render::workspace(&cwd_text, workspace_budget) {
248 body_parts.push(workspace);
249 }
250 if let Some(pack) = render::pack(project_id) {
251 body_parts.push(pack);
252 }
253 if let Some(plan) = render::plan(Some(project_id)) {
254 body_parts.push(plan);
255 }
256
257 let body = body_parts.join("\n\n");
258 let mut tail = format!("\n\n{}", render::writeback());
259 if let Some(coordination) =
260 edda_bridge_claude::peers::render_coordination_protocol(project_id, session_id, &cwd_text)
261 {
262 tail.push_str("\n\n");
263 tail.push_str(&coordination);
264 }
265
266 let body_budget = render::context_budget(&cwd_text).saturating_sub(tail.len());
267 let content = format!("{}{tail}", render::apply_budget(&body, body_budget));
268 let wrapped = render::wrap_boundary(&content);
269 let sidefile = context_sidefile(project_id, &wrapped)?;
270 let output = serde_json::json!({
271 "env": {
272 "EDDA_HOT_PACK_PATH": sidefile.to_string_lossy(),
273 },
274 "additional_context": wrapped,
275 });
276 Ok(HookResult::output(serde_json::to_string(&output)?))
277}
278
279fn env_usize(name: &str, default: usize) -> usize {
280 std::env::var(name)
281 .ok()
282 .and_then(|value| value.parse().ok())
283 .unwrap_or(default)
284}
285
286fn context_sidefile(project_id: &str, context: &str) -> anyhow::Result<std::path::PathBuf> {
287 let packs_dir = edda_store::project_dir(project_id).join("packs");
288 let hot_path = packs_dir.join("hot.md");
289 if hot_path.is_file() {
290 return Ok(hot_path);
291 }
292
293 std::fs::create_dir_all(&packs_dir)?;
294 let generated_path = packs_dir.join("cursor-context.md");
295 std::fs::write(&generated_path, context)?;
296 Ok(generated_path)
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn empty_stdin_returns_no_output() {
305 let result = hook_entrypoint_from_stdin("").unwrap();
306
307 assert!(result.stdout.is_none());
308 assert!(result.stderr.is_none());
309 }
310
311 #[test]
312 fn malformed_stdin_fails_open_with_empty_json() {
313 let result = hook_entrypoint_from_stdin("not json").unwrap();
314
315 assert_eq!(result.stdout.as_deref(), Some("{}"));
316 assert!(result.stderr.is_none());
317 }
318
319 #[test]
320 fn session_start_pushes_context_and_readable_sidefile() {
321 let tmp = tempfile::tempdir().unwrap();
322 let stdin = serde_json::json!({
323 "hook_event_name": "sessionStart",
324 "session_id": "cursor-session-start-1",
325 "workspace_roots": [tmp.path()],
326 "composer_mode": "agent"
327 })
328 .to_string();
329
330 let result = hook_entrypoint_from_stdin(&stdin).unwrap();
331 let output: serde_json::Value =
332 serde_json::from_str(result.stdout.as_deref().unwrap()).unwrap();
333
334 let context = output["additional_context"].as_str().unwrap();
335 assert!(context.contains("Write-Back Protocol"));
336 assert!(context.contains("edda decide"));
337 let sidefile = output["env"]["EDDA_HOT_PACK_PATH"].as_str().unwrap();
338 assert!(std::path::Path::new(sidefile).is_file());
339 }
340
341 #[test]
342 fn before_submit_prompt_explicitly_continues() {
343 let stdin = serde_json::json!({
344 "hook_event_name": "beforeSubmitPrompt",
345 "conversation_id": "cursor-prompt-1",
346 "cwd": "/work/project"
347 })
348 .to_string();
349
350 let result = hook_entrypoint_from_stdin(&stdin).unwrap();
351 let output: serde_json::Value =
352 serde_json::from_str(result.stdout.as_deref().unwrap()).unwrap();
353
354 assert_eq!(output["continue"], true);
355 }
356
357 #[test]
358 fn pre_tool_warning_uses_cursor_permission_schema() {
359 let result = pre_tool_warning("Review the governing decision").unwrap();
360 let output: serde_json::Value =
361 serde_json::from_str(result.stdout.as_deref().unwrap()).unwrap();
362
363 assert_eq!(output["permission"], "allow");
364 assert_eq!(output["agent_message"], "Review the governing decision");
365 }
366
367 #[test]
368 fn post_tool_use_maps_cursor_shell_and_pushes_nudge() {
369 let tmp = tempfile::tempdir().unwrap();
370 let stdin = serde_json::json!({
371 "hook_event_name": "postToolUse",
372 "session_id": "cursor-post-tool-1",
373 "workspace_roots": [tmp.path()],
374 "tool_name": "Shell",
375 "tool_input": {"command": "git commit -m checkpoint"}
376 })
377 .to_string();
378
379 let result = hook_entrypoint_from_stdin(&stdin).unwrap();
380 let output: serde_json::Value =
381 serde_json::from_str(result.stdout.as_deref().unwrap()).unwrap();
382
383 let context = output["additional_context"].as_str().unwrap();
384 assert!(context.contains("edda decide"));
385 }
386
387 #[test]
388 fn pre_compact_marks_recovery_state_without_claiming_rebuild() {
389 let tmp = tempfile::tempdir().unwrap();
390 let project_id = edda_store::project_id(tmp.path());
391 let _ = edda_bridge_claude::state::take_compact_pending(&project_id);
392 let stdin = serde_json::json!({
393 "hook_event_name": "preCompact",
394 "session_id": "cursor-compact-1",
395 "workspace_roots": [tmp.path()]
396 })
397 .to_string();
398
399 let result = hook_entrypoint_from_stdin(&stdin).unwrap();
400
401 assert_eq!(result.stdout.as_deref(), Some("{}"));
402 assert!(edda_bridge_claude::state::take_compact_pending(&project_id));
403 }
404
405 #[test]
406 fn session_end_cleans_session_state() {
407 let tmp = tempfile::tempdir().unwrap();
408 let project_id = edda_store::project_id(tmp.path());
409 let session_id = "cursor-session-end-1";
410 let start = serde_json::json!({
411 "hook_event_name": "sessionStart",
412 "session_id": session_id,
413 "workspace_roots": [tmp.path()]
414 })
415 .to_string();
416 hook_entrypoint_from_stdin(&start).unwrap();
417 let heartbeat = edda_store::project_dir(&project_id)
418 .join("state")
419 .join(format!("session.{session_id}.json"));
420 assert!(heartbeat.is_file());
421
422 let end = serde_json::json!({
423 "hook_event_name": "sessionEnd",
424 "session_id": session_id,
425 "workspace_roots": [tmp.path()]
426 })
427 .to_string();
428 let result = hook_entrypoint_from_stdin(&end).unwrap();
429
430 assert_eq!(result.stdout.as_deref(), Some("{}"));
431 assert!(!heartbeat.exists());
432 }
433
434 #[test]
435 fn hook_events_are_recorded_in_cursor_session_ledger() {
436 let tmp = tempfile::tempdir().unwrap();
437 let project_id = edda_store::project_id(tmp.path());
438 let session_id = "cursor-ledger-1";
439 let stdin = serde_json::json!({
440 "hook_event_name": "beforeSubmitPrompt",
441 "session_id": session_id,
442 "workspace_roots": [tmp.path()],
443 "model": "composer"
444 })
445 .to_string();
446
447 hook_entrypoint_from_stdin(&stdin).unwrap();
448
449 let ledger = edda_store::project_dir(&project_id)
450 .join("ledger")
451 .join(format!("{session_id}.jsonl"));
452 let recorded = std::fs::read_to_string(ledger).unwrap();
453 assert!(recorded.contains(r#""bridge":"cursor""#));
454 assert!(recorded.contains(r#""hook_event_name":"beforeSubmitPrompt""#));
455 }
456
457 #[test]
458 fn subagent_lifecycle_tracks_and_clears_peer_heartbeat() {
459 let tmp = tempfile::tempdir().unwrap();
460 let project_id = edda_store::project_id(tmp.path());
461 let subagent_id = "cursor-subagent-1";
462 let start = serde_json::json!({
463 "hook_event_name": "subagentStart",
464 "conversation_id": "cursor-parent-1",
465 "workspace_roots": [tmp.path()],
466 "subagent_id": subagent_id,
467 "subagent_type": "explore"
468 })
469 .to_string();
470
471 let result = hook_entrypoint_from_stdin(&start).unwrap();
472 let output: serde_json::Value =
473 serde_json::from_str(result.stdout.as_deref().unwrap()).unwrap();
474 assert_eq!(output["permission"], "allow");
475 let heartbeat = edda_store::project_dir(&project_id)
476 .join("state")
477 .join(format!("session.{subagent_id}.json"));
478 assert!(heartbeat.is_file());
479
480 let stop = serde_json::json!({
481 "hook_event_name": "subagentStop",
482 "conversation_id": "cursor-parent-1",
483 "workspace_roots": [tmp.path()],
484 "subagent_id": subagent_id
485 })
486 .to_string();
487 hook_entrypoint_from_stdin(&stop).unwrap();
488 assert!(!heartbeat.exists());
489 }
490}