Skip to main content

edda_bridge_codex/
dispatch.rs

1//! Codex hook event dispatch.
2//!
3//! Output shape (Codex protocol):
4//! ```json
5//! {
6//!   "continue": true,
7//!   "hookSpecificOutput": { "additionalContext": "..." }
8//! }
9//! ```
10//!
11//! For PreToolUse we can also return `{ "continue": false, "stopReason": "..." }`
12//! to block, but this skeleton emits advisory context only — L3 rule enforcement
13//! is wired to the same `edda_postmortem::hooks::evaluate_rules` used by the
14//! Claude bridge, but delivered as `additionalContext` warnings for now.
15
16use edda_bridge_claude::{render, state};
17
18use crate::parse::*;
19
20// ── Hook Result ──
21
22#[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
53// ── Entry ──
54
55/// Fail-open: every internal error becomes `{"continue":true}` so a broken
56/// bridge cannot brick the user's session.
57pub 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        // Codex-only events — forward-compatible stubs
82        "SubagentStart" | "SubagentStop" | "PostCompact" | "PermissionRequest"
83        | "PostToolUseFailure" => Ok(ok()),
84        _ => Ok(ok()),
85    }
86}
87
88// ── SessionStart: full doctrine + workspace injection ──
89
90fn 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    // 1. Doctrine pack (havamal contract) — placed first, budget-capped.
105    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    // 2. Workspace context (decisions, notes, recent commits).
116    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    // 3. Hot pack (recent turns from prior sessions).
125    if let Some(pack) = render::pack(project_id) {
126        body_parts.push(pack);
127    }
128
129    // 4. Active plan excerpt.
130    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    // Tail: write-back protocol + coordination.
137    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
154// ── UserPromptSubmit: light workspace-only injection with dedup ──
155
156fn 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
182// ── PreToolUse: L3 rule evaluation ──
183
184fn 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
224// ── PostToolUse: nudge on decision signals ──
225
226fn 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
258// ── PreCompact / SessionEnd ──
259
260fn 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}