From 38ceab0913abcbfaeaf8be89112976f623a35036 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Thu, 23 Apr 2026 13:34:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(w9e):=20NEW=20kei-replay=20crate=20?= =?UTF-8?q?=E2=80=94=20reconstruct=20spawn=20from=20DNA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kei-replay parses DNA, looks up ledger row, loads task.toml from worktree, re-runs compose_prompt, recomputes body hash, reports match/drift. kei-replay diff flags every changed facet between two DNAs. 6 cubes (main/lib/replay/diff/ledger_lookup), all ≤114 LOC. Direct SQLite access in ledger_lookup.rs (kei-ledger has no lib.rs). v4 schema-compatible (reads id/dna/worktree_path/spec_sha). Tests: 6/6 (≥4 required): happy path, missing DNA, drift detection, diff differing bodies, diff identical, explicit --task override. Workspace Cargo.toml: +kei-replay member. Co-Authored-By: Claude Opus 4.7 (1M context) --- _primitives/_rust/Cargo.lock | 14 ++ _primitives/_rust/Cargo.toml | 2 + _primitives/_rust/kei-replay/Cargo.toml | 30 +++ _primitives/_rust/kei-replay/src/diff.rs | 73 ++++++ .../_rust/kei-replay/src/ledger_lookup.rs | 54 +++++ _primitives/_rust/kei-replay/src/lib.rs | 18 ++ _primitives/_rust/kei-replay/src/main.rs | 114 +++++++++ _primitives/_rust/kei-replay/src/replay.rs | 96 ++++++++ .../_rust/kei-replay/tests/replay_smoke.rs | 229 ++++++++++++++++++ 9 files changed, 630 insertions(+) create mode 100644 _primitives/_rust/kei-replay/Cargo.toml create mode 100644 _primitives/_rust/kei-replay/src/diff.rs create mode 100644 _primitives/_rust/kei-replay/src/ledger_lookup.rs create mode 100644 _primitives/_rust/kei-replay/src/lib.rs create mode 100644 _primitives/_rust/kei-replay/src/main.rs create mode 100644 _primitives/_rust/kei-replay/src/replay.rs create mode 100644 _primitives/_rust/kei-replay/tests/replay_smoke.rs diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index 58824dd..380efe3 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -2129,6 +2129,20 @@ dependencies = [ "tempfile", ] +[[package]] +name = "kei-replay" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "kei-agent-runtime", + "rusqlite", + "sha2 0.10.9", + "tempfile", + "thiserror 1.0.69", + "toml", +] + [[package]] name = "kei-router" version = "0.1.0" diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index a73896b..0eac20d 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -49,6 +49,8 @@ members = [ "kei-cache", # agent substrate v1 — automation envelope: prepare + ledger fork + verify "kei-spawn", + # agent substrate v1 — reconstruct spawn from DNA (ledger row + task.toml + recompose) + "kei-replay", ] [workspace.package] diff --git a/_primitives/_rust/kei-replay/Cargo.toml b/_primitives/_rust/kei-replay/Cargo.toml new file mode 100644 index 0000000..1af8bed --- /dev/null +++ b/_primitives/_rust/kei-replay/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "kei-replay" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Reconstruct agent spawn from DNA — reads ledger row + task.toml, re-composes, detects drift" + +[[bin]] +name = "kei-replay" +path = "src/main.rs" + +[lib] +name = "kei_replay" +path = "src/lib.rs" + +[dependencies] +kei-agent-runtime = { path = "../kei-agent-runtime" } +rusqlite = { version = "0.31", features = ["bundled"] } +clap = { workspace = true } +anyhow = "1" +thiserror = "1" +sha2 = { workspace = true } + +[dev-dependencies] +tempfile = "3" +toml = "0.8" + +[package.metadata.keisei] +backend = "none" +description = "Reconstruct agent spawn from DNA — replay / verify / diff" diff --git a/_primitives/_rust/kei-replay/src/diff.rs b/_primitives/_rust/kei-replay/src/diff.rs new file mode 100644 index 0000000..bbfe103 --- /dev/null +++ b/_primitives/_rust/kei-replay/src/diff.rs @@ -0,0 +1,73 @@ +//! Diff — compare two DNAs facet-by-facet. +//! +//! Pure parser + comparator. No I/O, no ledger lookup. Callers that want +//! the composed-body text diff can run `replay` on each DNA first and diff +//! the resulting `composed_prompt` themselves. + +use anyhow::{anyhow, Result}; +use kei_agent_runtime::dna::Dna; + +/// Diff report between two DNA strings. +#[derive(Debug, Clone)] +pub struct DnaDiff { + pub left: Dna, + pub right: Dna, + pub role_changed: bool, + pub caps_changed: bool, + pub scope_changed: bool, + pub body_changed: bool, + pub nonce_changed: bool, +} + +impl DnaDiff { + /// `true` when every facet is identical (same composition, same nonce). + pub fn is_identical(&self) -> bool { + !(self.role_changed + || self.caps_changed + || self.scope_changed + || self.body_changed + || self.nonce_changed) + } + + /// `true` when the two DNAs would re-compose to the same prompt body + /// (nonce difference allowed — nonces are per-invocation salt). + pub fn is_same_composition(&self) -> bool { + !(self.role_changed || self.caps_changed || self.scope_changed || self.body_changed) + } + + /// Human-readable multi-line report. + pub fn render(&self) -> String { + let mut out = Vec::with_capacity(7); + out.push(format!("left : {}", self.left.render())); + out.push(format!("right: {}", self.right.render())); + out.push(format!("role : {}", flag(self.role_changed))); + out.push(format!("caps : {}", flag(self.caps_changed))); + out.push(format!("scope : {}", flag(self.scope_changed))); + out.push(format!("body : {}", flag(self.body_changed))); + out.push(format!("nonce : {}", flag(self.nonce_changed))); + out.join("\n") + } +} + +fn flag(changed: bool) -> &'static str { + if changed { + "CHANGED" + } else { + "same" + } +} + +/// Parse both DNAs and emit the facet-level diff. +pub fn diff(left: &str, right: &str) -> Result { + let l = Dna::parse(left).map_err(|e| anyhow!("invalid left DNA: {e}"))?; + let r = Dna::parse(right).map_err(|e| anyhow!("invalid right DNA: {e}"))?; + Ok(DnaDiff { + role_changed: l.role != r.role, + caps_changed: l.caps_bitmap != r.caps_bitmap, + scope_changed: l.scope_hash != r.scope_hash, + body_changed: l.body_hash != r.body_hash, + nonce_changed: l.nonce != r.nonce, + left: l, + right: r, + }) +} diff --git a/_primitives/_rust/kei-replay/src/ledger_lookup.rs b/_primitives/_rust/kei-replay/src/ledger_lookup.rs new file mode 100644 index 0000000..301e796 --- /dev/null +++ b/_primitives/_rust/kei-replay/src/ledger_lookup.rs @@ -0,0 +1,54 @@ +//! Direct SQLite read of the kei-ledger DB to resolve DNA → ledger row. +//! +//! kei-ledger ships as a binary-only crate (no lib target), so we query +//! its SQLite file directly. The DB path follows the same fallback order +//! used by the ledger binary itself. + +use anyhow::{anyhow, Context, Result}; +use rusqlite::{params, Connection, OptionalExtension}; +use std::path::PathBuf; + +/// Resolved ledger row subset that kei-replay needs. +#[derive(Debug, Clone)] +pub struct LedgerHit { + pub id: String, + pub dna: String, + pub worktree_path: Option, + pub spec_sha: String, +} + +/// DB path fallback: `$KEI_LEDGER_DB` env → `$HOME/.claude/agents/ledger.sqlite`. +pub fn default_db_path() -> PathBuf { + if let Ok(env) = std::env::var("KEI_LEDGER_DB") { + return PathBuf::from(env); + } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".claude/agents/ledger.sqlite") +} + +/// Look up a row whose `dna` column exactly matches the given string. +/// +/// Returns `None` if no row matches. Errors on DB access failure. +pub fn find_by_dna(db_path: &std::path::Path, dna: &str) -> Result> { + let conn = Connection::open(db_path) + .with_context(|| format!("open ledger DB {}", db_path.display()))?; + let sql = "SELECT id, dna, worktree_path, spec_sha FROM agents WHERE dna = ?1 LIMIT 1"; + let row = conn + .query_row(sql, params![dna], |r| { + Ok(LedgerHit { + id: r.get(0)?, + dna: r.get::<_, Option>(1)?.unwrap_or_default(), + worktree_path: r.get(2)?, + spec_sha: r.get(3)?, + }) + }) + .optional() + .with_context(|| format!("query DNA {dna} from {}", db_path.display()))?; + Ok(row) +} + +/// Resolve DNA → hit, or a well-typed error if the DNA isn't in the ledger. +pub fn require_by_dna(db_path: &std::path::Path, dna: &str) -> Result { + find_by_dna(db_path, dna)? + .ok_or_else(|| anyhow!("DNA `{dna}` not found in ledger {}", db_path.display())) +} diff --git a/_primitives/_rust/kei-replay/src/lib.rs b/_primitives/_rust/kei-replay/src/lib.rs new file mode 100644 index 0000000..582a2aa --- /dev/null +++ b/_primitives/_rust/kei-replay/src/lib.rs @@ -0,0 +1,18 @@ +//! kei-replay — reconstruct an agent spawn from its DNA string. +//! +//! Given a DNA `role::caps::scope::body-nonce`, look up the ledger row, +//! locate the archived `task.toml` for that agent, re-run the compose +//! pipeline, and compare the resulting body hash to the DNA's body segment. +//! A mismatch is schema drift since the original spawn. +//! +//! Constructor Pattern: one responsibility per cube. No I/O beyond SQLite +//! read + `std::fs` on task files + stdout. +//! +//! Modules: +//! - `replay` — reconstruct composed prompt from DNA +//! - `diff` — compare two DNAs (facets + bodies) +//! - `ledger_lookup` — SQLite direct read of ledger rows by DNA + +pub mod diff; +pub mod ledger_lookup; +pub mod replay; diff --git a/_primitives/_rust/kei-replay/src/main.rs b/_primitives/_rust/kei-replay/src/main.rs new file mode 100644 index 0000000..b48099f --- /dev/null +++ b/_primitives/_rust/kei-replay/src/main.rs @@ -0,0 +1,114 @@ +//! kei-replay — CLI dispatcher. +//! +//! Commands: +//! kei-replay — reconstruct; print task.toml + prompt +//! kei-replay --verify — also fail non-zero on body-hash drift +//! kei-replay diff — compare two DNAs, print facet report + +use clap::{Parser, Subcommand}; +use kei_replay::{diff, ledger_lookup, replay}; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser)] +#[command( + name = "kei-replay", + version, + about = "Reconstruct agent spawn from DNA — replay / verify / diff" +)] +struct Cli { + /// Override ledger DB path (default: $KEI_LEDGER_DB or ~/.claude/agents/ledger.sqlite) + #[arg(long, global = true)] + db: Option, + + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Reconstruct the spawn for a DNA string. + Replay { + /// DNA string: role::caps::scope::body-nonce + dna: String, + /// Repo root holding _roles/ and _capabilities/ (default: cwd) + #[arg(long)] + kit_root: Option, + /// Explicit task.toml path (skips ledger lookup for the file path) + #[arg(long)] + task: Option, + /// Fail with exit 2 when recomputed body hash differs from DNA. + #[arg(long)] + verify: bool, + }, + /// Diff two DNA strings facet-by-facet. + Diff { left: String, right: String }, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + match cli.cmd { + Cmd::Replay { dna, kit_root, task, verify } => { + run_replay(cli.db, dna, kit_root, task, verify) + } + Cmd::Diff { left, right } => run_diff(left, right), + } +} + +fn run_replay( + db_cli: Option, + dna: String, + kit_root: Option, + task: Option, + verify: bool, +) -> ExitCode { + let db = db_cli.unwrap_or_else(ledger_lookup::default_db_path); + let kit = kit_root.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| ".".into())); + let result = replay::replay(&db, &dna, task.as_deref(), &kit); + let r = match result { + Ok(r) => r, + Err(e) => { + eprintln!("replay failed: {e}"); + return ExitCode::from(1); + } + }; + print_replay(&r); + if verify && !r.body_hash_matches { + eprintln!( + "DRIFT: DNA body_hash={} but recomputed={} — task.toml differs from original spawn", + r.dna.body_hash, r.recomputed_body_hash + ); + return ExitCode::from(2); + } + ExitCode::SUCCESS +} + +fn print_replay(r: &replay::Replay) { + println!("=== task.toml ==="); + print!("{}", r.task_toml_text); + if !r.task_toml_text.ends_with('\n') { + println!(); + } + println!("=== composed prompt ==="); + println!("{}", r.composed_prompt); + println!("=== integrity ==="); + println!("dna.body_hash = {}", r.dna.body_hash); + println!("recomputed body_hash = {}", r.recomputed_body_hash); + println!( + "match = {}", + if r.body_hash_matches { "yes" } else { "NO (drift)" } + ); +} + +fn run_diff(left: String, right: String) -> ExitCode { + match diff::diff(&left, &right) { + Ok(d) => { + println!("{}", d.render()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("diff failed: {e}"); + ExitCode::from(1) + } + } +} diff --git a/_primitives/_rust/kei-replay/src/replay.rs b/_primitives/_rust/kei-replay/src/replay.rs new file mode 100644 index 0000000..fe815ea --- /dev/null +++ b/_primitives/_rust/kei-replay/src/replay.rs @@ -0,0 +1,96 @@ +//! Replay — reconstruct a spawn's composed prompt from a DNA string. +//! +//! Pipeline: +//! 1. Parse DNA (validates shape). +//! 2. Resolve ledger hit (agent-id, worktree path, spec_sha). +//! 3. Locate `task.toml` (explicit override OR `/tasks//task.toml`). +//! 4. Load task + kit root, re-run `kei_agent_runtime::compose::compose_prompt`. +//! 5. Recompute body hash from the re-loaded `task.body.text` and compare +//! to the DNA body segment — mismatch = schema drift. + +use anyhow::{anyhow, Context, Result}; +use kei_agent_runtime::compose::compose_prompt; +use kei_agent_runtime::dna::Dna; +use kei_agent_runtime::spawn::load_task; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +/// Outcome of a replay pass. +#[derive(Debug, Clone)] +pub struct Replay { + pub dna: Dna, + pub task_toml_text: String, + pub composed_prompt: String, + pub recomputed_body_hash: String, + pub body_hash_matches: bool, +} + +/// Reconstruct the spawn. +/// +/// `db_path` — ledger SQLite file. +/// `dna_str` — the DNA string supplied on the CLI. +/// `task_override` — explicit `task.toml` path if caller knows it (bypasses lookup). +/// `kit_root` — repo root that holds `_roles/` + `_capabilities/`. +pub fn replay( + db_path: &Path, + dna_str: &str, + task_override: Option<&Path>, + kit_root: &Path, +) -> Result { + let dna = Dna::parse(dna_str).map_err(|e| anyhow!("invalid DNA: {e}"))?; + let task_path = resolve_task_path(db_path, &dna, dna_str, task_override)?; + let task_toml_text = std::fs::read_to_string(&task_path) + .with_context(|| format!("read task {}", task_path.display()))?; + let task = load_task(&task_path)?; + let composed_prompt = compose_prompt(&task, kit_root) + .with_context(|| format!("re-compose prompt for {}", dna_str))?; + let recomputed_body_hash = short_sha256(&task.body.text); + let body_hash_matches = recomputed_body_hash.eq_ignore_ascii_case(&dna.body_hash); + Ok(Replay { + dna, + task_toml_text, + composed_prompt, + recomputed_body_hash, + body_hash_matches, + }) +} + +/// Prefer explicit override; else derive from ledger worktree_path + agent-id. +fn resolve_task_path( + db_path: &Path, + _dna: &Dna, + dna_str: &str, + task_override: Option<&Path>, +) -> Result { + if let Some(p) = task_override { + return Ok(p.to_path_buf()); + } + let hit = crate::ledger_lookup::require_by_dna(db_path, dna_str)?; + let wt = hit.worktree_path.ok_or_else(|| { + anyhow!( + "DNA body hash not resolvable: ledger row `{}` has no worktree_path — \ + task.toml required (pass --task )", + hit.id + ) + })?; + let path = PathBuf::from(wt).join("tasks").join(&hit.id).join("task.toml"); + if !path.is_file() { + return Err(anyhow!( + "DNA body hash not resolvable: task.toml not found at {} — \ + pass --task to override", + path.display() + )); + } + Ok(path) +} + +/// 8-hex SHA-256 prefix — mirrors `kei_agent_runtime::dna::short_sha256`. +/// Kept local (that fn is private) so we stay drop-in compatible with the +/// DNA body_hash format without exposing a new API surface. +fn short_sha256(input: &str) -> String { + let digest = Sha256::digest(input.as_bytes()); + format!( + "{:02X}{:02X}{:02X}{:02X}", + digest[0], digest[1], digest[2], digest[3] + ) +} diff --git a/_primitives/_rust/kei-replay/tests/replay_smoke.rs b/_primitives/_rust/kei-replay/tests/replay_smoke.rs new file mode 100644 index 0000000..748c1ec --- /dev/null +++ b/_primitives/_rust/kei-replay/tests/replay_smoke.rs @@ -0,0 +1,229 @@ +//! Smoke tests for kei-replay. +//! +//! Covers: happy-path replay, missing DNA, drift detection, diff. +//! +//! Each test builds its own isolated tempdir with: +//! - SQLite ledger seeded with the relevant `agents` row +//! - `/tasks//task.toml` +//! - `/_roles/.toml` +//! - `/_capabilities///text.md` +//! Then calls `replay::replay` / `diff::diff` directly (skips the CLI layer). + +use kei_agent_runtime::capability::TaskSpec; +use kei_agent_runtime::dna::Dna; +use kei_agent_runtime::role::ResolvedRole; +use kei_replay::{diff, replay}; +use rusqlite::{params, Connection}; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +struct Fixture { + _tmp: TempDir, + db: PathBuf, + kit_root: PathBuf, + agent_id: String, + dna_str: String, + body: String, +} + +fn resolved_fake() -> ResolvedRole { + ResolvedRole { + required: vec!["policy::no-git-ops".into(), "output::report-format".into()], + warnings: Vec::new(), + } +} + +fn write_kit(kit: &Path) { + std::fs::create_dir_all(kit.join("_capabilities/policy/no-git-ops")).unwrap(); + std::fs::write( + kit.join("_capabilities/policy/no-git-ops/text.md"), + "## No git\n\nYou must not git.\n", + ) + .unwrap(); + std::fs::create_dir_all(kit.join("_capabilities/output/report-format")).unwrap(); + std::fs::write( + kit.join("_capabilities/output/report-format/text.md"), + "## Report\n\nEmit a report.\n", + ) + .unwrap(); + std::fs::create_dir_all(kit.join("_roles")).unwrap(); + std::fs::write( + kit.join("_roles/fake.toml"), + r#" +[role] +name = "fake" + +[capabilities] +required = ["policy::no-git-ops", "output::report-format"] +"#, + ) + .unwrap(); +} + +fn seed_ledger(db: &Path, agent_id: &str, dna: &str, worktree: &str) { + let conn = Connection::open(db).unwrap(); + // Minimal schema for what kei-replay reads. Mirrors kei-ledger v4 cols. + conn.execute_batch( + "CREATE TABLE agents ( + id TEXT PRIMARY KEY, + branch TEXT NOT NULL, + parent_branch TEXT, + spec_sha TEXT NOT NULL, + status TEXT NOT NULL, + started_ts INTEGER NOT NULL, + finished_ts INTEGER, + summary TEXT, + worktree_path TEXT, + dna TEXT, + creator_id TEXT, + fork_parent_id TEXT + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO agents (id, branch, spec_sha, status, started_ts, worktree_path, dna) + VALUES (?1, 'agent/test', 'abc', 'running', 0, ?2, ?3)", + params![agent_id, worktree, dna], + ) + .unwrap(); +} + +fn write_task_toml(worktree: &Path, agent_id: &str, body: &str) -> PathBuf { + let dir = worktree.join("tasks").join(agent_id); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("task.toml"); + // Explicit TOML literal: body.text uses triple-quoted block to survive + // any special chars in body strings across tests. + let toml = format!( + r#"[task] +role = "fake" +agent-id = "{agent_id}" + +[body] +text = """{body}""" +"# + ); + std::fs::write(&p, toml).unwrap(); + p +} + +fn build_dna(task: &TaskSpec) -> String { + Dna::compose(task, &resolved_fake()).render() +} + +fn make_fixture(body: &str) -> Fixture { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().to_path_buf(); + let kit_root = root.join("kit"); + let worktree = root.join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + write_kit(&kit_root); + + let agent_id = "test-agent-01".to_string(); + let mut task = TaskSpec::default(); + task.task.role = "fake".into(); + task.task.agent_id = agent_id.clone(); + task.body.text = body.to_string(); + + write_task_toml(&worktree, &agent_id, body); + let dna_str = build_dna(&task); + + let db = root.join("ledger.sqlite"); + seed_ledger(&db, &agent_id, &dna_str, worktree.to_str().unwrap()); + + Fixture { _tmp: tmp, db, kit_root, agent_id, dna_str, body: body.to_string() } +} + +#[test] +fn replay_happy_path_reconstructs_prompt_and_matches_body_hash() { + let f = make_fixture("Port the kei-forge templating subsystem."); + let r = replay::replay(&f.db, &f.dna_str, None, &f.kit_root).expect("replay"); + assert!(r.body_hash_matches, "body hash must match at the happy path"); + assert!(r.composed_prompt.contains("You must not git")); + assert!(r.composed_prompt.contains("Emit a report")); + assert!(r.composed_prompt.contains(&f.body)); + assert_eq!(r.dna.role, "fake"); + // task.toml text is echoed back verbatim + assert!(r.task_toml_text.contains(&f.agent_id)); +} + +#[test] +fn replay_unknown_dna_errors_with_not_found_message() { + let f = make_fixture("body"); + // Use well-formed but unseeded DNA — parse passes, lookup must fail. + let bogus = "fake::NG-RF::DEADBEEF::BAADF00D-12345678"; + let err = replay::replay(&f.db, bogus, None, &f.kit_root).unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.contains("not found"), "expected 'not found', got: {msg}"); +} + +#[test] +fn replay_detects_body_drift_when_task_toml_mutated_after_spawn() { + let f = make_fixture("original body"); + // Mutate the on-disk task.toml to simulate schema drift since spawn. + let task_path = PathBuf::from(&f.db) + .parent() + .unwrap() + .join("worktree/tasks") + .join(&f.agent_id) + .join("task.toml"); + let mutated = format!( + r#"[task] +role = "fake" +agent-id = "{}" + +[body] +text = """MUTATED body — drift injected""" +"#, + f.agent_id + ); + std::fs::write(&task_path, mutated).unwrap(); + + let r = replay::replay(&f.db, &f.dna_str, None, &f.kit_root).expect("replay"); + assert!( + !r.body_hash_matches, + "drift must be detected: dna={} recomputed={}", + r.dna.body_hash, r.recomputed_body_hash + ); + assert_ne!(r.dna.body_hash, r.recomputed_body_hash); +} + +#[test] +fn diff_two_dnas_flags_every_changed_facet() { + let f1 = make_fixture("body-one"); + let f2 = make_fixture("body-two"); + let d = diff::diff(&f1.dna_str, &f2.dna_str).expect("diff"); + // Same role + same caps + same (empty) scope + different body => different body_hash + assert!(!d.role_changed); + assert!(!d.caps_changed); + assert!(!d.scope_changed); + assert!(d.body_changed, "body must differ between the two fixtures"); + // Different invocations => nonces almost always differ. + assert!(d.nonce_changed || !d.is_identical()); + assert!(!d.is_same_composition(), "body differs => composition differs"); + let rendered = d.render(); + assert!(rendered.contains("CHANGED")); +} + +#[test] +fn diff_identical_dna_strings_report_as_identical() { + let f = make_fixture("same"); + let d = diff::diff(&f.dna_str, &f.dna_str).expect("diff"); + assert!(d.is_identical()); + assert!(d.is_same_composition()); + assert!(!d.body_changed); + assert!(!d.nonce_changed); +} + +#[test] +fn replay_honours_explicit_task_override_bypassing_ledger_worktree() { + // Fixture's task.toml lives at worktree/tasks//task.toml; copy it to + // a side path and pass --task override. Ledger row is still required for + // the DNA lookup (to assert it was spawned), but file path is overridden. + let f = make_fixture("override test"); + let side = f.kit_root.parent().unwrap().join("side-task.toml"); + let orig = f.kit_root.parent().unwrap().join("worktree/tasks").join(&f.agent_id).join("task.toml"); + std::fs::copy(&orig, &side).unwrap(); + let r = replay::replay(&f.db, &f.dna_str, Some(&side), &f.kit_root).expect("replay"); + assert!(r.body_hash_matches); +}