use anyhow::Result;
use serde_json::{json, Value};
pub(crate) fn claude_event_for_hook(hook: &str) -> Result<&'static str> {
match hook {
"on-session-start" => Ok("SessionStart"),
"before-prompt-build" => Ok("UserPromptSubmit"),
"on-compaction-notice" => Ok("PreCompact"),
"on-agent-end" => Ok("Stop"),
"on-session-end" => Ok("SessionEnd"),
other => anyhow::bail!(
"hook-protocol is not defined for --host claude --hook {other}; \
valid hooks are on-session-start, before-prompt-build, on-compaction-notice, \
on-agent-end, on-session-end"
),
}
}
pub(crate) struct HookProtocolInputs<'a> {
pub context: Option<&'a Value>,
pub session_boundary_action: Option<&'a str>,
}
pub(crate) fn build_hook_protocol_payload(
hook: &str,
inputs: HookProtocolInputs<'_>,
) -> Result<Value> {
let event = claude_event_for_hook(hook)?;
match event {
"SessionStart" => Ok(session_start_payload(inputs.context)),
"UserPromptSubmit" => Ok(user_prompt_submit_payload(inputs)),
"Stop" | "PreCompact" | "SessionEnd" => Ok(json!({})),
other => anyhow::bail!("unexpected derived Claude event: {other}"),
}
}
fn session_start_payload(context: Option<&Value>) -> Value {
json!({
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": render_additional_context(context),
}
})
}
fn user_prompt_submit_payload(inputs: HookProtocolInputs<'_>) -> Value {
let mut payload = json!({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": render_additional_context(inputs.context),
}
});
if matches!(
inputs.session_boundary_action,
Some("stop") | Some("refresh")
) {
let action = inputs.session_boundary_action.unwrap();
let obj = payload.as_object_mut().expect("payload is json object");
obj.insert("decision".to_owned(), Value::String("block".to_owned()));
obj.insert(
"reason".to_owned(),
Value::String(format!(
"CCD session boundary is `{action}`; resolve continuity or wrap-up guidance before continuing.",
)),
);
}
payload
}
fn render_additional_context(context: Option<&Value>) -> String {
let ctx = context
.cloned()
.unwrap_or_else(|| Value::Object(Default::default()));
serde_json::to_string_pretty(&ctx).unwrap_or_else(|_| "{}".to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derives_every_supported_claude_event() {
assert_eq!(
claude_event_for_hook("on-session-start").unwrap(),
"SessionStart"
);
assert_eq!(
claude_event_for_hook("before-prompt-build").unwrap(),
"UserPromptSubmit"
);
assert_eq!(
claude_event_for_hook("on-compaction-notice").unwrap(),
"PreCompact"
);
assert_eq!(claude_event_for_hook("on-agent-end").unwrap(), "Stop");
assert_eq!(
claude_event_for_hook("on-session-end").unwrap(),
"SessionEnd"
);
}
#[test]
fn rejects_unknown_hook() {
let err = claude_event_for_hook("supervisor-tick").unwrap_err();
assert!(err.to_string().contains("hook-protocol is not defined"));
}
#[test]
fn rejects_task_completed_alias() {
assert!(claude_event_for_hook("on-task-completed").is_err());
}
fn empty_inputs() -> HookProtocolInputs<'static> {
HookProtocolInputs {
context: None,
session_boundary_action: None,
}
}
#[test]
fn session_start_shape_has_additional_context() {
let payload = build_hook_protocol_payload("on-session-start", empty_inputs()).unwrap();
assert_eq!(
payload["hookSpecificOutput"]["hookEventName"],
"SessionStart"
);
assert!(payload["hookSpecificOutput"]["additionalContext"].is_string());
assert!(payload.get("decision").is_none());
}
#[test]
fn user_prompt_submit_without_boundary_omits_decision() {
let payload = build_hook_protocol_payload("before-prompt-build", empty_inputs()).unwrap();
assert_eq!(
payload["hookSpecificOutput"]["hookEventName"],
"UserPromptSubmit"
);
assert!(payload.get("decision").is_none());
}
#[test]
fn user_prompt_submit_with_stop_boundary_sets_decision_block() {
let inputs = HookProtocolInputs {
context: None,
session_boundary_action: Some("stop"),
};
let payload = build_hook_protocol_payload("before-prompt-build", inputs).unwrap();
assert_eq!(payload["decision"], "block");
assert!(payload["reason"].as_str().unwrap().contains("stop"));
}
#[test]
fn user_prompt_submit_with_refresh_boundary_sets_decision_block() {
let inputs = HookProtocolInputs {
context: None,
session_boundary_action: Some("refresh"),
};
let payload = build_hook_protocol_payload("before-prompt-build", inputs).unwrap();
assert_eq!(payload["decision"], "block");
}
#[test]
fn user_prompt_submit_with_continue_boundary_does_not_block() {
let inputs = HookProtocolInputs {
context: None,
session_boundary_action: Some("continue"),
};
let payload = build_hook_protocol_payload("before-prompt-build", inputs).unwrap();
assert!(payload.get("decision").is_none());
}
#[test]
fn stop_payload_is_empty_object_in_pr2() {
let payload = build_hook_protocol_payload("on-agent-end", empty_inputs()).unwrap();
assert_eq!(payload, serde_json::json!({}));
}
#[test]
fn pre_compact_payload_is_empty_object() {
let payload = build_hook_protocol_payload("on-compaction-notice", empty_inputs()).unwrap();
assert_eq!(payload, serde_json::json!({}));
}
#[test]
fn session_end_payload_is_empty_object() {
let payload = build_hook_protocol_payload("on-session-end", empty_inputs()).unwrap();
assert_eq!(payload, serde_json::json!({}));
}
#[test]
fn additional_context_renders_the_passed_value() {
let ctx = serde_json::json!({ "next_focus": ["finish PR-2"], "branch": "feat/auto-lifecycle-pr-2" });
let inputs = HookProtocolInputs {
context: Some(&ctx),
session_boundary_action: None,
};
let payload = build_hook_protocol_payload("on-session-start", inputs).unwrap();
let rendered = payload["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap();
assert!(rendered.contains("next_focus"), "rendered: {rendered}");
assert!(rendered.contains("finish PR-2"), "rendered: {rendered}");
assert!(
rendered.contains("feat/auto-lifecycle-pr-2"),
"rendered: {rendered}"
);
}
}