use assert_cmd::Command;
use predicates::prelude::*;
use spool::domain::MemoryScope;
use spool::lifecycle_store::{
    LifecycleStore, ProposeMemoryRequest, RecordMemoryRequest, TransitionMetadata,
    lifecycle_root_from_config, propose_ai_memory, record_manual_memory,
};
use std::fs;
use tempfile::tempdir;

#[test]
fn get_outputs_prompt_context() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(vault_dir.join("20-Areas")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/spool 项目档案.md"),
        r#"---
tags: [rust, cli]
---
# spool 项目档案

这是 spool 项目背景。
repo_path 识别很重要。
"#,
    )
    .unwrap();

    fs::write(
        vault_dir.join("20-Areas/AI协作偏好.md"),
        "# AI协作偏好\n\n先理解边界，再动手。\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[output]
default_format = "prompt"
max_chars = 12000
max_notes = 8

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects", "20-Areas"]
default_tags = ["rust", "cli"]

[[projects.modules]]
id = "routing"
path_prefixes = ["src/engine"]
keywords = ["repo_path", "route"]

[[scenes]]
id = "planning"
keywords = ["规划", "方案"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "实现 repo_path route 规划",
            "--cwd",
            repo_dir.to_str().unwrap(),
            "--files",
            "src/engine/project_matcher.rs",
            "--format",
            "prompt",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("项目：spool"))
        .stdout(predicate::str::contains("候选笔记"));
}

#[test]
fn explain_outputs_route_trace() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/spool.md"),
        "# spool\n\nroute planning context\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]

[[scenes]]
id = "planning"
keywords = ["planning"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "explain",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "planning route",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("# route explain"))
        .stdout(predicate::str::contains("## project"))
        .stdout(predicate::str::contains("matched_project_id: spool"))
        .stdout(predicate::str::contains("note_roots: 10-Projects"))
        .stdout(predicate::str::contains("scan_roots: 10-Projects"))
        .stdout(predicate::str::contains("note_count: 1"));
}

#[test]
fn get_target_should_affect_prompt_header() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/spool.md"),
        "# spool\n\ncontext\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "spool context",
            "--cwd",
            repo_dir.to_str().unwrap(),
            "--target",
            "codex",
            "--format",
            "prompt",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("给 Codex 使用的精简上下文"));
}

#[test]
fn get_should_rank_title_heading_and_wikilink_matches_above_body_only_matches() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/routing-guide.md"),
        "# Repo Path Routing Guide\n\n## Project Matcher\n\nSee [[Project Matcher]] for routing details.\n",
    )
    .unwrap();
    fs::write(
        vault_dir.join("10-Projects/notes.md"),
        "# Implementation Notes\n\nBackground paragraph with routing mentioned once.\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[output]
default_format = "markdown"
max_chars = 12000
max_notes = 8

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]

[[projects.modules]]
id = "routing"
path_prefixes = ["src/engine"]
keywords = ["routing"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    let assert = Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "Refine Repo-Path routing",
            "--cwd",
            repo_dir.to_str().unwrap(),
            "--files",
            "src/engine/RepoPathMatcher.rs",
            "--format",
            "markdown",
        ])
        .assert()
        .success();

    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    let guide_index = stdout.find("## 10-Projects/routing-guide.md").unwrap();
    let notes_index = stdout.find("## 10-Projects/notes.md").unwrap();
    assert!(guide_index < notes_index, "stdout was:\n{stdout}");
}

#[test]
fn get_should_prioritize_structured_frontmatter_memory() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/auth-constraints.md"),
        r#"---
memory_type: constraint
project_id: spool
sensitivity: internal
source_of_truth: true
retrieval_priority: high
---
# Auth Constraints

Authentication design constraints and invariants.
"#,
    )
    .unwrap();
    fs::write(
        vault_dir.join("10-Projects/auth-notes.md"),
        "# Auth Constraints\n\nAuthentication design constraints and invariants.\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[output]
default_format = "markdown"
max_chars = 12000
max_notes = 8

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    let assert = Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "review auth constraints",
            "--cwd",
            repo_dir.to_str().unwrap(),
            "--format",
            "markdown",
        ])
        .assert()
        .success();

    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    let curated_index = stdout.find("## 10-Projects/auth-constraints.md").unwrap();
    let generic_index = stdout.find("## 10-Projects/auth-notes.md").unwrap();
    assert!(curated_index < generic_index, "stdout was:\n{stdout}");
}

#[test]
fn scene_preferred_notes_should_influence_candidates() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(vault_dir.join("20-Areas")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/spool.md"),
        "# spool\n\ngeneral context\n",
    )
    .unwrap();
    fs::write(
        vault_dir.join("20-Areas/AI协作偏好.md"),
        "# AI协作偏好\n\npreferred scene note\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["./10-Projects", "20-Areas/../20-Areas"]

[[scenes]]
id = "planning"
keywords = ["planning"]
preferred_notes = ["./20-Areas/../20-Areas/AI协作偏好.md"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "explain",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "planning",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("preferred by scene planning"))
        .stdout(predicate::str::contains(
            "preferred_notes: 20-Areas/AI协作偏好.md",
        ));
}

#[test]
fn get_uses_config_default_format_when_flag_missing() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/spool.md"),
        "# spool\n\ncontext\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[output]
default_format = "json"
max_chars = 12000
max_notes = 8

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "spool context",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("\"route\""));
}

#[test]
fn invalid_default_format_should_fail_fast() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/bad.md"),
        "---\ntags: [oops\n---\n# bad\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[output]
default_format = "yaml"
max_chars = 12000
max_notes = 8

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "spool context",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .failure()
        .stderr(
            predicate::str::contains("unknown variant `yaml`")
                .or(predicate::str::contains("output.default_format")),
        );
}

#[test]
fn wakeup_project_json_outputs_canonical_packet() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/project.md"),
        r#"---
memory_type: project
project_id: spool
sensitivity: internal
source_of_truth: true
retrieval_priority: high
---
# spool Project

Current project context.
"#,
    )
    .unwrap();
    fs::write(
        vault_dir.join("10-Projects/constraints.md"),
        r#"---
memory_type: constraint
project_id: spool
sensitivity: internal
source_of_truth: true
retrieval_priority: high
---
# Constraints

Keep Obsidian as curated truth.
"#,
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[output]
default_format = "json"
max_chars = 12000
max_notes = 8

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    let assert = Command::cargo_bin("spool")
        .unwrap()
        .args([
            "wakeup",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "design MCP interface",
            "--cwd",
            repo_dir.to_str().unwrap(),
            "--files",
            "src/app.rs",
            "--target",
            "claude",
            "--profile",
            "project",
        ])
        .assert()
        .success();

    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    let packet: serde_json::Value = serde_json::from_str(&stdout).unwrap();

    assert_eq!(packet["version"], "wakeup.v1");
    assert_eq!(packet["profile"], "project");
    assert_eq!(packet["target"], "claude");
    assert_eq!(packet["query"]["task"], "design MCP interface");
    assert_eq!(packet["query"]["files"][0], "src/app.rs");
    assert!(packet.get("identity").is_some());
    assert!(packet.get("working_style").is_some());
    assert!(packet.get("active_context").is_some());
    assert!(packet.get("priorities").is_some());
    assert!(packet.get("constraints").is_some());
    assert!(packet.get("decisions").is_some());
    assert!(packet.get("incidents").is_some());
    assert!(packet.get("recommended_notes").is_some());
    assert!(packet.get("provenance").is_some());
    assert!(packet.get("policy").is_some());
}

#[test]
fn wakeup_should_inject_knowledge_index_when_available() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();
    fs::write(
        vault_dir.join("10-Projects/project.md"),
        "---\nmemory_type: project\nproject_id: spool\nsensitivity: internal\n---\n# spool\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[output]
default_format = "json"
max_chars = 8000
max_notes = 4

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    // Seed one user-level and one project-level accepted record so INDEX is populated.
    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "record-manual",
            "--config",
            config_path.to_str().unwrap(),
            "--title",
            "User pref",
            "--summary",
            "prefer concise output",
            "--memory-type",
            "preference",
            "--scope",
            "user",
            "--source-ref",
            "manual:cli",
            "--user-id",
            "long",
        ])
        .assert()
        .success();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "record-manual",
            "--config",
            config_path.to_str().unwrap(),
            "--title",
            "Project decision X",
            "--summary",
            "chose approach A",
            "--memory-type",
            "decision",
            "--scope",
            "project",
            "--source-ref",
            "manual:cli",
            "--user-id",
            "long",
            "--project-id",
            "spool",
        ])
        .assert()
        .success();

    // Now run wakeup — INDEX.md was refreshed on each record-manual and
    // should be injected into the packet.
    let assert = Command::cargo_bin("spool")
        .unwrap()
        .args([
            "wakeup",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "resume work",
            "--cwd",
            repo_dir.to_str().unwrap(),
            "--target",
            "claude",
            "--profile",
            "project",
        ])
        .assert()
        .success();

    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    let packet: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    let index = packet["knowledge_index"]
        .as_str()
        .expect("knowledge_index should be present");
    assert!(index.contains("User-Level"));
    assert!(index.contains("User pref"));
    assert!(index.contains("Project: spool"));
    assert!(index.contains("Project decision X"));
}

#[test]
fn wakeup_should_omit_knowledge_index_when_file_missing() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();
    fs::write(
        vault_dir.join("10-Projects/project.md"),
        "---\nmemory_type: project\nproject_id: spool\n---\n# spool\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[output]
default_format = "json"

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    let assert = Command::cargo_bin("spool")
        .unwrap()
        .args([
            "wakeup",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "resume work",
            "--cwd",
            repo_dir.to_str().unwrap(),
            "--target",
            "claude",
            "--profile",
            "project",
        ])
        .assert()
        .success();

    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    let packet: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    // knowledge_index 因 skip_serializing_if=None 所以 key 不存在,或存在为 null
    let index = packet.get("knowledge_index");
    assert!(
        index.is_none() || index == Some(&serde_json::Value::Null),
        "knowledge_index should be absent or null when INDEX.md missing"
    );
}

#[test]
fn wakeup_developer_profile_should_use_developer_roots() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("00-Identity")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("00-Identity/preferences.md"),
        r#"---
memory_type: preference
sensitivity: internal
source_of_truth: true
---
# Collaboration Preferences

Prefer concise responses and explicit provenance.
"#,
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[developer]
note_roots = ["00-Identity"]
"#,
        vault_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    let assert = Command::cargo_bin("spool")
        .unwrap()
        .args([
            "wakeup",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "developer wakeup",
            "--cwd",
            repo_dir.to_str().unwrap(),
            "--profile",
            "developer",
        ])
        .assert()
        .success();

    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    let packet: serde_json::Value = serde_json::from_str(&stdout).unwrap();

    assert_eq!(packet["profile"], "developer");
    assert_eq!(packet["identity"]["active_profile"], "developer");
    assert_eq!(packet["identity"]["developer_roots"][0], "00-Identity");
    assert!(
        !packet["working_style"]["items"]
            .as_array()
            .unwrap()
            .is_empty()
    );
}

#[test]
fn wakeup_should_render_markdown_and_prompt_formats() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/project.md"),
        r#"---
memory_type: project
project_id: spool
sensitivity: internal
source_of_truth: true
retrieval_priority: high
---
# spool Project

Current project context.
"#,
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "wakeup",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "markdown wakeup",
            "--cwd",
            repo_dir.to_str().unwrap(),
            "--profile",
            "project",
            "--format",
            "markdown",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("# wakeup packet"));

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "wakeup",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "prompt wakeup",
            "--cwd",
            repo_dir.to_str().unwrap(),
            "--profile",
            "project",
            "--format",
            "prompt",
            "--target",
            "codex",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("给 Codex 使用的 wake-up packet"));
}

#[test]
fn empty_frontmatter_should_be_allowed() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/empty-frontmatter.md"),
        "---\n---\n# spool\n\ncontext\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[output]
default_format = "prompt"
max_chars = 12000
max_notes = 8

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "spool context",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .success();
}

#[test]
fn parent_directory_should_not_match_project() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_root = temp.path().join("workspace");
    let repo_dir = repo_root.join("spool");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/spool.md"),
        "# spool\n\ncontext\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "explain",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "spool context",
            "--cwd",
            repo_root.to_str().unwrap(),
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains("no project matched cwd"));
}

#[test]
fn invalid_frontmatter_should_fail_fast() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/bad.md"),
        "---\ntags: [oops\n---\n# bad\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "spool context",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .failure();
}

#[test]
fn unclosed_frontmatter_should_fail_fast() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/unclosed.md"),
        "---\ntags: [rust, cli]\n# body leaked into frontmatter\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "spool context",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .failure();
}

#[test]
fn fenced_code_block_headings_should_not_split_sections() {
    let body = "# Title\n\n```rust\n# not a heading\n```\n\n## Real\ncontent\n";
    let sections = spool::vault::markdown::extract_sections(body);
    assert_eq!(sections.len(), 2);
    assert_eq!(sections[0].heading.as_deref(), Some("Title"));
    assert!(sections[0].content.contains("# not a heading"));
    assert_eq!(sections[1].heading.as_deref(), Some("Real"));
}

#[test]
fn tilde_fenced_code_block_headings_should_not_split_sections() {
    let body = "# Title\n\n~~~md\n# not a heading\n~~~\n\n## Real\ncontent\n";
    let sections = spool::vault::markdown::extract_sections(body);
    assert_eq!(sections.len(), 2);
    assert_eq!(sections[0].heading.as_deref(), Some("Title"));
    assert!(sections[0].content.contains("# not a heading"));
    assert_eq!(sections[1].heading.as_deref(), Some("Real"));
}

#[test]
fn scan_should_fail_when_file_exceeds_size_limit() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    let oversized = format!("# big\n\n{}", "a".repeat(200));
    fs::write(vault_dir.join("10-Projects/big.md"), oversized).unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[vault.limits]
max_files = 10
max_file_bytes = 32
max_total_bytes = 1024
max_depth = 8

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "spool context",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains("max_file_bytes"));
}

#[test]
fn large_markdown_outside_note_roots_should_not_fail_scan() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(vault_dir.join("99-Archive")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/spool.md"),
        "# spool\n\ncontext\n",
    )
    .unwrap();
    fs::write(
        vault_dir.join("99-Archive/big.md"),
        format!("# big\n\n{}", "a".repeat(200)),
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[vault.limits]
max_files = 10
max_file_bytes = 32
max_total_bytes = 1024
max_depth = 8

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "spool context",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .success();
}

#[test]
fn large_note_set_should_limit_candidates_to_configured_max_notes() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(vault_dir.join("20-Areas")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    for index in 0..120 {
        let root = if index % 3 == 0 {
            "10-Projects"
        } else {
            "20-Areas"
        };
        let filename = format!("{root}/note-{index:03}.md");
        let body = if index % 10 == 0 {
            format!(
                "# Repo Path Routing {index}\n\n## Project Matcher\n\nRouting note {index} mentions [[Project Matcher]] and repo_path.\n"
            )
        } else {
            format!("# General Note {index}\n\nBackground information {index}.\n")
        };
        fs::write(vault_dir.join(filename), body).unwrap();
    }

    let config = format!(
        r#"[vault]
root = "{}"

[output]
default_format = "json"
max_chars = 20000
max_notes = 5

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects", "20-Areas"]

[[projects.modules]]
id = "routing"
path_prefixes = ["src/engine"]
keywords = ["routing", "repo_path"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    let output = Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "Refine Repo-Path routing",
            "--cwd",
            repo_dir.to_str().unwrap(),
            "--files",
            "src/engine/RepoPathMatcher.rs",
            "--format",
            "json",
        ])
        .output()
        .unwrap();

    assert!(output.status.success());

    let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
    let candidates = parsed["route"]["candidates"].as_array().unwrap();
    assert_eq!(candidates.len(), 5);

    // Phase 4 R1: score_breakdown must be present and its weights
    // must sum to the candidate's `score`. This is the structured
    // contract every downstream eval / explain consumer relies on.
    for candidate in candidates {
        let breakdown = candidate["score_breakdown"]
            .as_array()
            .expect("score_breakdown array missing on candidate");
        assert!(
            !breakdown.is_empty(),
            "score_breakdown must be non-empty for scored candidate: {candidate}"
        );
        let summed: i64 = breakdown
            .iter()
            .map(|c| c["weight"].as_i64().unwrap_or(0))
            .sum();
        let score = candidate["score"].as_i64().unwrap_or(0);
        assert_eq!(
            summed, score,
            "breakdown weights must sum to score: {candidate}"
        );
        for contrib in breakdown {
            assert!(
                contrib["source"].is_string(),
                "contribution.source must be string: {contrib}"
            );
            assert!(
                contrib["field"].is_string(),
                "contribution.field must be string: {contrib}"
            );
            assert!(
                contrib["term"].is_string(),
                "contribution.term must be string: {contrib}"
            );
        }

        let confidence = candidate["confidence"]
            .as_str()
            .expect("confidence must be a string on every candidate");
        assert!(
            matches!(confidence, "high" | "medium" | "low"),
            "confidence must be high|medium|low, got: {confidence}"
        );
    }
}

#[test]
fn source_of_truth_note_should_report_high_confidence() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    let note = "---\nmemory_type: constraint\nsource_of_truth: true\n---\n# Auth Constraints\n\nNever store tokens in localStorage.\n";
    fs::write(vault_dir.join("10-Projects/auth-constraints.md"), note).unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[output]
default_format = "json"
max_chars = 20000
max_notes = 5

[[projects]]
id = "myapp"
name = "myapp"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    let output = Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "auth token storage",
            "--cwd",
            repo_dir.to_str().unwrap(),
            "--format",
            "json",
        ])
        .output()
        .unwrap();

    assert!(output.status.success());
    let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
    let candidates = parsed["route"]["candidates"].as_array().unwrap();
    assert!(!candidates.is_empty());
    let first = &candidates[0];
    assert_eq!(
        first["confidence"].as_str().unwrap(),
        "high",
        "source_of_truth note must have confidence=high"
    );
}

#[test]
fn missing_note_root_should_fail_instead_of_scanning_whole_vault() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("99-Archive")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("99-Archive/big.md"),
        format!("# big\n\n{}", "a".repeat(200)),
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[vault.limits]
max_files = 10
max_file_bytes = 32
max_total_bytes = 1024
max_depth = 8

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "spool context",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains(
            "configured note_root does not exist",
        ));
}

#[test]
fn unmatched_project_should_fail_instead_of_scanning_whole_vault() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/spool.md"),
        "# spool\n\ncontext\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[[projects]]
id = "other"
name = "other"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        temp.path().join("other-repo").display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "spool context",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains("no project matched cwd"));
}

#[test]
fn matched_project_without_note_roots_should_fail_fast() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(&vault_dir).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "spool context",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains(
            "matched project has no note_roots configured",
        ));
}

#[test]
fn config_relative_paths_should_resolve_from_config_dir() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let config_dir = temp.path().join("configs");
    let workspace_dir = temp.path().join("workspace");
    let repo_dir = workspace_dir.join("spool");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&config_dir).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/spool.md"),
        "# spool\n\ncontext\n",
    )
    .unwrap();

    let config = r#"[vault]
root = "../vault"

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["../workspace/spool"]
note_roots = ["10-Projects"]
"#;
    let config_path = config_dir.join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .current_dir(temp.path())
        .args([
            "explain",
            "--config",
            "configs/spool.toml",
            "--task",
            "spool context",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("## project\n- id: spool"));
}

#[test]
fn note_root_should_not_escape_vault_root() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let outside_dir = temp.path().join("outside");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&outside_dir).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(outside_dir.join("secret.md"), "# secret\n\noutside\n").unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["../outside"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "spool context",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains(
            "relative note path must not escape root",
        ));
}

#[test]
fn preferred_note_should_not_escape_root() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/spool.md"),
        "# spool\n\ncontext\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]

[[scenes]]
id = "planning"
keywords = ["planning"]
preferred_notes = ["../outside.md"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "explain",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "planning",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains(
            "relative note path must not escape root",
        ));
}

#[test]
fn frontmatter_tags_should_match_default_tags() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/spool.md"),
        "---\ntags: [rust, cli]\n---\n# spool\n\ncontext\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
default_tags = ["rust"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "explain",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "spool context",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("matched frontmatter tag rust"));
}

#[test]
fn canonicalized_repo_path_should_match_cwd() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let workspace_dir = temp.path().join("workspace");
    let repo_dir = workspace_dir.join("spool");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    fs::write(
        vault_dir.join("10-Projects/spool.md"),
        "# spool\n\ncontext\n",
    )
    .unwrap();

    let relative_repo_path = workspace_dir.join("..").join("workspace").join("spool");
    let config = format!(
        r#"[vault]
root = "{}"

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        relative_repo_path.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "explain",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "spool context",
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("## project\n- id: spool"));
}

fn setup_lifecycle_cli_fixture() -> (tempfile::TempDir, std::path::PathBuf, String, String) {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    fs::create_dir_all(&vault_dir).unwrap();
    let config_path = temp.path().join("spool.toml");
    fs::write(
        &config_path,
        format!("[vault]\nroot = \"{}\"\n", vault_dir.display()),
    )
    .unwrap();
    let lifecycle_root = lifecycle_root_from_config(temp.path());
    let store = LifecycleStore::new(lifecycle_root.as_path());

    let manual = record_manual_memory(
        &store,
        RecordMemoryRequest {
            title: "简洁输出".to_string(),
            summary: "偏好简洁".to_string(),
            memory_type: "preference".to_string(),
            scope: MemoryScope::User,
            source_ref: "manual:gui".to_string(),
            project_id: None,
            user_id: Some("long".to_string()),
            sensitivity: None,
            metadata: TransitionMetadata::default(),
            entities: Vec::new(),
            tags: Vec::new(),
            triggers: Vec::new(),
            related_files: Vec::new(),
            related_records: Vec::new(),
            supersedes: None,
            applies_to: Vec::new(),
            valid_until: None,
        },
    )
    .unwrap();
    let proposal = propose_ai_memory(
        &store,
        ProposeMemoryRequest {
            title: "测试偏好".to_string(),
            summary: "先 smoke 再收口".to_string(),
            memory_type: "workflow".to_string(),
            scope: MemoryScope::User,
            source_ref: "session:1".to_string(),
            project_id: None,
            user_id: Some("long".to_string()),
            sensitivity: None,
            metadata: TransitionMetadata::default(),
            entities: Vec::new(),
            tags: Vec::new(),
            triggers: Vec::new(),
            related_files: Vec::new(),
            related_records: Vec::new(),
            supersedes: None,
            applies_to: Vec::new(),
            valid_until: None,
        },
    )
    .unwrap();

    (temp, config_path, proposal.record_id, manual.record_id)
}

#[test]
fn memory_list_and_show_should_render_lifecycle_records() {
    let (_temp, config_path, proposal_record_id, _manual_record_id) = setup_lifecycle_cli_fixture();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "list",
            "--config",
            config_path.to_str().unwrap(),
            "--view",
            "pending-review",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("# Pending review"))
        .stdout(predicate::str::contains(&proposal_record_id))
        .stdout(predicate::str::contains("测试偏好"));

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "show",
            "--config",
            config_path.to_str().unwrap(),
            "--record-id",
            &proposal_record_id,
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("# Memory record"))
        .stdout(predicate::str::contains("state: candidate"))
        .stdout(predicate::str::contains("先 smoke 再收口"));
}

#[test]
fn memory_actions_should_update_lifecycle_state() {
    let (_temp, config_path, proposal_record_id, manual_record_id) = setup_lifecycle_cli_fixture();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "accept",
            "--config",
            config_path.to_str().unwrap(),
            "--record-id",
            &proposal_record_id,
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("action: accept"))
        .stdout(predicate::str::contains("state: accepted"))
        .stdout(predicate::str::contains("pending_review: false"))
        .stdout(predicate::str::contains("wakeup_ready: true"));

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "promote",
            "--config",
            config_path.to_str().unwrap(),
            "--record-id",
            &proposal_record_id,
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("action: promote"))
        .stdout(predicate::str::contains("state: canonical"))
        .stdout(predicate::str::contains("wakeup_ready: true"));

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "archive",
            "--config",
            config_path.to_str().unwrap(),
            "--record-id",
            &manual_record_id,
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("action: archive"))
        .stdout(predicate::str::contains("state: archived"))
        .stdout(predicate::str::contains("wakeup_ready: false"));
}

#[test]
fn memory_actions_should_fail_for_invalid_transition() {
    let (_temp, config_path, _proposal_record_id, manual_record_id) = setup_lifecycle_cli_fixture();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "accept",
            "--config",
            config_path.to_str().unwrap(),
            "--record-id",
            &manual_record_id,
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains("invalid lifecycle transition"));
}

#[test]
fn memory_create_commands_should_add_manual_and_proposed_records() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    fs::create_dir_all(&vault_dir).unwrap();
    let config_path = temp.path().join("spool.toml");
    fs::write(
        &config_path,
        format!("[vault]\nroot = \"{}\"\n", vault_dir.display()),
    )
    .unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "record-manual",
            "--config",
            config_path.to_str().unwrap(),
            "--title",
            "简洁输出",
            "--summary",
            "偏好简洁",
            "--memory-type",
            "preference",
            "--scope",
            "user",
            "--source-ref",
            "manual:cli",
            "--user-id",
            "long",
            "--sensitivity",
            "internal",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("# Lifecycle create"))
        .stdout(predicate::str::contains("kind: record_manual"))
        .stdout(predicate::str::contains("state: accepted"))
        .stdout(predicate::str::contains("wakeup_ready: true"));

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "propose",
            "--config",
            config_path.to_str().unwrap(),
            "--title",
            "测试偏好",
            "--summary",
            "先 smoke 再收口",
            "--memory-type",
            "workflow",
            "--scope",
            "user",
            "--source-ref",
            "session:1",
            "--user-id",
            "long",
            "--project-id",
            "spool",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("kind: propose"))
        .stdout(predicate::str::contains("state: candidate"))
        .stdout(predicate::str::contains("pending_review: true"));
}

#[test]
fn record_manual_should_generate_vault_index_md() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    fs::create_dir_all(&vault_dir).unwrap();
    let config_path = temp.path().join("spool.toml");
    fs::write(
        &config_path,
        format!("[vault]\nroot = \"{}\"\n", vault_dir.display()),
    )
    .unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "record-manual",
            "--config",
            config_path.to_str().unwrap(),
            "--title",
            "索引自动生成",
            "--summary",
            "验证 INDEX.md 回写",
            "--memory-type",
            "preference",
            "--scope",
            "user",
            "--source-ref",
            "manual:cli",
            "--user-id",
            "long",
        ])
        .assert()
        .success();

    let index_path = vault_dir.join("INDEX.md");
    assert!(
        index_path.exists(),
        "INDEX.md should be generated after record-manual"
    );
    let body = fs::read_to_string(&index_path).unwrap();
    assert!(body.contains("# Spool Knowledge Index"));
    assert!(body.contains("索引自动生成"));
    assert!(body.contains("[preference]"));
}

#[test]
fn memory_lint_should_report_clean_when_ledger_has_no_issues() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    fs::create_dir_all(&vault_dir).unwrap();
    let config_path = temp.path().join("spool.toml");
    fs::write(
        &config_path,
        format!("[vault]\nroot = \"{}\"\n", vault_dir.display()),
    )
    .unwrap();

    // Seed one accepted record
    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "record-manual",
            "--config",
            config_path.to_str().unwrap(),
            "--title",
            "lint clean",
            "--summary",
            "clean ledger",
            "--memory-type",
            "preference",
            "--scope",
            "user",
            "--source-ref",
            "manual:cli",
            "--user-id",
            "long",
        ])
        .assert()
        .success();

    Command::cargo_bin("spool")
        .unwrap()
        .args(["memory", "lint", "--config", config_path.to_str().unwrap()])
        .assert()
        .success()
        .stdout(predicate::str::contains("Wiki Lint Report"))
        .stdout(predicate::str::contains("1 active records"))
        .stdout(predicate::str::contains("知识库干净"));
}

#[test]
fn memory_sync_index_dry_run_should_not_create_file() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    fs::create_dir_all(&vault_dir).unwrap();
    let config_path = temp.path().join("spool.toml");
    fs::write(
        &config_path,
        format!("[vault]\nroot = \"{}\"\n", vault_dir.display()),
    )
    .unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "sync-index",
            "--config",
            config_path.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("dry-run"))
        .stdout(predicate::str::contains("--apply"));

    assert!(
        !vault_dir.join("INDEX.md").exists(),
        "dry-run should not create INDEX.md"
    );
}

#[test]
fn memory_sync_index_apply_should_create_and_be_idempotent() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    fs::create_dir_all(&vault_dir).unwrap();
    let config_path = temp.path().join("spool.toml");
    fs::write(
        &config_path,
        format!("[vault]\nroot = \"{}\"\n", vault_dir.display()),
    )
    .unwrap();

    // Seed a record so INDEX has content
    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "record-manual",
            "--config",
            config_path.to_str().unwrap(),
            "--title",
            "Sync test",
            "--summary",
            "testing sync-index",
            "--memory-type",
            "preference",
            "--scope",
            "user",
            "--source-ref",
            "manual:cli",
            "--user-id",
            "long",
        ])
        .assert()
        .success();

    // Delete the auto-generated INDEX.md to prove sync-index can rebuild it
    let index_path = vault_dir.join("INDEX.md");
    if index_path.exists() {
        fs::remove_file(&index_path).unwrap();
    }
    assert!(!index_path.exists());

    // Apply: should create
    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "sync-index",
            "--config",
            config_path.to_str().unwrap(),
            "--apply",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("Created"));

    assert!(index_path.exists());
    let body = fs::read_to_string(&index_path).unwrap();
    assert!(body.contains("Sync test"));

    // Apply again: should be unchanged (idempotent)
    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "sync-index",
            "--config",
            config_path.to_str().unwrap(),
            "--apply",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("Unchanged"));
}

#[test]
fn memory_history_should_return_full_event_sequence() {
    let (_temp, config_path, proposal_record_id, _manual_record_id) = setup_lifecycle_cli_fixture();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "accept",
            "--config",
            config_path.to_str().unwrap(),
            "--record-id",
            &proposal_record_id,
        ])
        .assert()
        .success();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "history",
            "--config",
            config_path.to_str().unwrap(),
            "--record-id",
            &proposal_record_id,
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("# Memory history"))
        .stdout(predicate::str::contains("events: 2"))
        .stdout(predicate::str::contains("action: propose_ai"))
        .stdout(predicate::str::contains("action: accept"));
}

#[test]
fn memory_history_should_fail_with_not_found_for_unknown_record_id() {
    // Bug #6: empty history is equivalent to "record doesn't exist"
    // (every record has at least one create event). Aligning CLI
    // behavior with `memory show` and the MCP `memory_history` tool
    // — all three surfaces must report `not found` instead of
    // silently printing an empty list.
    let (_temp, config_path, _proposal_record_id, _manual_record_id) =
        setup_lifecycle_cli_fixture();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "history",
            "--config",
            config_path.to_str().unwrap(),
            "--record-id",
            "definitely-missing-id",
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains(
            "memory record not found: definitely-missing-id",
        ));
}

#[test]
fn memory_read_commands_should_use_daemon_when_configured() {
    let (_temp, config_path, proposal_record_id, _manual_record_id) = setup_lifecycle_cli_fixture();
    let daemon_bin = assert_cmd::cargo::cargo_bin("spool-daemon");

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "list",
            "--config",
            config_path.to_str().unwrap(),
            "--view",
            "pending-review",
            "--daemon-bin",
            daemon_bin.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains(&proposal_record_id));

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "show",
            "--config",
            config_path.to_str().unwrap(),
            "--record-id",
            &proposal_record_id,
            "--daemon-bin",
            daemon_bin.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("state: candidate"));

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "history",
            "--config",
            config_path.to_str().unwrap(),
            "--record-id",
            &proposal_record_id,
            "--daemon-bin",
            daemon_bin.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("action: propose_ai"));
}

#[test]
fn memory_read_commands_should_fallback_when_daemon_is_unavailable() {
    let (_temp, config_path, proposal_record_id, _manual_record_id) = setup_lifecycle_cli_fixture();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "list",
            "--config",
            config_path.to_str().unwrap(),
            "--view",
            "pending-review",
            "--daemon-bin",
            "/definitely/missing/spool-daemon",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains(&proposal_record_id));

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "show",
            "--config",
            config_path.to_str().unwrap(),
            "--record-id",
            &proposal_record_id,
            "--daemon-bin",
            "/definitely/missing/spool-daemon",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("state: candidate"));

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "history",
            "--config",
            config_path.to_str().unwrap(),
            "--record-id",
            &proposal_record_id,
            "--daemon-bin",
            "/definitely/missing/spool-daemon",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("action: propose_ai"));
}

#[test]
fn memory_commands_should_surface_write_metadata() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    fs::create_dir_all(&vault_dir).unwrap();
    let config_path = temp.path().join("spool.toml");
    fs::write(
        &config_path,
        format!("[vault]\nroot = \"{}\"\n", vault_dir.display()),
    )
    .unwrap();

    let assert = Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "record-manual",
            "--config",
            config_path.to_str().unwrap(),
            "--title",
            "简洁输出",
            "--summary",
            "偏好简洁",
            "--memory-type",
            "preference",
            "--scope",
            "user",
            "--source-ref",
            "manual:cli",
            "--user-id",
            "long",
            "--actor",
            "codex",
            "--reason",
            "captured from explicit user instruction",
            "--evidence-refs",
            "obsidian://preferences,session:1",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("actor: codex"))
        .stdout(predicate::str::contains(
            "reason: captured from explicit user instruction",
        ))
        .stdout(predicate::str::contains(
            "evidence_refs: obsidian://preferences, session:1",
        ));

    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    let record_id_line = stdout
        .lines()
        .find(|line| line.contains("record_id"))
        .unwrap()
        .to_string();
    let record_id = record_id_line.split('`').nth(1).unwrap().to_string();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "show",
            "--config",
            config_path.to_str().unwrap(),
            "--record-id",
            &record_id,
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("actor: codex"))
        .stdout(predicate::str::contains(
            "reason: captured from explicit user instruction",
        ))
        .stdout(predicate::str::contains(
            "evidence_refs: obsidian://preferences, session:1",
        ));
}

#[test]
fn memory_action_commands_should_surface_transition_metadata() {
    let (_temp, config_path, proposal_record_id, _manual_record_id) = setup_lifecycle_cli_fixture();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "accept",
            "--config",
            config_path.to_str().unwrap(),
            "--record-id",
            &proposal_record_id,
            "--actor",
            "long",
            "--reason",
            "approved after review",
            "--evidence-refs",
            "session:1,session:2",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("actor: long"))
        .stdout(predicate::str::contains("reason: approved after review"))
        .stdout(predicate::str::contains(
            "evidence_refs: session:1, session:2",
        ));

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "history",
            "--config",
            config_path.to_str().unwrap(),
            "--record-id",
            &proposal_record_id,
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("actor: long"))
        .stdout(predicate::str::contains("reason: approved after review"))
        .stdout(predicate::str::contains(
            "evidence_refs: session:1, session:2",
        ));
}

#[test]
fn get_should_include_lifecycle_canonical_memory_section() {
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();
    fs::write(
        vault_dir.join("10-Projects/spool.md"),
        "# spool\n\nrepo_path routing baseline note.\n",
    )
    .unwrap();

    let config = format!(
        r#"[vault]
root = "{}"

[output]
default_format = "prompt"
max_chars = 12000
max_notes = 8
max_lifecycle = 5

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    let lifecycle_root = lifecycle_root_from_config(temp.path());
    fs::create_dir_all(&lifecycle_root).unwrap();
    let store = LifecycleStore::new(&lifecycle_root);
    record_manual_memory(
        &store,
        RecordMemoryRequest {
            title: "避免 mock 测试".to_string(),
            summary: "production migration 曾因 mock 过度而失败".to_string(),
            memory_type: "constraint".to_string(),
            scope: MemoryScope::User,
            source_ref: "cli-smoke".to_string(),
            project_id: None,
            user_id: None,
            sensitivity: None,
            metadata: TransitionMetadata::default(),
            entities: Vec::new(),
            tags: Vec::new(),
            triggers: Vec::new(),
            related_files: Vec::new(),
            related_records: Vec::new(),
            supersedes: None,
            applies_to: Vec::new(),
            valid_until: None,
        },
    )
    .unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "get",
            "--config",
            config_path.to_str().unwrap(),
            "--task",
            "实现 repo_path route 规划",
            "--cwd",
            repo_dir.to_str().unwrap(),
            "--format",
            "prompt",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("记忆（accepted / canonical）"))
        .stdout(predicate::str::contains("避免 mock 测试"));
}

#[test]
fn memory_sync_vault_should_create_canonical_note_and_be_idempotent() {
    let temp = tempdir().unwrap();
    let vault_root = temp.path().join("vault");
    fs::create_dir_all(&vault_root).unwrap();
    let config_path = temp.path().join("spool.toml");
    fs::write(
        &config_path,
        format!("[vault]\nroot = \"{}\"\n", vault_root.display()),
    )
    .unwrap();

    let lifecycle_root = lifecycle_root_from_config(temp.path());
    let store = LifecycleStore::new(lifecycle_root.as_path());
    let manual = record_manual_memory(
        &store,
        RecordMemoryRequest {
            title: "偏好摘要".to_string(),
            summary: "用户偏好简洁".to_string(),
            memory_type: "preference".to_string(),
            scope: MemoryScope::User,
            source_ref: "manual:cli".to_string(),
            project_id: None,
            user_id: None,
            sensitivity: None,
            metadata: TransitionMetadata::default(),
            entities: Vec::new(),
            tags: Vec::new(),
            triggers: Vec::new(),
            related_files: Vec::new(),
            related_records: Vec::new(),
            supersedes: None,
            applies_to: Vec::new(),
            valid_until: None,
        },
    )
    .unwrap();

    // dry-run should not write file
    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "sync-vault",
            "--config",
            config_path.to_str().unwrap(),
            "--dry-run",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("vault sync summary (dry-run)"))
        .stdout(predicate::str::contains("would_create: 1"));

    let note_path = vault_root
        .join("50-Memory-Ledger/Extracted")
        .join(format!("{}.md", manual.record_id));
    assert!(!note_path.exists(), "dry-run must not write");

    // actual first write
    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "sync-vault",
            "--config",
            config_path.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("created: 1"));

    let content = fs::read_to_string(&note_path).unwrap();
    assert!(content.contains(&format!("record_id: {}", manual.record_id)));
    assert!(content.contains("state: accepted"));
    assert!(content.contains("# 偏好摘要"));

    // idempotent re-run yields unchanged
    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "sync-vault",
            "--config",
            config_path.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("unchanged: 1"));
}

// ─────────────────────────────────────────────────────────────────────
// spool mcp install / uninstall / doctor smoke tests
// ─────────────────────────────────────────────────────────────────────

fn setup_mcp_smoke_fixture() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
    let temp = tempdir().unwrap();
    let home = temp.path().to_path_buf();
    let config_path = home.join("spool.toml");
    fs::write(&config_path, "[vault]\nroot=\"/tmp/spool-smoke\"\n").unwrap();
    let binary_path = home.join("fake-spool-mcp");
    fs::write(&binary_path, "#!/bin/sh\nexit 0\n").unwrap();
    (temp, config_path, binary_path)
}

fn run_spool_with_home(home: &std::path::Path, args: &[&str]) -> assert_cmd::assert::Assert {
    Command::cargo_bin("spool")
        .unwrap()
        .env("HOME", home)
        .args(args)
        .assert()
}

#[test]
fn mcp_install_should_register_spool_entry_in_claude_config() {
    let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
    let home = temp.path();
    let claude_json = home.join(".claude.json");

    run_spool_with_home(
        home,
        &[
            "mcp",
            "install",
            "--client",
            "claude",
            "--config",
            config_path.to_str().unwrap(),
            "--binary-path",
            binary_path.to_str().unwrap(),
        ],
    )
    .success()
    .stdout(predicate::str::contains("status: installed"));

    let raw = fs::read_to_string(&claude_json).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
    assert_eq!(parsed["mcpServers"]["spool"]["type"], "stdio");
    assert_eq!(
        parsed["mcpServers"]["spool"]["command"].as_str().unwrap(),
        binary_path.to_string_lossy()
    );
    assert_eq!(
        parsed["mcpServers"]["spool"]["args"][0].as_str().unwrap(),
        "--config"
    );
}

#[test]
fn mcp_install_dry_run_should_not_create_file() {
    let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
    let home = temp.path();
    let claude_json = home.join(".claude.json");

    run_spool_with_home(
        home,
        &[
            "mcp",
            "install",
            "--client",
            "claude",
            "--config",
            config_path.to_str().unwrap(),
            "--binary-path",
            binary_path.to_str().unwrap(),
            "--dry-run",
        ],
    )
    .success()
    .stdout(predicate::str::contains("status: dry-run"));

    assert!(!claude_json.exists(), "dry-run must not write file");
}

#[test]
fn mcp_install_should_be_idempotent_on_repeat() {
    let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
    let home = temp.path();

    let args = [
        "mcp",
        "install",
        "--client",
        "claude",
        "--config",
        config_path.to_str().unwrap(),
        "--binary-path",
        binary_path.to_str().unwrap(),
    ];

    run_spool_with_home(home, &args).success();
    run_spool_with_home(home, &args)
        .success()
        .stdout(predicate::str::contains("status: unchanged"));
}

#[test]
fn mcp_install_conflict_without_force_should_exit_nonzero() {
    let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
    let home = temp.path();
    let claude_json = home.join(".claude.json");

    let preexisting = serde_json::json!({
        "mcpServers": {
            "spool": {"type": "stdio", "command": "/old/path", "args": []}
        }
    });
    fs::write(
        &claude_json,
        serde_json::to_string_pretty(&preexisting).unwrap(),
    )
    .unwrap();

    run_spool_with_home(
        home,
        &[
            "mcp",
            "install",
            "--client",
            "claude",
            "--config",
            config_path.to_str().unwrap(),
            "--binary-path",
            binary_path.to_str().unwrap(),
        ],
    )
    .failure()
    .stdout(predicate::str::contains("status: conflict"));

    let raw = fs::read_to_string(&claude_json).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
    assert_eq!(parsed["mcpServers"]["spool"]["command"], "/old/path");
}

#[test]
fn mcp_install_with_force_should_overwrite_and_backup() {
    let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
    let home = temp.path();
    let claude_json = home.join(".claude.json");

    let preexisting = serde_json::json!({
        "mcpServers": {
            "spool": {"type": "stdio", "command": "/old/path", "args": []}
        }
    });
    fs::write(
        &claude_json,
        serde_json::to_string_pretty(&preexisting).unwrap(),
    )
    .unwrap();

    run_spool_with_home(
        home,
        &[
            "mcp",
            "install",
            "--client",
            "claude",
            "--config",
            config_path.to_str().unwrap(),
            "--binary-path",
            binary_path.to_str().unwrap(),
            "--force",
        ],
    )
    .success()
    .stdout(predicate::str::contains("status: installed"));

    let raw = fs::read_to_string(&claude_json).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
    assert_eq!(
        parsed["mcpServers"]["spool"]["command"].as_str().unwrap(),
        binary_path.to_string_lossy()
    );

    // backup file pattern: .claude.json.bak-spool-<ts>
    let dir_entries: Vec<_> = fs::read_dir(home).unwrap().flatten().collect();
    let has_backup = dir_entries.iter().any(|e| {
        e.file_name()
            .to_string_lossy()
            .starts_with(".claude.json.bak-spool-")
    });
    assert!(has_backup, "force install must leave a backup");
}

#[test]
fn mcp_uninstall_should_remove_spool_entry_only() {
    let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
    let home = temp.path();
    let claude_json = home.join(".claude.json");

    // first install
    run_spool_with_home(
        home,
        &[
            "mcp",
            "install",
            "--client",
            "claude",
            "--config",
            config_path.to_str().unwrap(),
            "--binary-path",
            binary_path.to_str().unwrap(),
        ],
    )
    .success();

    // sprinkle an unrelated entry
    let mut doc: serde_json::Value =
        serde_json::from_str(&fs::read_to_string(&claude_json).unwrap()).unwrap();
    doc["mcpServers"]["pencil"] = serde_json::json!({"command": "/keep/me"});
    fs::write(&claude_json, serde_json::to_string_pretty(&doc).unwrap()).unwrap();

    // uninstall
    run_spool_with_home(home, &["mcp", "uninstall", "--client", "claude"])
        .success()
        .stdout(predicate::str::contains("status: removed"));

    let raw = fs::read_to_string(&claude_json).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
    assert!(parsed["mcpServers"].get("spool").is_none());
    assert_eq!(parsed["mcpServers"]["pencil"]["command"], "/keep/me");
}

#[test]
fn mcp_uninstall_when_nothing_installed_should_succeed() {
    let temp = tempdir().unwrap();
    let home = temp.path();

    run_spool_with_home(home, &["mcp", "uninstall", "--client", "claude"])
        .success()
        .stdout(predicate::str::contains("status: not-installed"));
}

#[test]
fn mcp_doctor_should_pass_after_install() {
    let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
    let home = temp.path();

    run_spool_with_home(
        home,
        &[
            "mcp",
            "install",
            "--client",
            "claude",
            "--config",
            config_path.to_str().unwrap(),
            "--binary-path",
            binary_path.to_str().unwrap(),
        ],
    )
    .success();

    run_spool_with_home(
        home,
        &[
            "mcp",
            "doctor",
            "--client",
            "claude",
            "--config",
            config_path.to_str().unwrap(),
            "--binary-path",
            binary_path.to_str().unwrap(),
        ],
    )
    .success()
    .stdout(predicate::str::contains("[ ok ] claude_config_exists"))
    .stdout(predicate::str::contains(
        "[ ok ] mcp_servers_spool_registered",
    ))
    .stdout(predicate::str::contains("[ ok ] spool_mcp_binary"));
}

#[test]
fn mcp_doctor_should_fail_when_binary_missing() {
    let (temp, config_path, _binary_path) = setup_mcp_smoke_fixture();
    let home = temp.path();
    let missing = home.join("definitely-not-there");

    run_spool_with_home(
        home,
        &[
            "mcp",
            "doctor",
            "--client",
            "claude",
            "--config",
            config_path.to_str().unwrap(),
            "--binary-path",
            missing.to_str().unwrap(),
        ],
    )
    .failure()
    .stdout(predicate::str::contains("[FAIL] spool_mcp_binary"));
}

#[test]
fn mcp_install_should_emit_json_on_request() {
    let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
    let home = temp.path();

    let assert = run_spool_with_home(
        home,
        &[
            "mcp",
            "install",
            "--client",
            "claude",
            "--config",
            config_path.to_str().unwrap(),
            "--binary-path",
            binary_path.to_str().unwrap(),
            "--format",
            "json",
        ],
    )
    .success();
    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
    assert_eq!(parsed["client"], "claude");
    assert_eq!(parsed["status"], "installed");
}

// ─────────────────────────────────────────────────────────────────────
// R2: hook payload (settings.json + hooks/*.sh + commands/*.md +
// skills/) and `spool hook` CLI surface
// ─────────────────────────────────────────────────────────────────────

#[test]
fn mcp_install_should_emit_hooks_commands_and_skill_payload() {
    let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
    let home = temp.path();
    run_spool_with_home(
        home,
        &[
            "mcp",
            "install",
            "--client",
            "claude",
            "--config",
            config_path.to_str().unwrap(),
            "--binary-path",
            binary_path.to_str().unwrap(),
        ],
    )
    .success();

    // hooks dir + 5 scripts
    let hooks_dir = home.join(".claude").join("hooks");
    let expected_hooks = [
        "spool-SessionStart.sh",
        "spool-UserPromptSubmit.sh",
        "spool-PostToolUse.sh",
        "spool-Stop.sh",
        "spool-PreCompact.sh",
    ];
    for name in expected_hooks {
        let p = hooks_dir.join(name);
        assert!(p.exists(), "missing hook {}", p.display());
        let body = fs::read_to_string(&p).unwrap();
        assert!(
            body.contains(config_path.to_str().unwrap()),
            "{} should reference config path after substitution",
            p.display()
        );
        assert!(
            !body.contains("@@spool_BIN@@"),
            "{} placeholder leaked",
            p.display()
        );
    }

    // commands
    for name in [
        "spool-wakeup.md",
        "spool-capture.md",
        "spool-review.md",
        "spool-doctor.md",
    ] {
        assert!(
            home.join(".claude").join("commands").join(name).exists(),
            "missing command {}",
            name
        );
    }

    // skill
    assert!(
        home.join(".claude")
            .join("skills")
            .join("spool-runtime")
            .join("SKILL.md")
            .exists()
    );

    // settings.json contains a spool- entry per hook event
    let settings_raw = fs::read_to_string(home.join(".claude").join("settings.json")).unwrap();
    let settings: serde_json::Value = serde_json::from_str(&settings_raw).unwrap();
    for event in [
        "SessionStart",
        "UserPromptSubmit",
        "PostToolUse",
        "Stop",
        "PreCompact",
    ] {
        let entries = settings["hooks"][event].as_array().unwrap();
        assert!(
            entries.iter().any(|e| e["hooks"][0]["command"]
                .as_str()
                .is_some_and(|c| c.contains("spool-"))),
            "{} entry missing in settings.json",
            event
        );
    }
}

#[test]
fn mcp_install_should_preserve_pre_existing_hook_entries() {
    let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
    let home = temp.path();
    fs::create_dir_all(home.join(".claude")).unwrap();
    let preexisting = serde_json::json!({
        "hooks": {
            "SessionStart": [
                {"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]}
            ]
        }
    });
    fs::write(
        home.join(".claude").join("settings.json"),
        serde_json::to_string_pretty(&preexisting).unwrap(),
    )
    .unwrap();

    run_spool_with_home(
        home,
        &[
            "mcp",
            "install",
            "--client",
            "claude",
            "--config",
            config_path.to_str().unwrap(),
            "--binary-path",
            binary_path.to_str().unwrap(),
        ],
    )
    .success();

    let settings: serde_json::Value = serde_json::from_str(
        &fs::read_to_string(home.join(".claude").join("settings.json")).unwrap(),
    )
    .unwrap();
    let entries = settings["hooks"]["SessionStart"].as_array().unwrap();
    assert_eq!(entries.len(), 2, "bd prime + spool must coexist");
    assert_eq!(entries[0]["hooks"][0]["command"], "bd prime");
}

#[test]
fn mcp_uninstall_should_remove_hooks_commands_and_skills() {
    let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
    let home = temp.path();
    run_spool_with_home(
        home,
        &[
            "mcp",
            "install",
            "--client",
            "claude",
            "--config",
            config_path.to_str().unwrap(),
            "--binary-path",
            binary_path.to_str().unwrap(),
        ],
    )
    .success();
    run_spool_with_home(home, &["mcp", "uninstall", "--client", "claude"]).success();

    assert!(
        !home
            .join(".claude")
            .join("hooks")
            .join("spool-SessionStart.sh")
            .exists()
    );
    assert!(
        !home
            .join(".claude")
            .join("commands")
            .join("spool-wakeup.md")
            .exists()
    );
    assert!(
        !home
            .join(".claude")
            .join("skills")
            .join("spool-runtime")
            .exists()
    );
}

#[test]
fn hook_session_start_should_emit_wakeup_marker() {
    // build a vault + config + repo, then call `spool hook session-start`
    let temp = tempdir().unwrap();
    let vault = temp.path().join("vault");
    fs::create_dir_all(vault.join("10-Projects")).unwrap();
    fs::write(
        vault.join("10-Projects/proj.md"),
        r#"---
memory_type: decision
project_id: spool
retrieval_priority: high
---
# 决策: 用 cargo install
"#,
    )
    .unwrap();
    let cwd = temp.path().join("repo");
    fs::create_dir_all(&cwd).unwrap();
    let config = format!(
        r#"[vault]
root = "{}"

[output]
default_format = "prompt"
max_chars = 4000
max_notes = 4

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
        vault.display(),
        cwd.display()
    );
    let cfg = temp.path().join("spool.toml");
    fs::write(&cfg, config).unwrap();

    let assert = Command::cargo_bin("spool")
        .unwrap()
        .args([
            "hook",
            "session-start",
            "--config",
            cfg.to_str().unwrap(),
            "--cwd",
            cwd.to_str().unwrap(),
        ])
        .assert()
        .success();
    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    assert!(stdout.contains("<spool-memory>"));
    assert!(stdout.contains("</spool-memory>"));
}

#[test]
fn hook_session_start_should_work_even_with_trellis_present() {
    let temp = tempdir().unwrap();
    let cwd = temp.path().join("repo");
    fs::create_dir_all(cwd.join(".trellis")).unwrap();
    fs::write(cwd.join(".trellis").join(".developer"), "name=long").unwrap();
    let vault = temp.path().join("vault");
    fs::create_dir_all(vault.join("10-Projects")).unwrap();
    fs::write(
        vault.join("10-Projects/constraint.md"),
        "---\nmemory_type: constraint\n---\n# Never use eval\n\nAvoid eval in production.\n",
    )
    .unwrap();
    let cfg = temp.path().join("spool.toml");
    fs::write(
        &cfg,
        format!(
            "[vault]\nroot = \"{}\"\n\n[output]\nmax_chars = 8000\nmax_notes = 4\n\n[[projects]]\nid = \"test\"\nname = \"test\"\nrepo_paths = [\"{}\"]\nnote_roots = [\"10-Projects\"]\n",
            vault.display(),
            cwd.display()
        ),
    )
    .unwrap();

    let assert = Command::cargo_bin("spool")
        .unwrap()
        .args([
            "hook",
            "session-start",
            "--config",
            cfg.to_str().unwrap(),
            "--cwd",
            cwd.to_str().unwrap(),
        ])
        .assert()
        .success();
    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    assert!(stdout.contains("<spool-memory>"));
    assert!(stdout.contains("Constraints"));
}

#[test]
fn hook_post_tool_use_should_append_distill_queue_line() {
    let temp = tempdir().unwrap();
    let cwd = temp.path().join("repo");
    fs::create_dir_all(&cwd).unwrap();
    let cfg = temp.path().join("spool.toml");
    fs::write(&cfg, "x=1").unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "hook",
            "post-tool-use",
            "--config",
            cfg.to_str().unwrap(),
            "--cwd",
            cwd.to_str().unwrap(),
            "--tool-name",
            "Bash",
            "--payload",
            "ls -la",
        ])
        .assert()
        .success();
    let queue = cwd.join(".spool").join("distill-pending.queue");
    assert!(queue.exists());
    let body = fs::read_to_string(&queue).unwrap();
    assert!(body.lines().count() >= 1);
    assert!(body.contains("\"tool_name\":\"Bash\""));
}

#[test]
fn hook_user_prompt_should_write_last_prompt_marker() {
    let temp = tempdir().unwrap();
    let cwd = temp.path().join("repo");
    fs::create_dir_all(&cwd).unwrap();
    let cfg = temp.path().join("spool.toml");
    fs::write(&cfg, "x=1").unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "hook",
            "user-prompt",
            "--config",
            cfg.to_str().unwrap(),
            "--cwd",
            cwd.to_str().unwrap(),
        ])
        .assert()
        .success();
    assert!(cwd.join(".spool").join("last-prompt.unix").exists());
}

#[test]
fn hook_stop_and_pre_compact_should_drop_markers() {
    let temp = tempdir().unwrap();
    let cwd = temp.path().join("repo");
    fs::create_dir_all(&cwd).unwrap();
    let cfg = temp.path().join("spool.toml");
    fs::write(&cfg, "x=1").unwrap();

    for hook in ["stop", "pre-compact"] {
        Command::cargo_bin("spool")
            .unwrap()
            .args([
                "hook",
                hook,
                "--config",
                cfg.to_str().unwrap(),
                "--cwd",
                cwd.to_str().unwrap(),
            ])
            .assert()
            .success();
    }
    assert!(cwd.join(".spool").join("last-stop.unix").exists());
    assert!(cwd.join(".spool").join("last-pre-compact.unix").exists());
}

#[test]
fn hook_should_exit_zero_on_internal_failure() {
    // Run a hook with a config path that does not exist. With the
    // run_silent wrapper in src/hook_runtime/mod.rs, this should still
    // exit 0 — never propagating an error to Claude Code (D7).
    let temp = tempdir().unwrap();
    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "hook",
            "user-prompt",
            "--config",
            temp.path()
                .join("definitely-missing.toml")
                .to_str()
                .unwrap(),
            "--cwd",
            temp.path().to_str().unwrap(),
        ])
        .assert()
        .success();
}

#[test]
fn hook_stop_should_persist_self_tag_as_accepted_record() {
    // R3b end-to-end smoke: a transcript jsonl containing a Chinese
    // self-tag from the user produces an `accepted` ledger entry that
    // shows up in `memory list --view wakeup-ready`.
    let temp = tempdir().unwrap();
    let cwd = temp.path().join("repo");
    fs::create_dir_all(&cwd).unwrap();
    let cfg = temp.path().join("spool.toml");
    fs::write(&cfg, "[vault]\nroot = \"/tmp/spool-r3b-smoke\"\n").unwrap();

    // Synthetic transcript: user says "记一下: ...".
    let transcript_path = temp.path().join("session.jsonl");
    let user_line = serde_json::json!({
        "type": "user",
        "message": {
            "role": "user",
            "content": "记一下：cargo install 是默认安装路径"
        }
    })
    .to_string();
    fs::write(&transcript_path, format!("{user_line}\n")).unwrap();

    // Invoke the hook.
    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "hook",
            "stop",
            "--config",
            cfg.to_str().unwrap(),
            "--cwd",
            cwd.to_str().unwrap(),
            "--transcript-path",
            transcript_path.to_str().unwrap(),
        ])
        .assert()
        .success();

    // The ledger now has one accepted record visible from
    // `memory list --view wakeup-ready`.
    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "list",
            "--config",
            cfg.to_str().unwrap(),
            "--view",
            "wakeup-ready",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("cargo install 是默认安装路径"))
        .stdout(predicate::str::contains("(preference)"));
}

#[test]
fn hook_stop_should_resolve_transcript_via_hook_input_json() {
    // Ensures the Stop hook accepts Claude Code's stdin payload
    // (forwarded via `--hook-input`) and uses its `transcript_path`
    // field to locate the session.
    let temp = tempdir().unwrap();
    let cwd = temp.path().join("repo");
    fs::create_dir_all(&cwd).unwrap();
    let cfg = temp.path().join("spool.toml");
    fs::write(&cfg, "[vault]\nroot = \"/tmp/spool-r3b-smoke2\"\n").unwrap();

    let transcript_path = temp.path().join("session.jsonl");
    let line = serde_json::json!({
        "type": "user",
        "message": {"role": "user", "content": "我决定用 cargo install 落到 ~/.cargo/bin"}
    })
    .to_string();
    fs::write(&transcript_path, format!("{line}\n")).unwrap();

    let hook_input = serde_json::json!({
        "session_id": "abc-123",
        "transcript_path": transcript_path.to_string_lossy(),
        "hook_event_name": "Stop"
    })
    .to_string();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "hook",
            "stop",
            "--config",
            cfg.to_str().unwrap(),
            "--cwd",
            cwd.to_str().unwrap(),
            "--hook-input",
            &hook_input,
        ])
        .assert()
        .success();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "list",
            "--config",
            cfg.to_str().unwrap(),
            "--view",
            "wakeup-ready",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("(decision)"))
        .stdout(predicate::str::contains("cargo install"));
}

#[test]
fn hook_stop_should_drop_self_tag_containing_secret() {
    // The user typed a self-tag that includes an API key. We MUST
    // refuse to persist it (no half-redacted memory in the wakeup
    // pool) and the wakeup-ready list stays empty.
    let temp = tempdir().unwrap();
    let cwd = temp.path().join("repo");
    fs::create_dir_all(&cwd).unwrap();
    let cfg = temp.path().join("spool.toml");
    fs::write(&cfg, "[vault]\nroot = \"/tmp/spool-r3b-secret\"\n").unwrap();

    let transcript_path = temp.path().join("session.jsonl");
    let line = serde_json::json!({
        "type": "user",
        "message": {
            "role": "user",
            "content": "记一下: prod token sk-abcDEF1234567890ABCDEFGHIJ stays here"
        }
    })
    .to_string();
    fs::write(&transcript_path, format!("{line}\n")).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "hook",
            "stop",
            "--config",
            cfg.to_str().unwrap(),
            "--cwd",
            cwd.to_str().unwrap(),
            "--transcript-path",
            transcript_path.to_str().unwrap(),
        ])
        .assert()
        .success();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "list",
            "--config",
            cfg.to_str().unwrap(),
            "--view",
            "wakeup-ready",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("Wakeup-ready"))
        // No accepted record should appear (the secret-containing
        // self-tag was dropped before the ledger write).
        .stdout(predicate::str::contains("(preference)").not())
        .stdout(predicate::str::contains("(decision)").not());
}

#[test]
fn session_start_hook_should_complete_within_500ms() {
    // Performance smoke: the session-start hook (heaviest hook — full
    // vault scan + wakeup packet render) must stay within the 500ms
    // p95 budget defined in the hook runtime PRD.
    let temp = tempdir().unwrap();
    let vault_dir = temp.path().join("vault");
    let repo_dir = temp.path().join("repo");
    fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
    fs::create_dir_all(vault_dir.join("20-Areas")).unwrap();
    fs::create_dir_all(&repo_dir).unwrap();

    // Generate ~200 notes across two note_roots.
    for i in 0..200 {
        let root = if i % 2 == 0 {
            "10-Projects"
        } else {
            "20-Areas"
        };
        let filename = format!("{root}/note-{i:03}.md");
        let body = if i % 10 == 0 {
            format!(
                "---\nmemory_type: decision\nproject_id: spool\nretrieval_priority: high\n---\n# Decision {i}\n\nArchitectural decision about routing {i}.\n"
            )
        } else if i % 7 == 0 {
            format!(
                "---\nmemory_type: constraint\nsource_of_truth: true\n---\n# Constraint {i}\n\nNever do X in context {i}.\n"
            )
        } else {
            format!("# General Note {i}\n\nBackground information for note {i}.\n")
        };
        fs::write(vault_dir.join(filename), body).unwrap();
    }

    let config = format!(
        r#"[vault]
root = "{}"

[output]
default_format = "prompt"
max_chars = 12000
max_notes = 8

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects", "20-Areas"]

[[projects.modules]]
id = "routing"
path_prefixes = ["src/engine"]
keywords = ["routing", "decision"]
"#,
        vault_dir.display(),
        repo_dir.display()
    );
    let config_path = temp.path().join("spool.toml");
    fs::write(&config_path, config).unwrap();

    let start = std::time::Instant::now();
    let assert = Command::cargo_bin("spool")
        .unwrap()
        .args([
            "hook",
            "session-start",
            "--config",
            config_path.to_str().unwrap(),
            "--cwd",
            repo_dir.to_str().unwrap(),
        ])
        .assert()
        .success();
    let elapsed = start.elapsed();

    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    assert!(
        stdout.contains("<spool-memory>"),
        "stdout must contain spool-memory marker, got:\n{stdout}"
    );

    assert!(
        elapsed.as_millis() < 500,
        "session-start hook took {}ms (budget: 500ms)",
        elapsed.as_millis()
    );
}

#[test]
fn hook_stop_should_propose_incident_candidate_for_repeated_frustration() {
    // R3c end-to-end: a transcript jsonl with two user messages
    // containing frustration phrases ("错了" / "失败") within the
    // sliding window produces an `incident` candidate visible in
    // `memory list --view pending-review`.
    let temp = tempdir().unwrap();
    let cwd = temp.path().join("repo");
    fs::create_dir_all(&cwd).unwrap();
    let cfg = temp.path().join("spool.toml");
    fs::write(&cfg, "[vault]\nroot = \"/tmp/spool-r3c-extraction\"\n").unwrap();

    let transcript_path = temp.path().join("session.jsonl");
    let entries = [
        serde_json::json!({"type": "user", "message": {"role": "user", "content": "试一下 cargo test"}}),
        serde_json::json!({"type": "user", "message": {"role": "user", "content": "还是错了"}}),
        serde_json::json!({"type": "user", "message": {"role": "user", "content": "改了之后又失败了"}}),
    ];
    let mut body = String::new();
    for e in entries {
        body.push_str(&e.to_string());
        body.push('\n');
    }
    fs::write(&transcript_path, body).unwrap();

    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "hook",
            "stop",
            "--config",
            cfg.to_str().unwrap(),
            "--cwd",
            cwd.to_str().unwrap(),
            "--transcript-path",
            transcript_path.to_str().unwrap(),
        ])
        .assert()
        .success();

    // Candidate visible in pending-review.
    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "list",
            "--config",
            cfg.to_str().unwrap(),
            "--view",
            "pending-review",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("(incident)"))
        .stdout(predicate::str::contains("又失败了"));

    // Candidate NOT visible in wakeup-ready (must be accepted first).
    Command::cargo_bin("spool")
        .unwrap()
        .args([
            "memory",
            "list",
            "--config",
            cfg.to_str().unwrap(),
            "--view",
            "wakeup-ready",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("(incident)").not());
}
