#[path = "support/plan.rs"]
mod plan_fixture;
mod support;
use sha2::Digest;
use shepherd_cli::shepherd::run::{LaneStatus, RunStatus, Vocabulary};
use shepherd_cli::shepherd::{RunState, dispatch::Role};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
fn binary() -> &'static str {
env!("CARGO_BIN_EXE_shepherd")
}
fn fixture(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!(
"shepherd-wave-b2-run-{label}-{}-{nonce:x}",
std::process::id()
));
std::fs::create_dir_all(&root).expect("create fixture root");
let status = Command::new("git")
.args(["init", "--quiet"])
.current_dir(&root)
.status()
.expect("initialize fixture git repository");
assert!(status.success(), "git init must succeed");
// Materialize the same bytes the repository's own .gitattributes
// guarantees (`* text=auto eol=lf`). Windows git defaults to
// core.autocrlf=true, so a worktree checkout rewrote LF to CRLF and the
// plan parser refused a file this fixture had just committed itself.
let eol = Command::new("git")
.args(["config", "core.autocrlf", "false"])
.current_dir(&root)
.status()
.expect("pin fixture end-of-line policy");
assert!(eol.success(), "git config core.autocrlf must succeed");
root
}
fn invoke(root: &Path, args: &[&str]) -> Output {
Command::new(binary())
.args(args)
.current_dir(root)
.env("SHEPHERD_HOME", root.join("isolated-home"))
.env("SHEPHERD_MODEL_QUOTA", "128")
.output()
.expect("run shepherd binary")
}
fn invoke_with_stdin(root: &Path, args: &[&str], input: serde_json::Value) -> Output {
let mut child = Command::new(binary())
.args(args)
.current_dir(root)
.env("SHEPHERD_HOME", root.join("isolated-home"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn shepherd binary");
child
.stdin
.take()
.expect("command stdin")
.write_all(&serde_json::to_vec(&input).expect("encode command input"))
.expect("write command stdin");
child.wait_with_output().expect("wait for shepherd binary")
}
fn primary(root: &Path) -> PathBuf {
std::fs::canonicalize(root).expect("canonical fixture root")
}
fn text(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
fn cleanup(root: &Path) {
support::remove_dir_all(root)
}
fn orientation_report(
root: &Path,
run: &str,
agent_id: &str,
kind: &str,
evidence_path: &str,
verdict: Option<serde_json::Value>,
) -> serde_json::Value {
let evidence = root.join(evidence_path);
let bytes = std::fs::read(&evidence).expect("orientation evidence");
let digest: String = sha2::Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
let entry = serde_json::json!({"path": evidence_path, "sha256": digest, "line": 1});
let mut report = serde_json::json!({
"schema": "shepherd.orientation-report/1",
"run": run,
"result_id": agent_id,
"kind": kind,
"role": kind,
"read_scope": [evidence_path],
"status": "complete",
"summary": format!("{kind} complete"),
"assumptions": [{"id": format!("{agent_id}-assumption"), "statement": "bounded"}],
"claims": [{"id": format!("{agent_id}-claim"), "statement": "observed", "evidence": [entry]}],
"evidence": [entry],
"caveats": []
});
if let Some(verdict) = verdict {
report["verdict"] = verdict;
}
report
}
fn dispatch_orientation_child(
root: &Path,
run: &str,
agent_id: &str,
kind: &str,
result_artifact: &str,
) {
let pre_path = root.join(format!(".shepherd/runs/{run}/orientation-pre.json"));
let started_at = if kind == "critic" && pre_path.exists() {
let pre: serde_json::Value =
serde_json::from_slice(&std::fs::read(pre_path).expect("orientation pre for Critic"))
.expect("orientation pre JSON");
pre["created_at"].as_i64().expect("pre created_at") + 1
} else {
1
};
let role = Role::from_carrier(&format!("shepherd:{kind}")).expect("orientation role");
plan_fixture::orientation_record(root, run, role, agent_id, result_artifact, started_at);
}
#[test]
fn init_scaffolds_the_layout_v5_run_at_the_primary_root_and_refuses_a_second_init() {
let root = fixture("init");
let first = invoke(
&root,
&[
"run",
"init",
"v900-dev0",
"--kind",
"sprint",
"--branch",
"v9.0.0-dev.0",
"--base",
"main",
],
);
assert_eq!(
first.status.code(),
Some(0),
"stderr={}",
text(&first.stderr)
);
assert_eq!(
text(&first.stdout),
format!(
"{}\n",
primary(&root)
.join(".shepherd/runs/v900-dev0/run.json")
.display()
)
);
let directory = "lanes";
assert!(
root.join(".shepherd/runs/v900-dev0")
.join(directory)
.is_dir(),
"{directory} is part of the canonical layout-v5 run shape"
);
let second = invoke(&root, &["run", "init", "v900-dev0"]);
assert_eq!(second.status.code(), Some(5));
assert_eq!(
text(&second.stderr),
"ERROR: run already exists: v900-dev0\n"
);
cleanup(&root);
}
#[test]
fn invalid_ids_missing_runs_and_schema_ahead_documents_have_stable_exit_codes_without_writes() {
let root = fixture("fail-closed");
let invalid = invoke(&root, &["run", "init", "../escape"]);
assert_eq!(invalid.status.code(), Some(2));
assert!(!root.join(".shepherd/runs").exists());
let absent = invoke(&root, &["run", "show", "v900-dev0"]);
assert_eq!(absent.status.code(), Some(5));
assert_eq!(
text(&absent.stderr),
format!(
"ERROR: no such run: v900-dev0 (expected {})\n",
primary(&root)
.join(".shepherd/runs/v900-dev0/run.json")
.display()
)
);
let run = root.join(".shepherd/runs/v900-dev0");
std::fs::create_dir_all(&run).expect("create run fixture");
let future =
b"{\"schema_version\":2,\"run\":\"v900-dev0\",\"status\":\"paused-by-future-host\"}\n";
std::fs::write(run.join("run.json"), future).expect("write future document");
let claim = invoke(&root, &["run", "claim", "v900-dev0"]);
assert_eq!(claim.status.code(), Some(2));
assert_eq!(
std::fs::read(run.join("run.json")).expect("read unchanged future document"),
future
);
cleanup(&root);
}
#[test]
fn lifecycle_lane_wave_layout_and_read_only_routes_cover_the_native_surface() {
let root = fixture("surface");
let init = invoke(&root, &["run", "init", "v901-dev0"]);
assert_eq!(init.status.code(), Some(0), "stderr={}", text(&init.stderr));
for args in [
&[
"run",
"set",
"v901-dev0",
"--seed",
"runs/v901-dev0/seed.md",
"--plan",
"runs/v901-dev0/plan.md",
][..],
&[
"run",
"lane",
"add",
"v901-dev0",
"l1-engine",
"--branch",
"lane/l1",
][..],
&[
"run",
"lane",
"set",
"v901-dev0",
"l1-engine",
"--state",
"in-progress",
][..],
&[
"run",
"wave",
"accept",
"v901-dev0",
"l1-engine",
"--commit",
"abc123",
][..],
] {
let result = invoke(&root, args);
assert_eq!(
result.status.code(),
Some(0),
"args={args:?} stderr={}",
text(&result.stderr)
);
}
let pending = invoke(&root, &["run", "wave", "pending", "v901-dev0"]);
assert_eq!(pending.status.code(), Some(6));
assert_eq!(text(&pending.stdout), "l1-engine\tabc123\n");
let merged = invoke(&root, &["run", "wave", "merged", "v901-dev0", "l1-engine"]);
assert_eq!(
merged.status.code(),
Some(0),
"stderr={}",
text(&merged.stderr)
);
let clear = invoke(&root, &["run", "wave", "pending", "v901-dev0", "--json"]);
assert_eq!(clear.status.code(), Some(0));
assert!(text(&clear.stdout).contains("\"ok\":true"));
std::fs::write(
root.join(".shepherd/runs/v901-dev0/lanes/l1-engine/plan.md"),
"### W1-L1-S1: native command\n",
)
.expect("lane plan");
std::fs::write(
root.join(".shepherd/runs/v901-dev0/auditor-verdicts.txt"),
"L1 w1-s1 PASS completed\n",
)
.expect("ledger");
let verified = invoke(&root, &["run", "wave", "verify", "v901-dev0"]);
assert_eq!(
verified.status.code(),
Some(0),
"stderr={}",
text(&verified.stderr)
);
assert_eq!(
text(&verified.stdout),
"W1-L1-S1\tPASS\tL1 w1-s1 PASS completed\n"
);
let show = invoke(&root, &["run", "show", "v901-dev0", "--json"]);
assert_eq!(show.status.code(), Some(0));
assert!(text(&show.stdout).contains("\"l1-engine\""));
let listed = invoke(&root, &["run", "list", "--json"]);
assert_eq!(text(&listed.stdout), "[\"v901-dev0\"]\n");
let ledger = invoke(&root, &["run", "ledger", "path", "v901-dev0"]);
assert_eq!(ledger.status.code(), Some(0));
assert!(text(&ledger.stdout).ends_with(".shepherd/runs/v901-dev0/auditor-verdicts.txt\n"));
let checked = invoke(&root, &["run", "ledger", "check", "v901-dev0", "--json"]);
assert_eq!(
checked.status.code(),
Some(0),
"stderr={}",
text(&checked.stderr)
);
let layout = invoke(&root, &["run", "layout", "v901-dev0", "--json"]);
assert_eq!(layout.status.code(), Some(0));
assert!(text(&layout.stdout).contains("\"ok\": true"));
cleanup(&root);
}
#[cfg(unix)]
#[test]
fn symlinked_run_state_is_refused_without_following_the_link() {
let root = fixture("symlink");
let outside = fixture("outside");
std::fs::create_dir_all(root.join(".shepherd/runs/v902-dev0")).expect("run directory");
std::fs::write(outside.join("state.json"), b"{\"run\":\"v902-dev0\"}\n")
.expect("outside state");
std::os::unix::fs::symlink(
outside.join("state.json"),
root.join(".shepherd/runs/v902-dev0/run.json"),
)
.expect("symlink state");
let show = invoke(&root, &["run", "show", "v902-dev0"]);
assert_ne!(show.status.code(), Some(0));
assert!(text(&show.stderr).contains("symlink"));
assert_eq!(
std::fs::read(outside.join("state.json")).expect("outside unchanged"),
b"{\"run\":\"v902-dev0\"}\n"
);
cleanup(&outside);
cleanup(&root);
}
#[test]
fn rename_canonicalize_and_migrate_preserve_the_run_lifecycle_contract() {
let root = fixture("migration");
let init = invoke(&root, &["run", "init", "v903-dev0"]);
assert_eq!(init.status.code(), Some(0));
let renamed = invoke(&root, &["run", "rename", "v903-dev0", "v904-dev0-codex-01"]);
assert_eq!(
renamed.status.code(),
Some(0),
"stderr={}",
text(&renamed.stderr)
);
let canonicalized = invoke(&root, &["run", "canonicalize", "v904-dev0-codex-01"]);
assert_eq!(
canonicalized.status.code(),
Some(0),
"stderr={}",
text(&canonicalized.stderr)
);
assert!(root.join(".shepherd/runs/v904-dev0/run.json").is_file());
let legacy = root.join(".shepherd/runs/v905-dev0");
std::fs::create_dir_all(&legacy).expect("legacy directory");
std::fs::write(
legacy.join("run.json"),
b"{\"run_id\":\"v905-dev0\",\"lanes\":{\"l1-engine\":{\"state\":\"pending\"}},\"updated_at\":\"42\"}\n",
)
.expect("legacy run document");
let migrated = invoke(&root, &["run", "migrate", "v905-dev0"]);
assert_eq!(
migrated.status.code(),
Some(0),
"stderr={}",
text(&migrated.stderr)
);
let state: serde_json::Value =
serde_json::from_slice(&std::fs::read(legacy.join("run.json")).expect("migrated state"))
.expect("canonical JSON");
assert_eq!(state["run"], "v905-dev0");
assert_eq!(state["lanes"][0]["id"], "l1-engine");
cleanup(&root);
}
fn file_digest(path: &Path) -> String {
sha2::Sha256::digest(std::fs::read(path).expect("digest input"))
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
#[test]
fn orientation_is_native_two_phase_and_planned_transition_cannot_bypass_final_post() {
let root = fixture("orientation");
let run = "v907-dev0";
let init = invoke(&root, &["run", "init", run]);
assert_eq!(init.status.code(), Some(0), "stderr={}", text(&init.stderr));
std::fs::write(
root.join(".shepherd/project.json"),
br#"{"id":"018f47ce-72d7-7f64-9eb1-2f651d521c2a","scaffolded_at":1000}"#,
)
.expect("project identity");
let run_dir = root.join(".shepherd/runs").join(run);
plan_fixture::seed(&root, run);
let seed_path = format!(".shepherd/runs/{run}/seed.md");
let seed_bytes = std::fs::read(run_dir.join("seed.md")).expect("typed seed");
std::fs::write(root.join("seed.md"), b"verified seed\n").expect("project seed");
let seed_hash = file_digest(&run_dir.join("seed.md"));
let phase0 = format!(
"# Orientation\n\n## Run and seed\n- run: {run}\n- verified seed path: {seed_path}\n- seed hash: {seed_hash}\n- planted observation: status=planted\n\n## Auditor briefs\n### auditor-1\n- question: reproduce the seeded claim\n- exact read scope: seed.md\n- output path: reports/auditor-1.json\n- budget: bounded\n- result hash: native\n\n## Discovery briefs\n### discovery-1\n- question: resolve the external unknown\n- exact read scope: seed.md\n- output path: reports/discovery-1.json\n- budget: bounded\n- result hash: native\n\n## Assumptions and decisions\n- A-1: unsupported claims remain caveats.\n\n## Coverage map\n- seed deliverable -> orientation evidence.\n\n## Self-review\n- PASS: native pre gate is pending until child results are stopped.\n\n## Critic loop\n- pre-critic: Critic is not dispatched before native pre acceptance.\n- native post gate: pending.\n"
);
std::fs::write(run_dir.join("phase0.md"), &phase0).expect("phase0");
let direct_planned = invoke(&root, &["run", "transition", run, "--to", "planned"]);
assert_ne!(
direct_planned.status.code(),
Some(0),
"planned bypassed orientation"
);
let post_before_pre = invoke(&root, &["run", "orientation", "post", run]);
assert_ne!(
post_before_pre.status.code(),
Some(0),
"post existed before pre"
);
std::fs::write(run_dir.join("seed.md"), b"stale project seed\n").expect("mutate seed");
let stale_seed_pre = invoke(&root, &["run", "orientation", "pre", run]);
assert_ne!(
stale_seed_pre.status.code(),
Some(0),
"stale verified seed bypassed native pre"
);
std::fs::write(run_dir.join("seed.md"), seed_bytes).expect("restore typed seed");
std::fs::create_dir_all(run_dir.join("reports")).expect("reports");
let auditor_path = run_dir.join("reports/auditor-1.json");
let discovery_path = run_dir.join("reports/discovery-1.json");
std::fs::write(
&auditor_path,
serde_json::to_vec_pretty(&orientation_report(
&root,
run,
"auditor-1",
"auditor",
"seed.md",
None,
))
.expect("auditor JSON"),
)
.expect("auditor report");
std::fs::write(
&discovery_path,
serde_json::to_vec_pretty(&orientation_report(
&root,
run,
"discovery-1",
"discovery",
"seed.md",
None,
))
.expect("discovery JSON"),
)
.expect("discovery report");
dispatch_orientation_child(&root, run, "engineer-1", "engineer", "phase0.md");
dispatch_orientation_child(&root, run, "auditor-1", "auditor", "reports/auditor-1.json");
dispatch_orientation_child(
&root,
run,
"discovery-1",
"discovery",
"reports/discovery-1.json",
);
dispatch_orientation_child(
&root,
run,
"critic-before-pre",
"critic",
"reports/critic-before-pre.json",
);
let critic_before_pre = invoke(&root, &["run", "orientation", "pre", run]);
assert!(!critic_before_pre.status.success());
let critic_error = text(&critic_before_pre.stderr);
assert!(
critic_error.to_ascii_lowercase().contains("critic"),
"precreated Critic rejection must name the invalid phase ordering: {critic_error}"
);
std::fs::remove_file(run_dir.join("dispatch/critic-before-pre.json"))
.expect("remove precreated Critic fixture");
let valid_engineer_dispatch = run_dir.join("dispatch/engineer-1.json");
let valid_engineer_bytes =
std::fs::read(&valid_engineer_dispatch).expect("valid Engineer dispatch");
let mut forged_engineer: serde_json::Value =
serde_json::from_slice(&valid_engineer_bytes).expect("Engineer dispatch JSON");
forged_engineer["write_scope"] = serde_json::json!(["plan.md"]);
forged_engineer["result_artifact"] = serde_json::json!("plan.md");
std::fs::write(
&valid_engineer_dispatch,
serde_json::to_vec(&forged_engineer).expect("forged Engineer dispatch"),
)
.expect("write forged Engineer dispatch");
let wrong_engineer = invoke(&root, &["run", "orientation", "pre", run]);
assert_ne!(wrong_engineer.status.code(), Some(0));
std::fs::write(&valid_engineer_dispatch, valid_engineer_bytes)
.expect("restore Engineer dispatch");
#[cfg(unix)]
{
let outside_phase0 = root.join("outside-phase0.md");
std::fs::write(&outside_phase0, phase0.as_bytes()).expect("outside phase0");
std::fs::remove_file(run_dir.join("phase0.md")).expect("remove phase0");
std::os::unix::fs::symlink(&outside_phase0, run_dir.join("phase0.md"))
.expect("symlink phase0");
let linked_phase0 = invoke(&root, &["run", "orientation", "pre", run]);
assert_ne!(linked_phase0.status.code(), Some(0));
std::fs::remove_file(run_dir.join("phase0.md")).expect("remove phase0 symlink");
std::fs::write(run_dir.join("phase0.md"), phase0.as_bytes()).expect("restore phase0");
}
let pre = invoke(&root, &["run", "orientation", "pre", run, "--json"]);
assert_eq!(pre.status.code(), Some(0), "stderr={}", text(&pre.stderr));
let pre_value: serde_json::Value = serde_json::from_slice(&pre.stdout).expect("pre JSON");
assert_eq!(pre_value["accepted"], true);
let pre_path = run_dir.join("orientation-pre.json");
let pre_hash = file_digest(&pre_path);
let state_after_pre: serde_json::Value = serde_json::from_slice(
&std::fs::read(run_dir.join("run.json")).expect("state after native pre"),
)
.expect("state after native pre is JSON");
assert_eq!(state_after_pre["orientation_epoch"], 1);
let manifest_doc: serde_json::Value = serde_json::from_slice(
&std::fs::read(run_dir.join("orientation-manifest.json")).expect("manifest"),
)
.expect("manifest JSON");
let pre_doc: serde_json::Value =
serde_json::from_slice(&std::fs::read(&pre_path).expect("pre")).expect("pre JSON");
assert_eq!(manifest_doc["orientation_epoch"], 1);
assert_eq!(pre_doc["orientation_epoch"], 1);
let replay_pre = invoke(&root, &["run", "orientation", "pre", run, "--json"]);
assert_eq!(
replay_pre.status.code(),
Some(0),
"committed pre replay failed: {}",
text(&replay_pre.stderr)
);
let replay_value: serde_json::Value =
serde_json::from_slice(&replay_pre.stdout).expect("replay pre JSON");
assert_eq!(
replay_value["manifest_sha256"],
pre_value["manifest_sha256"]
);
assert_eq!(replay_value["pre_sha256"], pre_value["pre_sha256"]);
let mut crashed_state = state_after_pre.clone();
crashed_state["orientation_epoch"] = serde_json::json!(0);
std::fs::write(
run_dir.join("run.json"),
serde_json::to_vec_pretty(&crashed_state).expect("crashed state JSON"),
)
.expect("simulate crash before run-state publication");
let recovered = invoke(&root, &["run", "orientation", "pre", run, "--json"]);
assert_eq!(
recovered.status.code(),
Some(0),
"registry-ahead pre recovery failed: {}",
text(&recovered.stderr)
);
let replay_state: serde_json::Value = serde_json::from_slice(
&std::fs::read(run_dir.join("run.json")).expect("state after recovery"),
)
.expect("recovered state JSON");
assert_eq!(replay_state["orientation_epoch"], 1);
assert!(run_dir.join("orientation-manifest.json").is_file());
assert!(!run_dir.join("orientation-post.json").exists());
plan_fixture::materialize(&root, run, &["lane-a"], 3);
let critic_path = run_dir.join("reports/critic-1.json");
let critic = orientation_report(
&root,
run,
"critic-1",
"critic",
"seed.md",
Some(serde_json::json!({
"decision": "GREEN",
"findings": [],
"corrections": [],
"blockers": [],
"citations": [
{"source_id": "auditor-1", "path": "reports/auditor-1.json", "sha256": file_digest(&auditor_path), "line": 1},
{"claim_id": "auditor-1-claim"}
]
})),
);
let mut critic = critic;
plan_fixture::add_planning_evidence(&root, run, &mut critic);
critic["orientation_pre_sha256"] = serde_json::Value::String(pre_hash.clone());
std::fs::write(
&critic_path,
serde_json::to_vec_pretty(&critic).expect("critic JSON"),
)
.expect("critic report");
dispatch_orientation_child(&root, run, "critic-1", "critic", "reports/critic-1.json");
let mut circular = critic.clone();
circular["verdict"]["citations"]
.as_array_mut()
.expect("Critic citations")
.push(serde_json::json!({"claim_id": "critic-1-claim"}));
std::fs::write(
&critic_path,
serde_json::to_vec_pretty(&circular).expect("circular critic JSON"),
)
.expect("circular critic report");
let circular_post = invoke(&root, &["run", "orientation", "post", run]);
assert_ne!(
circular_post.status.code(),
Some(0),
"Critic accepted a citation to its own claim"
);
std::fs::write(
&critic_path,
serde_json::to_vec_pretty(&critic).expect("restored critic JSON"),
)
.expect("restore critic report");
let post = invoke(&root, &["run", "orientation", "post", run, "--json"]);
assert_eq!(post.status.code(), Some(0), "stderr={}", text(&post.stderr));
let post_value: serde_json::Value = serde_json::from_slice(&post.stdout).expect("post JSON");
assert_eq!(post_value["accepted"], true);
assert_eq!(post_value["pre_sha256"], pre_hash);
let post_doc: serde_json::Value = serde_json::from_slice(
&std::fs::read(run_dir.join("orientation-post.json")).expect("post"),
)
.expect("post JSON");
assert_eq!(post_doc["orientation_epoch"], 1);
let state_after_post: serde_json::Value =
serde_json::from_slice(&std::fs::read(run_dir.join("run.json")).expect("state after post"))
.expect("state after post is JSON");
assert_eq!(state_after_post["orientation_epoch"], 1);
let plan_path = run_dir.join("plan.md");
let verified_plan = std::fs::read(&plan_path).expect("reviewed plan");
std::fs::remove_file(&plan_path).expect("temporarily missing fixture plan");
let no_plan = invoke(&root, &["run", "transition", run, "--to", "planned"]);
assert!(
!no_plan.status.success(),
"orientation alone accepted execution-ready state"
);
assert_eq!(
RunState::load(&run_dir.join("run.json"))
.expect("unchanged planted state")
.status,
Vocabulary::Known(RunStatus::Planted)
);
std::fs::write(&plan_path, verified_plan).expect("restore reviewed plan");
// Planned is a native recomputation boundary, not a caller-supplied
// status shortcut. A forged post is replaced from the current dispatch
// ledger before the state transition is allowed.
std::fs::write(run_dir.join("orientation-post.json"), b"{}\n").expect("forge post");
let recomputed = invoke(&root, &["run", "transition", run, "--to", "planned"]);
assert_eq!(
recomputed.status.code(),
Some(0),
"stderr={}",
text(&recomputed.stderr)
);
let recomputed_post: serde_json::Value = serde_json::from_slice(
&std::fs::read(run_dir.join("orientation-post.json")).expect("recomputed post"),
)
.expect("recomputed post JSON");
assert_eq!(recomputed_post["accepted"], true);
let planned_state: serde_json::Value =
serde_json::from_slice(&std::fs::read(run_dir.join("run.json")).expect("planned state"))
.expect("planned state JSON");
assert_eq!(planned_state["status"], "planned");
assert_eq!(planned_state["orientation_epoch"], 1);
assert_eq!(
invoke(&root, &["run", "transition", run, "--to", "planted"])
.status
.code(),
Some(0)
);
std::fs::write(phase0_path(&run_dir), b"# Orientation\nchanged\n").expect("mutate phase0");
let stale = invoke(&root, &["run", "transition", run, "--to", "planned"]);
assert_ne!(
stale.status.code(),
Some(0),
"stale post permitted planned transition"
);
assert!(text(&invoke(&root, &["run", "show", run, "--json"]).stdout).contains("planted"));
std::fs::write(run_dir.join("phase0.md"), phase0).expect("restore phase0");
let planned = invoke(&root, &["run", "transition", run, "--to", "planned"]);
assert_eq!(
planned.status.code(),
Some(0),
"stderr={}",
text(&planned.stderr)
);
let planned_bytes = std::fs::read(run_dir.join("run.json")).expect("planned state bytes");
let verified = invoke(&root, &["plan", "verify", "--run", run, "--json"]);
assert!(
verified.status.success(),
"planned verification: {}",
text(&verified.stderr)
);
for path in [
run_dir.join("plan.md"),
run_dir.join("seed.md"),
run_dir.join("mesh.md"),
run_dir.join("phase0.md"),
run_dir.join("graph/topology.json"),
run_dir.join("lanes/lane-a/plan.md"),
run_dir.join("plan-probes.json"),
run_dir.join("orientation-post.json"),
root.join("src/plan-fixture-0.rs"),
] {
let original = std::fs::read(&path).expect("bound artifact");
let mut changed = original.clone();
changed.extend_from_slice(b"\nchanged after native planning\n");
std::fs::write(&path, changed).expect("change bound artifact");
for args in [
vec!["sprint", "open", "--run", run],
vec!["run", "transition", run, "--to", "executing"],
] {
let refused = invoke(&root, &args);
assert!(
!refused.status.success(),
"{} changed but execution opened",
path.display()
);
assert_eq!(
std::fs::read(run_dir.join("run.json")).expect("unchanged state"),
planned_bytes
);
}
std::fs::write(path, original).expect("restore exact bound bytes");
}
let probes_path = run_dir.join("plan-probes.json");
let probes = std::fs::read(&probes_path).expect("canonical probes");
std::fs::remove_file(&probes_path).expect("remove canonical probes fixture");
std::fs::write(
run_dir.join("plan-check.json"),
b"{\"ok\":true,\"accepted\":true}\n",
)
.expect("caller-forged successful report");
let no_probes = invoke(&root, &["sprint", "open", "--run", run]);
assert!(
!no_probes.status.success(),
"a caller-authored report replaced canonical probes"
);
assert_eq!(
std::fs::read(run_dir.join("run.json")).expect("unchanged state"),
planned_bytes
);
std::fs::write(&probes_path, probes).expect("restore canonical probes");
let mut forged_state: serde_json::Value =
serde_json::from_slice(&planned_bytes).expect("state JSON");
forged_state["plan"] = serde_json::json!("");
std::fs::write(
run_dir.join("run.json"),
serde_json::to_vec(&forged_state).expect("forged state"),
)
.expect("fixture removes plan pointer");
let no_pointer = invoke(&root, &["sprint", "open", "--run", run]);
assert!(
!no_pointer.status.success(),
"planned state without exact plan pointer was accepted"
);
std::fs::write(run_dir.join("run.json"), planned_bytes).expect("restore exact native state");
let planning_bind = invoke_with_stdin(
&root,
&["dispatch", "bind-root"],
serde_json::json!({
"schema": "shepherd.dispatch-request/1",
"run": run,
"harness": "codex",
"session_id": "root-session",
"role_carrier": "shepherd:shepherd",
"mode": "planning",
"lease_ms": 60000
}),
);
assert_eq!(
planning_bind.status.code(),
Some(0),
"planning bind stderr={}",
text(&planning_bind.stderr)
);
let opened = invoke(&root, &["sprint", "open", "--run", run]);
assert_eq!(
opened.status.code(),
Some(0),
"verified planned sprint open stderr={}",
text(&opened.stderr)
);
let execution_bind = invoke_with_stdin(
&root,
&["dispatch", "bind-root"],
serde_json::json!({
"schema": "shepherd.dispatch-request/1",
"run": run,
"harness": "codex",
"session_id": "root-session",
"role_carrier": "shepherd:shepherd",
"mode": "execution",
"lease_ms": 60000
}),
);
assert_eq!(
execution_bind.status.code(),
Some(0),
"execution rebind stderr={}",
text(&execution_bind.stderr)
);
let execution_binding: serde_json::Value =
serde_json::from_slice(&execution_bind.stdout).expect("execution binding JSON");
assert_eq!(execution_binding["mode"], "execution");
let resolved = invoke_with_stdin(
&root,
&["dispatch", "resolve"],
serde_json::json!({
"schema": "shepherd.dispatch-request/1",
"run": run,
"harness": "codex",
"session_id": "root-session"
}),
);
assert_eq!(
resolved.status.code(),
Some(0),
"execution resolve stderr={}",
text(&resolved.stderr)
);
let resolution: serde_json::Value =
serde_json::from_slice(&resolved.stdout).expect("execution resolution JSON");
assert_eq!(resolution["run"], run);
assert_eq!(resolution["mode"], "execution");
cleanup(&root);
}
fn phase0_path(run_dir: &Path) -> PathBuf {
run_dir.join("phase0.md")
}
#[test]
fn native_run_init_mints_unforgeable_incarnation_and_rejects_recreated_run_ids() {
let root = fixture("native-incarnation");
let run = "v910-dev0";
let init = invoke(&root, &["run", "init", run]);
assert_eq!(init.status.code(), Some(0), "stderr={}", text(&init.stderr));
let state_path = root.join(".shepherd/runs").join(run).join("run.json");
let state: serde_json::Value =
serde_json::from_slice(&std::fs::read(&state_path).expect("native run state"))
.expect("native run state JSON");
let incarnation = state["run_incarnation"]
.as_str()
.expect("native run incarnation")
.to_owned();
assert!(!incarnation.is_empty());
assert_eq!(state["orientation_epoch"], 0);
assert!(
root.join(".shepherd/native-orientation-registry.json")
.is_file()
);
std::fs::remove_dir_all(root.join(".shepherd/runs").join(run)).expect("remove old run");
let recreated = invoke(&root, &["run", "init", run]);
assert_ne!(recreated.status.code(), Some(0), "run id was reusable");
assert!(text(&recreated.stderr).contains("incarnation"));
cleanup(&root);
}
#[test]
fn planned_transition_rejects_identity_mutation_in_the_same_command() {
let root = fixture("planned-identity-mutation");
let run = "v911-dev0";
let init = invoke(&root, &["run", "init", run]);
assert_eq!(init.status.code(), Some(0), "stderr={}", text(&init.stderr));
let before = std::fs::read(root.join(".shepherd/runs").join(run).join("run.json"))
.expect("state before mutation");
let result = invoke(
&root,
&[
"run",
"set",
run,
"--status",
"planned",
"--seed",
"attacker.md",
],
);
assert_ne!(
result.status.code(),
Some(0),
"identity mutation was accepted"
);
assert_eq!(
std::fs::read(root.join(".shepherd/runs").join(run).join("run.json"))
.expect("state after mutation"),
before,
"rejected transition must not mutate native state"
);
cleanup(&root);
}
#[test]
fn direct_executing_status_cannot_bypass_native_planning_readiness() {
let root = fixture("executing-gate");
let init = invoke(&root, &["init", "--confirm"]);
assert!(init.status.success(), "{}", text(&init.stderr));
let run = "v908";
let initialized = invoke(&root, &["run", "init", run]);
assert!(
initialized.status.success(),
"{}",
text(&initialized.stderr)
);
let state_path = root.join(format!(".shepherd/runs/{run}/run.json"));
let before = std::fs::read(&state_path).expect("initial state");
for args in [
vec!["run", "transition", run, "--to", "executing"],
vec!["sprint", "open", "--run", run],
] {
let denied = invoke(&root, &args);
assert!(
!denied.status.success(),
"unverified execution was accepted: {}",
text(&denied.stdout)
);
assert_eq!(
std::fs::read(&state_path).expect("unmodified state"),
before
);
}
cleanup(&root);
}
#[test]
fn native_plan_fixture_opens_only_after_plan_and_orientation_acceptance() {
for lanes in [
vec!["lane-a"],
vec!["lane-a", "lane-b"],
vec!["lane-a", "lane-b", "lane-c", "lane-d", "lane-e", "lane-f"],
] {
let root = fixture("native-planning-fixture");
let run = "v909-dev0";
let baseline = plan_fixture::open_execution(&root, run, &lanes, 3);
let state_path = root.join(format!(".shepherd/runs/{run}/run.json"));
let state = RunState::load(&state_path).expect("native executing state");
assert_eq!(state.status, Vocabulary::Known(RunStatus::Executing));
assert_eq!(
state.lanes.len(),
lanes.len(),
"execution must bind every verified lane"
);
for (expected, lane) in lanes.iter().zip(&state.lanes) {
assert_eq!(lane.id, *expected);
assert_eq!(lane.plan, format!("lanes/{expected}/plan.md"));
assert_eq!(lane.state, Vocabulary::Known(LaneStatus::Pending));
assert!(lane.accepted_commit.is_none() && !lane.merged);
}
let close = invoke(&root, &["sprint", "close", "--run", run]);
assert!(
!close.status.success(),
"pending verified lanes permitted vacuous close"
);
let post: serde_json::Value = serde_json::from_slice(
&std::fs::read(root.join(format!(".shepherd/runs/{run}/orientation-post.json")))
.expect("native planned checkpoint"),
)
.expect("checkpoint JSON");
assert_eq!(post["planning"]["schema"], "shepherd.plan-readiness/1");
assert_eq!(post["planning"]["baseline"], baseline);
assert_eq!(post["planning"]["probe_count"], lanes.len());
cleanup(&root);
}
}
#[test]
fn planned_requires_critic_hashes_for_every_canonical_artifact() {
let root = fixture("critic-plan-hashes");
let run = "v914";
plan_fixture::prepare_execution(&root, run, &["lane-a"], 3);
assert!(
invoke(&root, &["run", "transition", run, "--to", "planted"])
.status
.success()
);
let directory = root.join(format!(".shepherd/runs/{run}"));
let post: serde_json::Value = serde_json::from_slice(
&std::fs::read(directory.join("orientation-post.json")).expect("post"),
)
.expect("post JSON");
let critic_path = directory.join("reports/fixture-critic.json");
let original = std::fs::read(&critic_path).expect("Critic report");
for relative in post["planning"]["run_artifacts"]
.as_object()
.expect("planning inventory")
.keys()
{
let mut critic: serde_json::Value = serde_json::from_slice(&original).expect("Critic JSON");
let path = format!(".shepherd/runs/{run}/{relative}");
critic["evidence"]
.as_array_mut()
.expect("evidence")
.retain(|entry| entry["path"] != path);
std::fs::write(
&critic_path,
serde_json::to_vec(&critic).expect("omitted evidence"),
)
.expect("fixture report");
let denied = invoke(&root, &["run", "transition", run, "--to", "planned"]);
assert!(
!denied.status.success(),
"unreviewed artifact {relative} passed planned transition"
);
assert!(
text(&denied.stderr).contains("Critic did not bind exact planning artifact")
|| text(&denied.stderr).contains("claim has unbound evidence"),
"{}",
text(&denied.stderr)
);
std::fs::write(&critic_path, &original).expect("restore report");
}
let accepted = invoke(&root, &["run", "transition", run, "--to", "planned"]);
assert!(accepted.status.success(), "{}", text(&accepted.stderr));
cleanup(&root);
}
fn commit_fixture_paths(root: &Path, paths: &[&str]) -> String {
assert!(
Command::new("git")
.args(["add", "-f", "--"])
.args(paths)
.current_dir(root)
.status()
.expect("stage fixture")
.success()
);
let commit = Command::new("git")
.args([
"-c",
"user.name=Shepherd Tests",
"-c",
"user.email=shepherd-tests@example.invalid",
"commit",
"--quiet",
"-m",
"commit planning inputs",
])
.current_dir(root)
.output()
.expect("commit fixture");
assert!(commit.status.success(), "{}", text(&commit.stderr));
let head = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.expect("HEAD");
assert!(head.status.success());
text(&head.stdout).trim().to_owned()
}
#[test]
fn planning_only_descendant_keeps_source_baseline_and_records_execution_head() {
let root = fixture("planning-descendant");
let run = "v915";
let baseline = plan_fixture::prepare_execution(&root, run, &["lane-a"], 3);
assert!(
invoke(&root, &["run", "transition", run, "--to", "planted"])
.status
.success()
);
let head = commit_fixture_paths(
&root,
&[
&format!(".shepherd/runs/{run}/plan-probes.json"),
&format!(".shepherd/runs/{run}/reports/fixture-critic.json"),
],
);
assert_ne!(baseline, head);
for args in [
vec!["plan", "verify", "--run", run, "--json"],
vec!["run", "transition", run, "--to", "planned"],
vec!["sprint", "open", "--run", run],
] {
let accepted = invoke(&root, &args);
assert!(
accepted.status.success(),
"{args:?}: {}",
text(&accepted.stderr)
);
}
let directory = root.join(format!(".shepherd/runs/{run}"));
let post: serde_json::Value = serde_json::from_slice(
&std::fs::read(directory.join("orientation-post.json")).expect("post"),
)
.expect("post JSON");
assert_eq!(post["planning"]["baseline"], baseline);
let state = RunState::load(&directory.join("run.json")).expect("opened run");
assert_eq!(state.extra["planning_execution_head"], head);
cleanup(&root);
}
#[test]
fn planning_descendants_reject_product_seed_mesh_other_runs_and_unissued_reports() {
for relative in [
"src/plan-fixture-0.rs",
".shepherd/runs/v916/seed.md",
".shepherd/runs/v916/mesh.md",
".shepherd/runs/v916/reports/unissued.json",
".shepherd/runs/v917/reports/foreign.json",
] {
let root = fixture("nonplanning-descendant");
let run = "v916";
plan_fixture::prepare_execution(&root, run, &["lane-a"], 3);
assert!(
invoke(&root, &["run", "transition", run, "--to", "planted"])
.status
.success()
);
let path = root.join(relative);
std::fs::create_dir_all(path.parent().expect("fixture parent")).expect("fixture parent");
let original = std::fs::read(&path).unwrap_or_default();
let mut changed = original.clone();
changed.extend_from_slice(b"\n// Nonplanning tracked change.\n");
std::fs::write(&path, changed).expect("changed fixture");
commit_fixture_paths(&root, &[relative]);
let denied = invoke(&root, &["plan", "verify", "--run", run, "--json"]);
assert!(
!denied.status.success(),
"non-planning descendant accepted {relative}"
);
assert!(
text(&denied.stderr).contains("planning descendant changes non-planning path"),
"{}",
text(&denied.stderr)
);
if relative.starts_with("src/") {
std::fs::write(path, original).expect("revert fixture product change");
commit_fixture_paths(&root, &[relative]);
let denied = invoke(&root, &["plan", "verify", "--run", run, "--json"]);
assert!(
!denied.status.success(),
"reverted product history was treated as planning-only"
);
assert!(
text(&denied.stderr).contains("planning descendant changes non-planning path"),
"{}",
text(&denied.stderr)
);
}
cleanup(&root);
}
}
#[test]
fn planning_baseline_rejects_unknown_noncommit_and_nonancestor_objects() {
let root = fixture("planning-baseline-identity");
let run = "v918";
plan_fixture::prepare_execution(&root, run, &["lane-a"], 3);
assert!(
invoke(&root, &["run", "transition", run, "--to", "planted"])
.status
.success()
);
let tree = Command::new("git")
.args(["rev-parse", "HEAD^{tree}"])
.current_dir(&root)
.output()
.expect("tree");
assert!(tree.status.success());
let tree = text(&tree.stdout).trim().to_owned();
let unrelated = Command::new("git")
.args([
"-c",
"user.name=Shepherd Tests",
"-c",
"user.email=shepherd-tests@example.invalid",
"commit-tree",
&tree,
"-m",
"unrelated fixture root",
])
.current_dir(&root)
.output()
.expect("unrelated commit object");
assert!(unrelated.status.success(), "{}", text(&unrelated.stderr));
let unrelated = text(&unrelated.stdout).trim().to_owned();
let probes_path = root.join(format!(".shepherd/runs/{run}/plan-probes.json"));
let original = std::fs::read(&probes_path).expect("probes");
for (baseline, message) in [
("0".repeat(40), "not a known commit"),
(tree, "not a known commit"),
(unrelated, "not an ancestor"),
] {
let mut probes: serde_json::Value = serde_json::from_slice(&original).expect("probe JSON");
probes["baseline"] = baseline.into();
std::fs::write(
&probes_path,
serde_json::to_vec(&probes).expect("invalid baseline"),
)
.expect("fixture probes");
let denied = invoke(&root, &["plan", "verify", "--run", run, "--json"]);
assert!(!denied.status.success(), "unverified baseline accepted");
assert!(
text(&denied.stderr).contains(message),
"{}",
text(&denied.stderr)
);
}
cleanup(&root);
}
#[test]
fn execution_rejects_unknown_duplicate_or_nonpending_lane_registrations() {
let root = fixture("planning-lane-custody");
let run = "v910-dev0";
plan_fixture::prepare_execution(&root, run, &["lane-a"], 3);
let registered = invoke(
&root,
&[
"run",
"lane",
"add",
run,
"lane-a",
"--branch",
"lane/retained",
],
);
assert!(registered.status.success(), "{}", text(®istered.stderr));
let state_path = root.join(format!(".shepherd/runs/{run}/run.json"));
let original = std::fs::read(&state_path).expect("preregistered pending lane");
let state: serde_json::Value = serde_json::from_slice(&original).expect("lane state JSON");
for (field, value) in [
("id", serde_json::json!("unplanned")),
("state", serde_json::json!("complete")),
("state", serde_json::json!("in-progress")),
("plan", serde_json::json!("lanes/foreign/plan.md")),
("accepted_commit", serde_json::json!("abc123")),
("merged", serde_json::json!(true)),
] {
let mut changed = state.clone();
changed["lanes"][0][field] = value;
let bytes = serde_json::to_vec(&changed).expect("changed lane fixture");
std::fs::write(&state_path, &bytes).expect("write lane fixture");
let refused = invoke(&root, &["sprint", "open", "--run", run]);
assert!(
!refused.status.success(),
"invalid pre-execution lane {field} was accepted"
);
assert_eq!(
std::fs::read(&state_path).expect("unchanged invalid state"),
bytes
);
}
let mut duplicate = state.clone();
duplicate["lanes"]
.as_array_mut()
.expect("lane rows")
.push(state["lanes"][0].clone());
let duplicate = serde_json::to_vec(&duplicate).expect("duplicate lane fixture");
std::fs::write(&state_path, &duplicate).expect("write duplicate lanes");
let refused = invoke(&root, &["sprint", "open", "--run", run]);
assert!(
!refused.status.success(),
"duplicate pre-execution lane was accepted"
);
assert_eq!(
std::fs::read(&state_path).expect("unchanged duplicate state"),
duplicate
);
std::fs::write(&state_path, original).expect("restore native pending registration");
let opened = invoke(&root, &["sprint", "open", "--run", run]);
assert!(opened.status.success(), "{}", text(&opened.stderr));
let state = RunState::load(&state_path).expect("opened lane state");
assert_eq!(state.lanes.len(), 1);
assert_eq!(state.lanes[0].branch, "lane/retained");
assert_eq!(state.lanes[0].state, Vocabulary::Known(LaneStatus::Pending));
cleanup(&root);
}
#[test]
fn linked_worktree_reads_and_mutates_only_the_primary_runs_root() {
let root = fixture("linked-primary");
let init = invoke(&root, &["run", "init", "v906-dev0"]);
assert_eq!(init.status.code(), Some(0));
let commit = Command::new("git")
.args([
"-c",
"user.name=Shepherd Test",
"-c",
"user.email=shepherd-test@example.invalid",
"commit",
"--allow-empty",
"--quiet",
"-m",
"fixture",
])
.current_dir(&root)
.status()
.expect("commit fixture");
assert!(commit.success());
let linked = root.with_file_name(format!(
"{}-linked",
root.file_name().expect("fixture name").to_string_lossy()
));
let add = Command::new("git")
.args(["worktree", "add", "--detach", "--quiet"])
.arg(&linked)
.arg("HEAD")
.current_dir(&root)
.status()
.expect("add linked worktree");
assert!(add.success());
std::fs::create_dir_all(linked.join(".shepherd/runs/v999-dev0")).expect("linked shadow");
std::fs::write(
linked.join(".shepherd/runs/v999-dev0/run.json"),
b"{\"run\":\"v999-dev0\"}\n",
)
.expect("linked shadow state");
let shown = invoke(&linked, &["run", "show", "v906-dev0"]);
assert_eq!(
shown.status.code(),
Some(0),
"stderr={}",
text(&shown.stderr)
);
let shadow = invoke(&linked, &["run", "show", "v999-dev0"]);
assert_eq!(shadow.status.code(), Some(5));
let update = invoke(
&linked,
&["run", "set", "v906-dev0", "--branch", "linked-update"],
);
assert_eq!(
update.status.code(),
Some(0),
"stderr={}",
text(&update.stderr)
);
assert!(
std::fs::read_to_string(root.join(".shepherd/runs/v906-dev0/run.json"))
.expect("primary state")
.contains("\"linked-update\"")
);
std::fs::remove_dir_all(&linked).expect("remove linked fixture");
cleanup(&root);
}