shepherd-cli 6.8.3

Small installed-plugin diagnostics for the Shepherd workflow
use std::path::{Path, PathBuf};
use std::process::Command;

fn checkout() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../..")
        .canonicalize()
        .unwrap()
}

fn context(temp: &Path, next: &str) -> PathBuf {
    let path = temp.join(format!("context-{next}.json"));
    let value = serde_json::json!({
        "task": "fixture task", "objective": "deliver one reviewed artifact",
        "artifact_root": "/tmp/artifact-root", "status": "completed",
        "changed_paths_or_commit": "report.csv", "verification": "schema check: exit 0",
        "review": "Auditor: pass", "evidence_location": "review.md",
        "remaining_work": "none", "next_action": next,
    });
    std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
    path
}

fn handoff(host: &str, root: &Path, context: &Path, output: Option<&Path>) -> std::process::Output {
    let mut command = Command::new(env!("CARGO_BIN_EXE_shepherd"));
    command
        .args(["handoff", "--host", host, "--plugin-root"])
        .arg(root)
        .arg("--context")
        .arg(context);
    if let Some(output) = output {
        command.arg("--output").arg(output);
    }
    command.output().unwrap()
}

#[test]
fn installed_templates_render_identical_handoff_without_writes() {
    let root = checkout();
    let temp = tempfile::tempdir().unwrap();
    let input = context(temp.path(), "return reviewed result");
    let before = std::fs::read(&input).unwrap();
    let mut outputs = Vec::new();
    for (host, plugin) in [
        ("claude", root.clone()),
        ("codex", root.join("plugins/shepherd")),
        ("claude", root.join("packages/harness-claude")),
        ("codex", root.join("packages/harness-codex")),
        ("pi", root.join("packages/harness-pi")),
    ] {
        let result = handoff(host, &plugin, &input, None);
        assert!(
            result.status.success(),
            "{host}: {}",
            String::from_utf8_lossy(&result.stderr)
        );
        let body = String::from_utf8(result.stdout).unwrap();
        assert!(body.contains("fixture task") && body.contains("report.csv"));
        assert!(!body.contains("{{") && !body.contains("<completed"));
        outputs.push(body);
    }
    assert!(outputs.windows(2).all(|pair| pair[0] == pair[1]));
    assert_eq!(std::fs::read(&input).unwrap(), before);
    assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 1);
}

#[test]
fn supplied_non_git_continuation_facts_survive_every_carrier() {
    let root = checkout();
    let temp = tempfile::tempdir().expect("create handoff fixture directory");
    let input = context(temp.path(), "return CSV to continuing root");
    let mut value: serde_json::Value =
        serde_json::from_slice(&std::fs::read(&input).expect("read context fixture"))
            .expect("parse context fixture");
    value["controller"] =
        serde_json::json!({"kind": "continuing root", "session": "project-root-17"});
    value["owner"] = serde_json::json!({"kind": "delegated thread", "session": "thread-4"});
    value["scope"] = serde_json::json!("produce one reviewed CSV");
    value["owned_paths"] = serde_json::json!(["reports/report.csv", "reports/evidence.json"]);
    value["forbidden_paths"] = serde_json::json!(["source/**", "credentials/**"]);
    value["fingerprints"] = serde_json::json!({
        "input": {"source.csv": "sha256:input-abc"},
        "output": {"reports/report.csv": "sha256:output-def"},
    });
    value["result_route"] =
        serde_json::json!({"address": "project-root-17", "method": "native message"});
    value["location_mapping"] = serde_json::json!({
        "source": "/source/artifacts", "destination": "/remote/artifacts",
    });
    value["activation"] =
        serde_json::json!("bridge received by thread-4; host capabilities recheck pending");
    value["review_episode"] = serde_json::json!({
        "episode": "review-7", "round": 1, "remaining_rounds": 1,
        "reviewers": ["critic-2", "auditor-3"], "findings": ["M05"],
    });
    std::fs::write(
        &input,
        serde_json::to_vec(&value).expect("serialize context fixture"),
    )
    .expect("write supplied continuation facts");
    let before = std::fs::read(&input).expect("read supplied context");
    let mut outputs = Vec::new();
    for (host, plugin) in [
        ("claude", root.clone()),
        ("codex", root.join("plugins/shepherd")),
        ("claude", root.join("packages/harness-claude")),
        ("codex", root.join("packages/harness-codex")),
        ("pi", root.join("packages/harness-pi")),
    ] {
        let result = handoff(host, &plugin, &input, None);
        assert!(
            result.status.success(),
            "{}",
            String::from_utf8_lossy(&result.stderr)
        );
        let body = String::from_utf8(result.stdout).expect("rendered handoff is UTF-8");
        for expected in [
            "Controller: {\"kind\": \"continuing root\", \"session\": \"project-root-17\"}",
            "Owner: {\"kind\": \"delegated thread\", \"session\": \"thread-4\"}",
            "Scope: produce one reviewed CSV",
            "Owned paths: [\"reports/report.csv\", \"reports/evidence.json\"]",
            "Forbidden paths: [\"source/**\", \"credentials/**\"]",
            "Fingerprints: {\"input\": {\"source.csv\": \"sha256:input-abc\"}, \"output\": {\"reports/report.csv\": \"sha256:output-def\"}}",
            "Result route: {\"address\": \"project-root-17\", \"method\": \"native message\"}",
            "Location mapping: {\"destination\": \"/remote/artifacts\", \"source\": \"/source/artifacts\"}",
            "Activation and received context: bridge received by thread-4; host capabilities recheck pending",
            "Review episode: {\"episode\": \"review-7\", \"findings\": [\"M05\"], \"remaining_rounds\": 1, \"reviewers\": [\"critic-2\", \"auditor-3\"], \"round\": 1}",
        ] {
            assert!(
                body.lines().any(|line| line == expected),
                "{host}: missing {expected}"
            );
        }
        for git_label in ["Branch: ", "Base: ", "Candidate: ", "Custody: "] {
            assert!(!body.lines().any(|line| line.starts_with(git_label)));
        }
        assert!(body.contains("Carry the same review episode"));
        outputs.push(body);
    }
    assert!(outputs.windows(2).all(|pair| pair[0] == pair[1]));
    assert_eq!(std::fs::read(&input).expect("reread context"), before);
    assert_eq!(
        std::fs::read_dir(temp.path())
            .expect("list fixture files")
            .count(),
        1
    );
}

#[test]
fn relocated_git_source_and_destination_facts_remain_distinct() {
    let root = checkout();
    let temp = tempfile::tempdir().expect("create relocation fixture directory");
    let input = context(temp.path(), "report destination checks before writing");
    let mut value: serde_json::Value =
        serde_json::from_slice(&std::fs::read(&input).expect("read context fixture"))
            .expect("parse context fixture");
    value["artifact_root"] = serde_json::json!("/cloud/clone");
    value["branch"] = serde_json::json!("refs/heads/thread-work");
    value["base"] = serde_json::json!("refs/heads/release");
    value["candidate"] = serde_json::json!("1111111111111111111111111111111111111111");
    value["controller"] = serde_json::json!("continuing project root, not thread-work");
    value["owner"] = serde_json::json!(
        "thread-work owns reports/**; prior writer stop requested, confirmation unknown"
    );
    value["result_route"] = serde_json::json!("native message to project-root-17");
    value["custody"] =
        serde_json::json!("source pinned before launch; destination identity checks pending");
    value["location_mapping"] = serde_json::json!({
        "source": {"repository": "https://example.invalid/acme/service.git", "artifact_root": "/local/service", "common_dir": "/local/service/.git"},
        "destination": {"artifact_root": "/cloud/clone", "common_dir": "/cloud/clone/.git", "identity": "unverified"},
        "integration_target": "refs/heads/release",
    });
    std::fs::write(
        &input,
        serde_json::to_vec(&value).expect("serialize relocation context"),
    )
    .expect("write relocation context");
    let result = handoff("codex", &root.join("packages/harness-codex"), &input, None);
    assert!(
        result.status.success(),
        "{}",
        String::from_utf8_lossy(&result.stderr)
    );
    let body = String::from_utf8(result.stdout).expect("rendered handoff is UTF-8");
    for expected in [
        "Artifact root: /cloud/clone",
        "Branch: refs/heads/thread-work",
        "Base: refs/heads/release",
        "Candidate: 1111111111111111111111111111111111111111",
        "Controller: continuing project root, not thread-work",
        "Owner: thread-work owns reports/**; prior writer stop requested, confirmation unknown",
        "Result route: native message to project-root-17",
        "Custody: source pinned before launch; destination identity checks pending",
    ] {
        assert!(
            body.lines().any(|line| line == expected),
            "missing {expected}"
        );
    }
    let mapping = body
        .lines()
        .find(|line| line.starts_with("Location mapping: "))
        .expect("rendered source/destination mapping");
    for expected in [
        "\"source\": {\"artifact_root\": \"/local/service\", \"common_dir\": \"/local/service/.git\", \"repository\": \"https://example.invalid/acme/service.git\"}",
        "\"destination\": {\"artifact_root\": \"/cloud/clone\", \"common_dir\": \"/cloud/clone/.git\", \"identity\": \"unverified\"}",
        "\"integration_target\": \"refs/heads/release\"",
    ] {
        assert!(
            mapping.contains(expected),
            "relocation fact lost: {expected}"
        );
    }
    assert!(body.contains("live worktree/common directory"));
}

#[test]
fn continuation_instructions_follow_supplied_git_and_review_state() {
    let root = checkout();
    let temp = tempfile::tempdir().unwrap();
    let input = context(temp.path(), "return report");
    let mut value: serde_json::Value =
        serde_json::from_slice(&std::fs::read(&input).unwrap()).unwrap();
    let mut absent_body = None;
    for supplied in [None, Some(false), Some(true)] {
        if supplied == Some(true) {
            value["custody"] = serde_json::json!("verified Git tuple");
            value["review_episode"] = serde_json::json!("episode 7, one round remaining");
        } else if supplied == Some(false) {
            value["custody"] = serde_json::Value::Null;
            value["review_episode"] = serde_json::Value::Null;
            for key in [
                "branch",
                "base",
                "candidate",
                "date",
                "rollback",
                "activation",
                "reconciliation",
                "controller",
                "owner",
                "scope",
                "owned_paths",
                "forbidden_paths",
                "fingerprints",
                "result_route",
                "location_mapping",
            ] {
                value[key] = serde_json::Value::Null;
            }
        }
        std::fs::write(&input, serde_json::to_vec(&value).unwrap()).unwrap();
        let result = handoff("claude", &root, &input, None);
        assert!(result.status.success());
        let body = String::from_utf8(result.stdout).unwrap();
        assert_eq!(
            body.contains("live worktree/common directory"),
            supplied == Some(true)
        );
        assert_eq!(
            body.contains("Carry the same review episode"),
            supplied == Some(true)
        );
        assert!(!body.contains(": None"));
        assert!(body.contains("artifact root"));
        for label in [
            "Controller: ",
            "Owner: ",
            "Scope: ",
            "Owned paths: ",
            "Forbidden paths: ",
            "Fingerprints: ",
            "Result route: ",
            "Location mapping: ",
        ] {
            assert!(!body.lines().any(|line| line.starts_with(label)));
        }
        if supplied.is_none() {
            absent_body = Some(body.clone());
        } else if supplied == Some(false) {
            assert_eq!(Some(&body), absent_body.as_ref());
        }
        if supplied == Some(true) {
            assert!(
                body.contains("verified Git tuple")
                    && body.contains("episode 7, one round remaining")
            );
        }
    }
}

#[test]
#[cfg(unix)]
fn installed_template_cannot_escape_plugin_root() {
    use std::os::unix::fs::symlink;
    let temp = tempfile::tempdir().unwrap();
    let plugin = temp.path().join("plugin");
    std::fs::create_dir_all(plugin.join("templates")).unwrap();
    std::fs::write(
        plugin.join("package.json"),
        serde_json::json!({"name":"@pzzld-org/pi-shepherd","version":env!("CARGO_PKG_VERSION")})
            .to_string(),
    )
    .unwrap();
    let outside = temp.path().join("outside.md");
    std::fs::write(&outside, "{{ task }}").unwrap();
    symlink(&outside, plugin.join("templates/handoff.md")).unwrap();
    let input = context(temp.path(), "safe action");
    let output = temp.path().join("result.md");
    let result = handoff("pi", &plugin, &input, Some(&output));
    assert!(!result.status.success());
    assert!(String::from_utf8_lossy(&result.stderr).contains("escapes plugin root"));
    assert!(!output.exists());
}

#[test]
fn malformed_or_missing_context_fails_before_output() {
    let root = checkout();
    let temp = tempfile::tempdir().unwrap();
    let input = temp.path().join("context.json");
    let output = temp.path().join("handoff.md");
    std::fs::write(&input, "not json").unwrap();
    assert!(
        !handoff("claude", &root, &input, Some(&output))
            .status
            .success()
    );
    assert!(!output.exists());
    std::fs::write(&input, r#"{"task":"only a task"}"#).unwrap();
    assert!(
        !handoff("claude", &root, &input, Some(&output))
            .status
            .success()
    );
    assert!(!output.exists());
}

#[test]
fn explicit_output_is_no_clobber_and_identical_retry_is_noop() {
    let root = checkout();
    let temp = tempfile::tempdir().unwrap();
    let first = context(temp.path(), "first action");
    let second = context(temp.path(), "second action");
    let output = temp.path().join("handoff.md");
    assert!(
        handoff("claude", &root, &first, Some(&output))
            .status
            .success()
    );
    let bytes = std::fs::read(&output).unwrap();
    let modified = std::fs::metadata(&output).unwrap().modified().unwrap();
    assert!(
        handoff("claude", &root, &first, Some(&output))
            .status
            .success()
    );
    assert_eq!(
        std::fs::metadata(&output).unwrap().modified().unwrap(),
        modified
    );
    assert!(
        !handoff("claude", &root, &second, Some(&output))
            .status
            .success()
    );
    assert_eq!(std::fs::read(&output).unwrap(), bytes);
}

#[test]
fn racing_different_outputs_leave_one_complete_artifact() {
    let root = checkout();
    let temp = tempfile::tempdir().unwrap();
    let first = context(temp.path(), "first action");
    let second = context(temp.path(), "second action");
    let output = temp.path().join("handoff.md");
    let expected_first = handoff("pi", &root.join("packages/harness-pi"), &first, None).stdout;
    let expected_second = handoff("pi", &root.join("packages/harness-pi"), &second, None).stdout;
    let mut a = Command::new(env!("CARGO_BIN_EXE_shepherd"));
    a.args(["handoff", "--host", "pi", "--plugin-root"])
        .arg(root.join("packages/harness-pi"))
        .arg("--context")
        .arg(&first)
        .arg("--output")
        .arg(&output);
    let mut b = Command::new(env!("CARGO_BIN_EXE_shepherd"));
    b.args(["handoff", "--host", "pi", "--plugin-root"])
        .arg(root.join("packages/harness-pi"))
        .arg("--context")
        .arg(&second)
        .arg("--output")
        .arg(&output);
    let mut child_a = a.spawn().unwrap();
    let mut child_b = b.spawn().unwrap();
    let status_a = child_a.wait().unwrap();
    let status_b = child_b.wait().unwrap();
    assert_ne!(status_a.success(), status_b.success());
    let actual = std::fs::read(&output).unwrap();
    assert!(actual == expected_first || actual == expected_second);
}