1use edda_bridge_claude::{render, state};
17
18use crate::parse::*;
19
20#[derive(Debug, Default, Clone)]
23pub struct HookResult {
24 pub stdout: Option<String>,
25 pub stderr: Option<String>,
26}
27
28impl HookResult {
29 pub fn output(stdout: String) -> Self {
30 Self {
31 stdout: Some(stdout),
32 stderr: None,
33 }
34 }
35
36 pub fn empty() -> Self {
37 Self::default()
38 }
39}
40
41fn ok() -> HookResult {
42 HookResult::output(r#"{"continue":true}"#.to_string())
43}
44
45fn with_context(context: &str) -> anyhow::Result<HookResult> {
46 let output = serde_json::json!({
47 "continue": true,
48 "hookSpecificOutput": { "additionalContext": context }
49 });
50 Ok(HookResult::output(serde_json::to_string(&output)?))
51}
52
53pub fn hook_entrypoint_from_stdin(stdin: &str) -> anyhow::Result<HookResult> {
58 if stdin.trim().is_empty() {
59 return Ok(HookResult::empty());
60 }
61 let envelope = match parse_hook_stdin(stdin) {
62 Ok(e) => e,
63 Err(_) => return Ok(ok()),
64 };
65
66 let project_id = resolve_project_id(&envelope.cwd);
67 let _ = edda_store::ensure_dirs(&project_id);
68 let _ = append_to_session_ledger(&envelope);
69
70 if !envelope.session_id.is_empty() {
71 edda_bridge_claude::peers::touch_heartbeat(&project_id, &envelope.session_id);
72 }
73
74 match envelope.hook_event_name.as_str() {
75 "SessionStart" => dispatch_session_start(&project_id, &envelope),
76 "UserPromptSubmit" => dispatch_user_prompt_submit(&project_id, &envelope),
77 "PreToolUse" => dispatch_pre_tool_use(&project_id, &envelope),
78 "PostToolUse" => dispatch_post_tool_use(&project_id, &envelope),
79 "PreCompact" => dispatch_pre_compact(&project_id),
80 "SessionEnd" | "Stop" => dispatch_session_end(&project_id, &envelope),
81 "SubagentStart" | "SubagentStop" | "PostCompact" | "PermissionRequest"
83 | "PostToolUseFailure" => Ok(ok()),
84 _ => Ok(ok()),
85 }
86}
87
88fn dispatch_session_start(
91 project_id: &str,
92 envelope: &CodexEnvelope,
93) -> anyhow::Result<HookResult> {
94 let cwd = &envelope.cwd;
95 let session_id = &envelope.session_id;
96
97 if !session_id.is_empty() {
98 let label = std::env::var("EDDA_SESSION_LABEL").unwrap_or_default();
99 edda_bridge_claude::peers::write_heartbeat_minimal(project_id, session_id, &label, cwd);
100 }
101
102 let mut body_parts: Vec<String> = Vec::new();
103
104 let doctrine_budget: usize = std::env::var("EDDA_DOCTRINE_BUDGET_CHARS")
106 .ok()
107 .and_then(|v| v.parse().ok())
108 .unwrap_or(4000);
109 if let Some(doctrine) =
110 edda_pack::read_doctrine_pack(std::path::Path::new(cwd), doctrine_budget)
111 {
112 body_parts.push(doctrine);
113 }
114
115 let workspace_budget: usize = std::env::var("EDDA_WORKSPACE_BUDGET_CHARS")
117 .ok()
118 .and_then(|v| v.parse().ok())
119 .unwrap_or(2500);
120 if let Some(ws) = render::workspace(cwd, workspace_budget) {
121 body_parts.push(ws);
122 }
123
124 if let Some(pack) = render::pack(project_id) {
126 body_parts.push(pack);
127 }
128
129 if let Some(plan) = render::plan(Some(project_id)) {
131 body_parts.push(plan);
132 }
133
134 let body = body_parts.join("\n\n");
135
136 let mut tail = String::new();
138 tail.push_str("\n\n");
139 tail.push_str(&render::writeback());
140 if let Some(coord) =
141 edda_bridge_claude::peers::render_coordination_protocol(project_id, session_id, cwd)
142 {
143 tail.push_str(&format!("\n\n{coord}"));
144 }
145
146 let total_budget = render::context_budget(cwd);
147 let body_budget = total_budget.saturating_sub(tail.len());
148 let budgeted_body = render::apply_budget(&body, body_budget);
149 let content = format!("{budgeted_body}{tail}");
150 let wrapped = render::wrap_boundary(&content);
151 with_context(&wrapped)
152}
153
154fn dispatch_user_prompt_submit(
157 project_id: &str,
158 envelope: &CodexEnvelope,
159) -> anyhow::Result<HookResult> {
160 let cwd = &envelope.cwd;
161 let session_id = &envelope.session_id;
162
163 let workspace_budget: usize = std::env::var("EDDA_WORKSPACE_BUDGET_CHARS")
164 .ok()
165 .and_then(|v| v.parse().ok())
166 .unwrap_or(2500);
167 let ws = match render::workspace(cwd, workspace_budget) {
168 Some(w) => w,
169 None => return Ok(ok()),
170 };
171 let wrapped = render::wrap_boundary(&ws);
172
173 if !session_id.is_empty() && state::is_same_as_last_inject(project_id, session_id, &wrapped) {
174 return Ok(ok());
175 }
176 if !session_id.is_empty() {
177 state::write_inject_hash(project_id, session_id, &wrapped);
178 }
179 with_context(&wrapped)
180}
181
182fn dispatch_pre_tool_use(project_id: &str, envelope: &CodexEnvelope) -> anyhow::Result<HookResult> {
185 if std::env::var("EDDA_POSTMORTEM").unwrap_or_else(|_| "1".into()) == "0" {
186 return Ok(ok());
187 }
188 let mut rules_store = edda_postmortem::RulesStore::load_project(project_id);
189 if rules_store.active_rules().is_empty() {
190 return Ok(ok());
191 }
192
193 let files_touched: Vec<String> = envelope
194 .tool_input
195 .get("file_path")
196 .and_then(|v| v.as_str())
197 .map(|s| vec![s.to_string()])
198 .unwrap_or_default();
199 let command = envelope
200 .tool_input
201 .get("command")
202 .and_then(|v| v.as_str())
203 .map(|s| s.to_string());
204
205 let ctx = edda_postmortem::hooks::HookContext {
206 hook_event: "PreToolUse".to_string(),
207 tool_name: envelope.tool_name.clone(),
208 files_touched,
209 cwd: envelope.cwd.clone(),
210 command,
211 };
212
213 let result = edda_postmortem::hooks::evaluate_rules(&rules_store, &ctx);
214 if !result.matched_rule_ids.is_empty() {
215 edda_postmortem::hooks::record_matched_hits(&mut rules_store, &result.matched_rule_ids);
216 let _ = rules_store.save_project(project_id);
217 }
218 match edda_postmortem::hooks::format_warnings(&result) {
219 Some(warning) => with_context(&warning),
220 None => Ok(ok()),
221 }
222}
223
224fn dispatch_post_tool_use(
227 project_id: &str,
228 envelope: &CodexEnvelope,
229) -> anyhow::Result<HookResult> {
230 let session_id = &envelope.session_id;
231 let raw = serde_json::json!({
232 "tool_name": envelope.tool_name,
233 "tool_input": envelope.tool_input,
234 });
235 let signal = match edda_bridge_claude::nudge::detect_signal(&raw) {
236 Some(s) => s,
237 None => return Ok(ok()),
238 };
239
240 state::increment_counter(project_id, session_id, "signal_count");
241 if signal == edda_bridge_claude::nudge::NudgeSignal::SelfRecord {
242 state::increment_counter(project_id, session_id, "decide_count");
243 return Ok(ok());
244 }
245 if !state::should_nudge(project_id, session_id) {
246 return Ok(ok());
247 }
248 let decide_count = state::read_counter(project_id, session_id, "decide_count");
249 let nudge_text = edda_bridge_claude::nudge::format_nudge(&signal, decide_count);
250 if nudge_text.is_empty() {
251 return Ok(ok());
252 }
253 state::mark_nudge_sent(project_id, session_id);
254 state::increment_counter(project_id, session_id, "nudge_count");
255 with_context(&nudge_text)
256}
257
258fn dispatch_pre_compact(project_id: &str) -> anyhow::Result<HookResult> {
261 state::set_compact_pending(project_id);
262 Ok(ok())
263}
264
265fn dispatch_session_end(project_id: &str, envelope: &CodexEnvelope) -> anyhow::Result<HookResult> {
266 let cwd = &envelope.cwd;
267 let session_id = &envelope.session_id;
268 if !session_id.is_empty() {
269 let _ =
270 edda_bridge_claude::digest::digest_session_manual(project_id, session_id, cwd, true);
271 }
272 let peers_active = !session_id.is_empty()
273 && !edda_bridge_claude::peers::discover_active_peers(project_id, session_id).is_empty();
274 cleanup_session_state(project_id, session_id, peers_active);
275 Ok(ok())
276}
277
278fn cleanup_session_state(project_id: &str, session_id: &str, peers_active: bool) {
279 let state_dir = edda_store::project_dir(project_id).join("state");
280 let _ = std::fs::remove_file(state_dir.join(format!("inject_hash.{session_id}")));
281 let _ = std::fs::remove_file(state_dir.join(format!("nudge_ts.{session_id}")));
282 let _ = std::fs::remove_file(state_dir.join(format!("nudge_count.{session_id}")));
283 let _ = std::fs::remove_file(state_dir.join(format!("decide_count.{session_id}")));
284 let _ = std::fs::remove_file(state_dir.join(format!("signal_count.{session_id}")));
285 let _ = std::fs::remove_file(state_dir.join("compact_pending"));
286 edda_bridge_claude::peers::remove_heartbeat(project_id, session_id);
287 if peers_active {
288 edda_bridge_claude::peers::write_unclaim(project_id, session_id);
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 fn stdin_event(name: &str, cwd: &str, sid: &str) -> String {
297 serde_json::json!({
298 "hook_event_name": name,
299 "session_id": sid,
300 "cwd": cwd,
301 "model": "gpt-5-codex",
302 })
303 .to_string()
304 }
305
306 #[test]
307 fn empty_stdin_returns_empty() {
308 let r = hook_entrypoint_from_stdin("").unwrap();
309 assert!(r.stdout.is_none());
310 }
311
312 #[test]
313 fn malformed_stdin_fails_open() {
314 let r = hook_entrypoint_from_stdin("not json").unwrap();
315 let v: serde_json::Value = serde_json::from_str(r.stdout.as_ref().unwrap()).unwrap();
316 assert_eq!(v["continue"], true);
317 }
318
319 #[test]
320 fn unknown_event_returns_ok() {
321 let stdin = stdin_event("SomethingFuture", "/tmp", "s1");
322 let r = hook_entrypoint_from_stdin(&stdin).unwrap();
323 let v: serde_json::Value = serde_json::from_str(r.stdout.as_ref().unwrap()).unwrap();
324 assert_eq!(v["continue"], true);
325 }
326
327 #[test]
328 fn session_start_injects_context() {
329 std::env::set_var("EDDA_BRIDGE_AUTO_DIGEST", "0");
330 let tmp = tempfile::tempdir().unwrap();
331 let stdin = stdin_event("SessionStart", tmp.path().to_str().unwrap(), "codex-ss-1");
332 let r = hook_entrypoint_from_stdin(&stdin).unwrap();
333 let v: serde_json::Value = serde_json::from_str(r.stdout.as_ref().unwrap()).unwrap();
334 let ctx = v["hookSpecificOutput"]["additionalContext"]
335 .as_str()
336 .unwrap();
337 assert!(ctx.contains("Write-Back Protocol"));
338 assert!(ctx.contains("edda decide"));
339 std::env::remove_var("EDDA_BRIDGE_AUTO_DIGEST");
340 }
341
342 #[test]
343 fn session_start_reads_doctrine_pack() {
344 std::env::set_var("EDDA_BRIDGE_AUTO_DIGEST", "0");
345 let tmp = tempfile::tempdir().unwrap();
346 std::fs::write(
347 tmp.path().join(".havamal-pack.md"),
348 "## L1 — MUST STAY TRUE\nClaims never close work.",
349 )
350 .unwrap();
351 let stdin = stdin_event("SessionStart", tmp.path().to_str().unwrap(), "codex-ss-2");
352 let r = hook_entrypoint_from_stdin(&stdin).unwrap();
353 let v: serde_json::Value = serde_json::from_str(r.stdout.as_ref().unwrap()).unwrap();
354 let ctx = v["hookSpecificOutput"]["additionalContext"]
355 .as_str()
356 .unwrap();
357 assert!(ctx.contains("Doctrine (judgment layer)"));
358 assert!(ctx.contains("Claims never close work"));
359 std::env::remove_var("EDDA_BRIDGE_AUTO_DIGEST");
360 }
361
362 #[test]
363 fn pre_tool_use_no_rules_returns_ok() {
364 std::env::set_var("EDDA_POSTMORTEM", "1");
365 let stdin = serde_json::json!({
366 "hook_event_name": "PreToolUse",
367 "session_id": "codex-ptu-1",
368 "cwd": "/tmp/codex-ptu",
369 "tool_name": "Bash",
370 "tool_input": { "command": "ls" }
371 })
372 .to_string();
373 let r = hook_entrypoint_from_stdin(&stdin).unwrap();
374 let v: serde_json::Value = serde_json::from_str(r.stdout.as_ref().unwrap()).unwrap();
375 assert_eq!(v["continue"], true);
376 }
377}