shepherd-cli 6.4.5

The canonical shepherd command-line interface over the per-project registry, run artifacts, and sprint pipeline.
use std::{
    fs,
    io::Write,
    path::{Path, PathBuf},
    process::{Command, Output, Stdio},
    time::{SystemTime, UNIX_EPOCH},
};

fn binary() -> &'static str {
    env!("CARGO_BIN_EXE_shepherd")
}

fn fixture_dir(label: &str) -> PathBuf {
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock")
        .as_nanos();
    let root = std::env::temp_dir().join(format!(
        "shepherd-claude-hook-{label}-{}-{nonce:x}",
        std::process::id()
    ));
    fs::create_dir_all(&root).expect("create fixture directory");
    root
}

fn repository(label: &str) -> PathBuf {
    let root = fixture_dir(label);
    let status = Command::new("git")
        .args(["init", "--quiet"])
        .current_dir(&root)
        .status()
        .expect("initialize fixture repository");
    assert!(status.success());
    fs::create_dir_all(root.join(".shepherd/runs/v645")).expect("create run namespace");
    fs::write(
        root.join(".shepherd/project.json"),
        br#"{"id":"018f47ce-72d7-7f64-9eb1-2f651d521c2a","scaffolded_at":1000}"#,
    )
    .expect("write project identity");
    fs::write(
        root.join(".shepherd/runs/v645/run.json"),
        br#"{"run":"v645","status":"executing"}"#,
    )
    .expect("write active run");
    root
}

fn hook(root: &Path, input: serde_json::Value) -> Output {
    let mut child = Command::new(binary())
        .args(["claude-hook"])
        .current_dir(root)
        .env("SHEPHERD_HOME", root.join("isolated-home"))
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn shepherd claude hook");
    child
        .stdin
        .take()
        .expect("hook stdin")
        .write_all(&serde_json::to_vec(&input).expect("encode hook input"))
        .expect("write hook input");
    child.wait_with_output().expect("wait for hook")
}

#[test]
fn session_start_binds_root_and_safe_pretooluse_allows() {
    let root = repository("root-allow");
    let session = hook(
        &root,
        serde_json::json!({
            "hook_event_name": "SessionStart",
            "session_id": "claude-session-a"
        }),
    );
    assert!(
        session.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&session.stderr)
    );
    let output: serde_json::Value =
        serde_json::from_slice(&session.stdout).expect("SessionStart hook output is JSON");
    assert_eq!(
        output["hookSpecificOutput"]["hookEventName"],
        "SessionStart"
    );
    assert!(
        root.join(".shepherd/runs/v645/dispatch/.root-session.claude-session-a.json")
            .is_file()
    );

    let safe = hook(
        &root,
        serde_json::json!({
            "hook_event_name": "PreToolUse",
            "session_id": "claude-session-a",
            "tool_use_id": "safe-tool-a",
            "tool_name": "Bash",
            "tool_input": {"command": "printf safe"}
        }),
    );
    assert!(
        safe.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&safe.stderr)
    );
    assert!(safe.stdout.is_empty(), "safe tool use must emit no denial");
    fs::remove_dir_all(root).expect("remove fixture directory");
}

#[test]
fn pretooluse_denies_unresolved_or_unbound_requests() {
    let root = repository("deny");
    let denied = hook(
        &root,
        serde_json::json!({
            "hook_event_name": "PreToolUse",
            "session_id": "unbound-session",
            "tool_use_id": "deny-tool-a",
            "tool_name": "Write",
            "tool_input": {"file_path": "README.md", "content": "nope"}
        }),
    );
    assert!(
        denied.status.success(),
        "PreToolUse must emit a fail-closed Claude denial instead of crashing: stderr={}",
        String::from_utf8_lossy(&denied.stderr)
    );
    let output: serde_json::Value =
        serde_json::from_slice(&denied.stdout).expect("denial output is JSON");
    assert_eq!(output["hookSpecificOutput"]["hookEventName"], "PreToolUse");
    assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "deny");
    assert!(
        output["hookSpecificOutput"]["permissionDecisionReason"]
            .as_str()
            .is_some_and(|reason| !reason.is_empty())
    );
    fs::remove_dir_all(root).expect("remove fixture directory");
}

#[test]
fn malformed_input_and_unbound_subagent_events_have_safe_host_outputs() {
    let root = repository("malformed-and-blocked");
    let mut malformed = Command::new(binary())
        .args(["claude-hook"])
        .current_dir(&root)
        .env("SHEPHERD_HOME", root.join("isolated-home"))
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn malformed hook");
    malformed
        .stdin
        .take()
        .expect("malformed stdin")
        .write_all(b"{")
        .expect("write malformed input");
    let malformed = malformed.wait_with_output().expect("wait malformed hook");
    assert!(malformed.status.success());
    let malformed: serde_json::Value =
        serde_json::from_slice(&malformed.stdout).expect("malformed input denial is JSON");
    assert_eq!(
        malformed["hookSpecificOutput"]["permissionDecision"],
        "deny"
    );

    let blocked = hook(
        &root,
        serde_json::json!({
            "hook_event_name": "SubagentStart",
            "session_id": "claude-session-blocked",
            "agent_id": "claude-agent-blocked",
            "agent_type": "coder"
        }),
    );
    assert!(blocked.status.success());
    let blocked: serde_json::Value =
        serde_json::from_slice(&blocked.stdout).expect("blocked lifecycle context is JSON");
    assert_eq!(
        blocked["hookSpecificOutput"]["hookEventName"],
        "SubagentStart"
    );
    assert!(
        blocked["hookSpecificOutput"]["additionalContext"]
            .as_str()
            .is_some_and(|detail| detail.contains("rejected"))
    );
    fs::remove_dir_all(root).expect("remove fixture directory");
}

#[test]
fn subagent_stop_blocks_when_dispatch_cannot_be_resolved() {
    let root = repository("stop-unresolved");
    let stopped = hook(
        &root,
        serde_json::json!({
            "hook_event_name": "SubagentStop",
            "session_id": "claude-session-unresolved",
            "agent_id": "claude-agent-unresolved",
            "agent_type": "coder"
        }),
    );
    assert!(
        stopped.status.success(),
        "SubagentStop must return Claude's blocking decision instead of crashing: stderr={}",
        String::from_utf8_lossy(&stopped.stderr)
    );
    let output: serde_json::Value =
        serde_json::from_slice(&stopped.stdout).expect("blocked stop output is JSON");
    assert_eq!(output["decision"], "block");
    assert!(
        output["reason"]
            .as_str()
            .is_some_and(|reason| reason.contains("rejected")),
        "blocking stop output must explain why the lifecycle transition was rejected: {output}"
    );
    assert!(output.get("hookSpecificOutput").is_none());
    fs::remove_dir_all(root).expect("remove fixture directory");
}

#[test]
fn lifecycle_start_and_stop_follow_the_native_dispatch_ledger() {
    let root = repository("lifecycle");
    let session = hook(
        &root,
        serde_json::json!({
            "hook_event_name": "SessionStart",
            "session_id": "claude-session-lifecycle"
        }),
    );
    assert!(session.status.success());

    let binding = serde_json::json!({
        "run": "v645",
        "role": "coder",
        "lane": "l1-engine",
        "write_scope": ["crates/core/src/dispatch/**"],
        "model": "claude-sonnet-5",
        "observed_capabilities": [
            "read", "search", "shell", "write", "skill-load", "tool-discovery", "subagent-provider"
        ],
        "capability_source": "claude-hook-test",
        "harness_version": "test",
        "lease_ms": 60000
    });
    let started = hook(
        &root,
        serde_json::json!({
            "hook_event_name": "SubagentStart",
            "session_id": "claude-session-lifecycle",
            "agent_id": "claude-agent-lifecycle",
            "agent_type": "coder",
            "shepherd_dispatch": binding
        }),
    );
    assert!(
        started.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&started.stderr)
    );
    assert!(
        root.join(".shepherd/runs/v645/dispatch/claude-agent-lifecycle.json")
            .is_file()
    );

    let stopped = hook(
        &root,
        serde_json::json!({
            "hook_event_name": "SubagentStop",
            "session_id": "claude-session-lifecycle",
            "agent_id": "claude-agent-lifecycle",
            "agent_type": "coder",
            "shepherd_dispatch": {
                "run": "v645",
                "lane": "l1-engine",
                "expected_revision": 1,
                "result_artifact": "lanes/l1-engine/reports/claude-agent-lifecycle.md"
            }
        }),
    );
    assert!(
        stopped.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&stopped.stderr)
    );
    let record: serde_json::Value = serde_json::from_slice(
        &fs::read(root.join(".shepherd/runs/v645/dispatch/claude-agent-lifecycle.json"))
            .expect("read stopped dispatch record"),
    )
    .expect("decode stopped dispatch record");
    assert_eq!(record["state"], "stopped");
    fs::remove_dir_all(root).expect("remove fixture directory");
}