ccd-cli 1.0.0-beta.4

Bootstrap and validate Continuous Context Development repositories
//! Claude-specific hook protocol: derives the Claude hook event from the
//! `(--host, --hook)` pair and builds the per-event stdout JSON that
//! Claude Code consumes. See `docs/superpowers/specs/2026-04-23-auto-lifecycle-design.md` §1.

use anyhow::Result;
use serde_json::{json, Value};

/// Pair-to-event mapping per spec §1.2. `TaskCompleted` is **not** wired in v1.
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"
        ),
    }
}

/// Narrow input to `build_hook_protocol_payload`. Callers extract these from their
/// `HostHookRunReport` before calling — see `src/commands/dispatch/host.rs`.
pub(crate) struct HookProtocolInputs<'a> {
    /// Raw context object (same shape as `HostHookRunReport.context`). May be `None`.
    pub context: Option<&'a Value>,
    /// The `session_boundary.action` value as a string: `"continue"`, `"refresh"`, `"wrap_up"`, `"stop"`.
    /// Obtain from `SessionBoundaryAction::as_str()`. `None` when the report has no boundary.
    pub session_boundary_action: Option<&'a str>,
}

/// Build the stdout JSON for `ccd --output hook-protocol host-hook --host claude --hook <hook>`.
///
/// Per spec §1.3:
/// - `SessionStart` always carries `hookSpecificOutput.additionalContext`.
/// - `UserPromptSubmit` always carries `hookSpecificOutput.additionalContext` and adds
///   `{"decision": "block", "reason": ...}` as top-level keys (not nested inside
///   `hookSpecificOutput`) when `session_boundary_action ∈ {"stop", "refresh"}`.
///   This placement follows the Claude Code hooks contract — see spec §1.3.
/// - `Stop`, `PreCompact`, `SessionEnd` emit `{}` in v1 (quiet-by-default).
///   PR-3 turns `Stop` into a trigger-gated directive; `PreCompact`/`SessionEnd` stay `{}` for v1.
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!({})),
        // `claude_event_for_hook` is exhaustive over its `Ok` arm, so this is unreachable.
        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() {
        // TaskCompleted is intentionally not wired in v1; no hook name maps to it.
        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}"
        );
    }
}