shepherd-cli 6.8.2

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 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();
    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",
            ] {
                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"));
        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);
}