// SPDX-License-Identifier: GPL-3.0-only
//! `doctrine boot` — regenerate the cache-friendly governance snapshot.
//!
//! The snapshot is a **pure projection** of stable governance state into the
//! agent's cacheable session-start prefix (`@`-imported by `CLAUDE.md`), so an
//! agent pays for governance once per change, not once per session (SL-011 §1).
//!
//! Pure/imperative split (house rule): a pure assembler — `boot_sequence` +
//! `render_boot` — takes section bodies and yields a deterministic string with
//! no clock/rng/disk. The thin impure shell (`produce`, `write_if_changed`,
//! `run`) gathers bodies, resolves `current_exe()`, and reaches disk through the
//! shared `fsutil::write_atomic` seam, writing **only on content change** so an
//! unchanged snapshot keeps the cache warm (§1, the content-diff cache key).
//!
//! This phase (PHASE-01) stands up the frame: every section renders a benign
//! marker except `Invoking doctrine`, which carries the resolved exec path.
//! Later phases feed the ADR/memory/governance/static producers real bodies.

use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::fs;
use std::io::{self, Write as _};
use std::os::unix::fs as unix_fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, bail};
use serde_json::{Map, Value};

use crate::{adr, fsutil, governance, install, memory, policy, root, standard};

/// The snapshot lives in the runtime-state tree — derived, gitignored
/// (inherits the `.doctrine/*` ignore), `rm -rf`-able. Never authoritative.
const BOOT_REL: &str = ".doctrine/state/boot.md";

/// First line of the snapshot — warns a human off editing a regenerated file.
const BOOT_HEADER: &str =
    "<!-- Generated by `doctrine boot` — do not edit; regenerated each session -->";

/// Sentinel injected into the boot snapshot so an agent can cheaply detect
/// whether the governance context is already in-scope without reading disk.
const BOOT_SENTINEL: &str = "BOOT-SENTINEL: doctrine-governance-snapshot";

/// Ownership marker for `.pi/extensions/doctrine/index.ts` — first line of
/// each generated file, so we can distinguish doctrine-authored extensions
/// from hand-authored ones without parsing TypeScript.
const PI_EXT_HEADER: &str = "// Generated by `doctrine boot install` — do not edit.";

/// The project's user-owned governance pointer layer — seeded by `doctrine
/// install` (Skip-if-exists), read here as the `Governance` section body, or a
/// benign marker when absent. NOT the embed: this file is the editable layer.
const GOVERNANCE_REL: &str = ".doctrine/governance.md";

// ---------------------------------------------------------------------------
// Pure: snapshot assembly (the extensible seam)
// ---------------------------------------------------------------------------

/// One rendered section: a heading and its already-produced body.
struct Section {
    heading: String,
    body: String,
}

/// Where a section's body comes from. New governance kinds append a `GovRows`
/// row to the sequence with no other change. `ExecPath` is build-volatile, not
/// governance, so it is ordered **last** (see `boot_sequence`).
enum SourceKind {
    /// The resolved `current_exe()` path — so the agent invokes the off-PATH CLI.
    ExecPath,
    /// An embedded digest asset, named by its `install/`-relative filename. The
    /// name selects the canonical embed read by `produce(Static)` via
    /// `install::asset_text` — deterministic and not user-clobberable.
    Static(&'static str),
    /// The project's `.doctrine/governance.md` body, read from disk (the
    /// user-owned layer), or a marker when absent.
    Governance,
    /// A governance-kind listing filtered to its in-force status SET — accepted
    /// ADRs, required policies, default+required standards. One arm projects all
    /// numbered governance kinds: the kind descriptor + the status set that
    /// reveals past the list hide-set (SL-033 — the per-kind-arm collapse).
    GovRows(&'static governance::GovKind, &'static [&'static str]),
    /// Memory pointers — its own variant: `memory::list_rows` takes a distinct
    /// signature (a `None` scope arg), not the `GovKind` spine.
    Memories,
    /// The dense command-map projection (SL-150 §5.4). A marker variant — no
    /// payload, so `SourceKind` stays a clean data enum (its derives, incl.
    /// `Debug`, still hold). The renderer is INJECTED into `produce`/
    /// `build_sections` (a `fn() -> String` param), never named here: `boot`
    /// must not depend on `cli` (D9 — the layering edge stays `cli → boot`).
    CommandMap,
    /// Onboarding-tagged memory bodies, inlined verbatim in key order (SL-187).
    /// Bodies come from `memory::onboarding_bodies`; empty selection → marker.
    Onboarding,
}

/// The declared heading + source order. The `Invoking doctrine` (`ExecPath`)
/// section is **last**: the exec path is build-volatile (§5.3/F4), so ordering
/// it last confines a path change to the snapshot tail and leaves the
/// governance prefix cache-warm.
fn boot_sequence() -> Vec<(&'static str, SourceKind)> {
    vec![
        (
            "Routing & Process",
            SourceKind::Static("routing-process.md"),
        ),
        // The dense command-map projection — placed immediately after routing so
        // the agent meets the command surface before governance (SL-150).
        ("Commands", SourceKind::CommandMap),
        ("Governance (project)", SourceKind::Governance),
        (
            "Accepted ADRs",
            SourceKind::GovRows(&adr::ADR_KIND, &["accepted"]),
        ),
        (
            "Active Policies",
            SourceKind::GovRows(&policy::POLICY_KIND, &["required"]),
        ),
        (
            "Active Standards",
            SourceKind::GovRows(&standard::STANDARD_KIND, &["default", "required"]),
        ),
        ("Memory", SourceKind::Memories),
        // Onboarding memories (tag = "onboarding") inlined verbatim in key order —
        // replaces the retired boot-footer round-trip (SL-187). Marker when none.
        ("Onboarding", SourceKind::Onboarding),
        // universal, model-agnostic band — no per-model content baked in; the
        // hymns cascade is pulled on demand via `doctrine prompt resolve`.
        ("Model band", SourceKind::Static("model-band.md")),
        // build-volatile — keep LAST so a path change hits only the cache tail.
        ("Invoking doctrine", SourceKind::ExecPath),
    ]
}

/// Assemble the snapshot string: header comment, title, then each section as
/// `## {heading}\n{body}`. Deterministic — same sections in, byte-identical out.
fn render_boot(sections: &[Section]) -> String {
    let mut out = String::new();
    out.push_str(BOOT_HEADER);
    out.push('\n');
    out.push_str("# Doctrine Boot Context\n");
    out.push_str("<!-- ");
    out.push_str(BOOT_SENTINEL);
    out.push_str(" -->\n");
    for section in sections {
        out.push_str("\n## ");
        out.push_str(&section.heading);
        out.push('\n');
        out.push_str(&section.body);
        out.push('\n');
    }
    out
}

/// The fixed body for a not-yet-populated section. Fixed (no clock/inputs) so
/// `render_boot` stays byte-stable and the content-diff cache key holds.
fn marker(label: &str) -> String {
    format!("<!-- {label}: not yet populated -->")
}

/// A governance-specific nudge for empty Policies/Standards sections — points
/// at the shipped signpost memory covering these governance kinds.
fn gov_nudge(heading: &str) -> String {
    let kind = heading
        .strip_prefix("Active ")
        .unwrap_or(heading)
        .to_lowercase();
    format!("<!-- No active {kind} yet. See mem.signpost.doctrine.policies-standards -->")
}

// ---------------------------------------------------------------------------
// Impure shell: produce each body, write on change, run the verb
// ---------------------------------------------------------------------------

/// Produce one section's body. `ExecPath` carries the resolved exec path;
/// `CommandMap` renders the INJECTED `command_map` (so `boot` never names `cli`,
/// keeping the layering edge one-way — D9). Never panics on a missing source —
/// a miss is a marker, not a crash (the ordering-knot break, §5.5).
fn produce(
    heading: &str,
    kind: &SourceKind,
    root: &Path,
    exec: &Path,
    command_map: fn() -> String,
) -> Section {
    let body = match kind {
        SourceKind::ExecPath => exec.display().to_string(),
        // The dense command map — rendered by the injected `command_map` (pure,
        // infallible; ignores root/exec). `boot` never references `cli` (D9).
        SourceKind::CommandMap => command_map(),
        // A numbered governance kind, filtered to its IN-FORCE status set: accepted
        // ADRs, required policies, default+required standards. The explicit
        // `status` set reveals these past the list hide-set (deprecated|retired|…) —
        // exactly the boot intent (SL-025 §5.1 — boot is a declared non-clap
        // consumer building a `listing::ListArgs` directly). One arm for every
        // kind (SL-033): a single-element set reproduces the old per-kind arms
        // byte-for-byte; a multi-element set (standards: default+required) is what
        // the `&[&str]` buys. The error≡empty marker collapse and supersession⇏status
        // gap are inherited, shared, and out of scope here (§5.5) — documented, not fixed.
        SourceKind::GovRows(kind, set) => {
            let produced = governance::list_rows(
                kind,
                root,
                crate::listing::ListArgs {
                    status: set.iter().map(|s| (*s).to_string()).collect(),
                    ..Default::default()
                },
            );
            // Empty Policies or Standards sections get a specific nudge
            // pointing at the shipped signpost memory; every other governance
            // kind gets the generic marker.
            match (&produced, heading) {
                (Ok(rows), "Active Policies" | "Active Standards") if rows.trim().is_empty() => {
                    gov_nudge(heading)
                }
                _ => section_or_marker(heading, produced),
            }
        }
        // Compact key listing for the boot snapshot: active signpost memories
        // only, key-ascending, with uid fallback for keyless. The full table is
        // reachable via /retrieve-memory (ADR-005 PUSH-tier compactness).
        SourceKind::Memories => {
            let body = memory::boot_keys(root).map(|keys| {
                if keys.is_empty() {
                    return String::new();
                }
                let mut body = String::from(
                    "Run /retrieve-memory to surface relevant memories for your task.\n",
                );
                for key in &keys {
                    body.push_str(key);
                    body.push('\n');
                }
                body
            });
            section_or_marker(heading, body)
        }
        // canonical embedded digest — read from the embed, never disk (§5.2).
        SourceKind::Static(name) => section_or_marker(heading, install::asset_text(name)),
        // user-owned governance pointer layer — read from disk (§5.3).
        SourceKind::Governance => section_or_marker(
            heading,
            fs::read_to_string(root.join(GOVERNANCE_REL)).map_err(anyhow::Error::from),
        ),
        // Onboarding memories inlined verbatim in key order (SL-187) — replaces
        // the retired footer round-trip. Empty selection → marker.
        SourceKind::Onboarding => section_or_marker(
            heading,
            memory::onboarding_bodies(root).map(|bodies| bodies.join("\n\n")),
        ),
    };
    Section {
        heading: heading.to_string(),
        body,
    }
}

/// Map a producer's result to a section body: the real rows when present, else
/// the benign marker. An error OR an empty listing both fall back to the marker —
/// a miss is never a crash (§5.5). The trailing newline is trimmed so a section
/// never grows a blank tail line under `render_boot`.
fn section_or_marker(heading: &str, produced: anyhow::Result<String>) -> String {
    match produced {
        Ok(rows) if !rows.trim().is_empty() => rows.trim_end().to_string(),
        _ => marker(heading),
    }
}

/// Write `content` to `path` only when it differs from what is already there,
/// returning whether a write happened. The content-diff is the cache key (§1):
/// a no-op write would needlessly bust the cached prefix. Ensures the parent
/// dir, then swaps atomically through `fsutil::write_atomic`.
fn write_if_changed(path: &Path, content: &str) -> anyhow::Result<bool> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("Failed to create {}", parent.display()))?;
    }
    // any read failure (absent / unreadable) → treat as "differs" and rewrite.
    if fs::read_to_string(path).ok().as_deref() == Some(content) {
        return Ok(false);
    }
    fsutil::write_atomic(path, content.as_bytes())?;
    Ok(true)
}

/// Produce every section's body in the declared order — the single render path
/// shared by `regenerate` (which writes it) and `boot_check` (which diffs it).
/// One source of the snapshot, never a second fork (Charge V).
fn build_sections(root: &Path, exec: &Path, command_map: fn() -> String) -> Vec<Section> {
    boot_sequence()
        .iter()
        .map(|(heading, kind)| produce(heading, kind, root, exec, command_map))
        .collect()
}

/// Build every section and render the full snapshot string. Pure — same inputs
/// yield byte-identical output every time (the deterministic guarantee VT-2).
/// The shared snapshot entry point: also reused by `prompt resolve` (SL-187) —
/// never a second projector.
pub(crate) fn build_and_render(root: &Path, exec: &Path, command_map: fn() -> String) -> String {
    render_boot(&build_sections(root, exec, command_map))
}

/// Regenerate `.doctrine/state/boot.md` under `root`, resolving section bodies
/// against `exec`. Returns whether the file changed. Pure assembly, single write.
fn regenerate(root: &Path, exec: &Path, command_map: fn() -> String) -> anyhow::Result<bool> {
    let content = build_and_render(root, exec, command_map);
    write_if_changed(&root.join(BOOT_REL), &content)
}

/// Regenerate the on-disk UNIVERSAL boot snapshot and return its content — the
/// seam `prompt resolve` reuses (SL-187 PHASE-04). The disk artifact is
/// AXIS-INVARIANT (INV-D1): this entry takes no role/harness/model axis, so no
/// `prompt resolve` flag can alter `boot.md` — the axes extend stdout only.
/// Idempotent via `write_if_changed` (a deterministic projection of committed
/// inputs; self-healing under concurrency, INV-D2). Same generator as `regenerate`
/// and `boot_check` — never a second projector.
pub(crate) fn resolve_universal_snapshot(
    root: &Path,
    exec: &Path,
    command_map: fn() -> String,
) -> anyhow::Result<String> {
    let content = build_and_render(root, exec, command_map);
    write_if_changed(&root.join(BOOT_REL), &content)?;
    Ok(content)
}

/// Whether a section still carries its not-yet-populated marker body — i.e. its
/// source was missing or empty at render time. Exact, not a substring grep: a
/// section is unpopulated iff its body is byte-equal to `marker(heading)` or
/// `gov_nudge(heading)` (SL-069 — governance empty-section nudge).
///
/// Governance `GovRows` sections (Accepted ADRs, Active Policies, Active
/// Standards) are always excluded: zero entities of these kinds is a legitimate
/// terminal state, not a sentry concern (IMP-015). The boot snapshot text still
/// carries the marker/nudge; only the `--check` warning is suppressed.
fn is_marker(section: &Section) -> bool {
    // Governance GovRows — never a sentry concern when empty.
    if matches!(
        section.heading.as_str(),
        "Accepted ADRs" | "Active Policies" | "Active Standards"
    ) {
        return false;
    }
    section.body == marker(&section.heading)
        || (section.heading.starts_with("Active ") && section.body == gov_nudge(&section.heading))
}

/// The disk freshness/health report (Charge II/III). DETERMINISTIC — built from
/// a recompute and a single on-disk read, no clock, no embedded timestamp (which
/// would bust the cache every session, §5.5). `stale` ⇒ on-disk `boot.md` differs
/// from the recompute; `marker_sections` ⇒ sections whose source is unpopulated.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct CheckReport {
    pub(crate) stale: bool,
    pub(crate) marker_sections: Vec<String>,
}

impl CheckReport {
    /// A current, fully-populated file: in sync on disk, every source projected.
    fn is_clean(&self) -> bool {
        !self.stale && self.marker_sections.is_empty()
    }
}

/// Recompute the snapshot and compare it to what is on disk (Charge II). A DISK
/// sentry, not a session sentry (§5.4, codex F2): it sees the *file* fresh while
/// the *current inlined prefix* stays stale until `/clear`/restart — so callers
/// must NOT read a clean report as proof the live context is fresh. Absent /
/// unreadable on-disk file ⇒ stale (the recompute differs from nothing).
pub(crate) fn boot_check(root: &Path, exec: &Path, command_map: fn() -> String) -> CheckReport {
    let sections = build_sections(root, exec, command_map);
    let recomputed = render_boot(&sections);
    let on_disk = fs::read_to_string(root.join(BOOT_REL)).ok();
    CheckReport {
        stale: on_disk.as_deref() != Some(recomputed.as_str()),
        marker_sections: sections
            .iter()
            .filter(|s| is_marker(s))
            .map(|s| s.heading.clone())
            .collect(),
    }
}

/// Strip a kernel-appended `b" (deleted)"` suffix from a `/proc/self/exe`
/// reading. Pure, byte-level (UTF-8-agnostic, so a path that is non-UTF-8
/// *before* the ASCII suffix is still cleaned). `Some` iff the suffix was present.
#[cfg(unix)]
fn strip_deleted(p: &Path) -> Option<PathBuf> {
    use std::os::unix::ffi::{OsStrExt, OsStringExt};
    p.as_os_str()
        .as_bytes()
        .strip_suffix(b" (deleted)")
        .map(|b| PathBuf::from(std::ffi::OsString::from_vec(b.to_vec())))
}
#[cfg(not(unix))]
fn strip_deleted(_p: &Path) -> Option<PathBuf> {
    None // the /proc/self/exe poison is Linux-only
}

/// Pure branch logic with an injectable existence probe, so all three arms are
/// unit-testable without a real `current_exe()` reading. Prefer the raw reading
/// when it exists; on a `(deleted)` reading take the stripped path **only if it
/// exists**; otherwise fail loudly rather than bake a dead command.
fn pick_exec(raw: PathBuf, exists: impl Fn(&Path) -> bool) -> anyhow::Result<PathBuf> {
    if exists(&raw) {
        return Ok(raw);
    }
    if let Some(stripped) = strip_deleted(&raw)
        && exists(&stripped)
    {
        return Ok(stripped);
    }
    bail!(
        "doctrine executable path {} does not resolve to an on-disk binary; \
         reinstall from a stable location",
        raw.display()
    )
}

/// The single approved exec resolver — the thin shell over `pick_exec` that
/// supplies the real `current_exe()` reading and `Path::exists`. Every bake site
/// (hook command, pi extension, boot snapshot, corpus sync) resolves through here
/// so the `(deleted)` poison is sanitized once, at the source (SL-124 D1).
pub(crate) fn resolve_exec() -> anyhow::Result<PathBuf> {
    let raw = std::env::current_exe().context("Failed to resolve the doctrine executable path")?;
    pick_exec(raw, std::path::Path::exists)
}

/// `doctrine boot [-p ROOT]` — resolve the root, resolve `current_exe()` in the
/// shell (never in the pure layer), regenerate, and report wrote vs unchanged.
pub(crate) fn run(path: Option<PathBuf>, command_map: fn() -> String) -> anyhow::Result<()> {
    let root = root::find(path, &root::default_markers())?;
    let exec = resolve_exec()?;
    let dest = root.join(BOOT_REL);
    let verb = if regenerate(&root, &exec, command_map)? {
        "Wrote"
    } else {
        "Unchanged"
    };
    writeln!(io::stdout(), "{verb} {}", dest.display())?;
    Ok(())
}

/// `doctrine boot --check` — resolve the root + `current_exe()` in the shell,
/// run the disk sentry, and report. DISK-scoped wording only: never claim the
/// current session's inlined prefix is fresh (§5.4 — that is `/route`'s lag
/// warning + the freshen-now ritual, not this verb).
pub(crate) fn run_check(path: Option<PathBuf>, command_map: fn() -> String) -> anyhow::Result<()> {
    let root = root::find(path, &root::default_markers())?;
    let exec = resolve_exec()?;
    let report = boot_check(&root, &exec, command_map);
    let dest = root.join(BOOT_REL);
    let mut out = io::stdout();
    if report.is_clean() {
        writeln!(out, "clean {} — on-disk snapshot in sync", dest.display())?;
        return Ok(());
    }
    if report.stale {
        writeln!(
            out,
            "stale {} — differs from current governance; run `doctrine boot`, then `/clear` or restart",
            dest.display()
        )?;
    }
    if !report.marker_sections.is_empty() {
        writeln!(
            out,
            "unpopulated sections: {}",
            report.marker_sections.join(", ")
        )?;
    }
    Ok(())
}

/// `doctrine boot --emit` — regenerate the governance snapshot AND emit its
/// exact bytes to stdout. The same bytes written to disk are written to stdout:
/// one render, one output path each. No trailing extra newline — `render_boot`
/// already supplies the terminal newline (the deterministic guarantee).
///
/// `json`: wrap stdout as a Cursor `sessionStart` hook envelope instead of raw
/// markdown (IMP-245) — Cursor's hook runner parses stdout as JSON and errors
/// on a bare markdown body. The disk write is IDENTICAL either way; only the
/// stdout encoding branches.
pub(crate) fn run_emit(
    path: Option<PathBuf>,
    command_map: fn() -> String,
    json: bool,
) -> anyhow::Result<()> {
    let root = root::find(path, &root::default_markers())?;
    let exec = resolve_exec()?;
    let content = build_and_render(&root, &exec, command_map);
    write_if_changed(&root.join(BOOT_REL), &content)?;
    if json {
        write!(io::stdout(), "{}", session_start_hook_json(&content)?)?;
    } else {
        write!(io::stdout(), "{content}")?;
    }
    Ok(())
}

/// The Cursor `sessionStart` hook output envelope
/// (`cursor.com/docs/hooks.md`): `{"additional_context": "<snapshot>"}`.
/// Escaping (quotes, backticks, newlines, unicode) rides `serde_json` — the
/// same primitive already used for the `.mcp.json`/`.codex/hooks.json` merge
/// cores below and every MCP tool response body.
#[derive(serde::Serialize)]
struct SessionStartHookOutput<'a> {
    additional_context: &'a str,
}

/// Render `content` as the Cursor hook JSON envelope, one compact line + `\n`
/// (mirrors the MCP stdio transport's line-framing convention).
pub(crate) fn session_start_hook_json(content: &str) -> anyhow::Result<String> {
    let out = SessionStartHookOutput {
        additional_context: content,
    };
    Ok(format!("{}\n", serde_json::to_string(&out)?))
}

// ===========================================================================
// PHASE-04 — harness seam + `doctrine boot install` wiring
// ===========================================================================
//
// Per-harness wiring behind an `enum Harness` + `match` seam (NOT trait/Box<dyn>
// — design D8/Charge IV; mirrors `skills.rs`'s Claude-vs-Other shape). Decision
// logic is pure (`plan_boot_import`, `plan_session_hook`); the disk read/write is
// a thin imperative wrapper (`ensure_boot_import`, `install_refresh`), so §9's
// merge matrix is plain unit tests with no disk (house rule, A1).

/// The machine-local Claude settings file carrying the `SessionStart` hook —
/// gitignored (the absolute exec path belongs out of git, §5.3/D6).
const SETTINGS_REL: &str = ".claude/settings.local.json";

/// Codex hooks file (CLI codex) — project-root JSON under `.codex/`.
const CODE_HOOKS_REL: &str = ".codex/hooks.json";

/// The project-root `.mcp.json` Claude Code reads for project-scoped MCP servers
/// (CHR-013). Unlike `SETTINGS_REL` this is a committed, team-shared file — it
/// carries the doctrine MCP server registration (`doctrine serve --mcp`).
const MCP_REL: &str = ".mcp.json";

/// The `mcpServers` key doctrine registers its server under in `.mcp.json` — the
/// client-side alias, so its tools surface as `mcp__doctrine__review_*`.
const MCP_SERVER_KEY: &str = "doctrine";
/// The portable `.mcp.json` `command` (SL-195, POL-002): env-expansion resolved
/// by Claude Code at load (`${VAR:-default}`, mcp.md:384). Single source of the
/// literal — `desired_mcp_entry`, the `plan_mcp` comparator, and
/// `is_doctrine_mcp_entry` all consume it (STD-001). NO host abspath in the
/// committed file; `DOCTRINE_BIN` overrides when `doctrine` is off PATH.
const MCP_COMMAND: &str = "${DOCTRINE_BIN:-doctrine}";

/// The `SessionStart` matcher token — fires on a fresh session and on `/clear`
/// (`clear` firing a `SessionStart` hook is already witnessed; the OR-token is
/// confirmed live at closure, §6/PHASE-06).
const SESSION_MATCHER: &str = "startup|clear";

/// The Codex `SessionStart` matcher token — fires on startup, resume, clear,
/// and compact events.
const SESSION_MATCHER_CODEX: &str = "startup|resume|clear|compact";

/// Cosmetic matcher for the `WorktreeCreate` hook entry (SL-152).
/// `WorktreeCreate` has no matcher support (hooks.md:237), so this never scopes at
/// runtime — it only gives the merge core a stable canonical-entry identity.
const WORKTREE_CREATE_MATCHER: &str = "*";

// ---------------------------------------------------------------------------
// The harness seam (R2) — enum + match, one local id per wired harness.
// ---------------------------------------------------------------------------

/// A wired harness. A third harness is a new arm, no framework (design D8).
/// Identity-unification with `skills::Agent` is deferred debt until SL-012 frees
/// `skills.rs` (the §3 concurrency gate forbids touching it now).
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Harness {
    Claude,
    Codex,
}

/// Parse an explicit `--agent` token. Unlike `skills::parse_agent` (which
/// delegates unknowns to `npx`), boot wires only the harnesses it knows, so an
/// unknown name is an error, not a passthrough.
fn parse_harness(s: &str) -> anyhow::Result<Harness> {
    if s.eq_ignore_ascii_case("claude") {
        Ok(Harness::Claude)
    } else if s.eq_ignore_ascii_case("codex") {
        Ok(Harness::Codex)
    } else {
        bail!("Unknown harness '{s}'. Known harnesses: claude, codex.")
    }
}

pub(crate) fn harness_label(h: &Harness) -> &'static str {
    match h {
        Harness::Claude => "claude",
        Harness::Codex => "codex",
    }
}

/// The committed file each harness `@`-imports the snapshot from. **One file per
/// harness** (review fix #1): Claude reads `CLAUDE.md`, codex `AGENTS.md` — never
/// both for one agent, else the snapshot would inline twice. This repo's
/// `CLAUDE.md → AGENTS.md` symlink makes the union dedup to a single inode.
fn import_targets(h: &Harness, root: &Path) -> Vec<PathBuf> {
    match h {
        Harness::Claude => vec![root.join("CLAUDE.md")],
        Harness::Codex => vec![root.join("AGENTS.md")],
    }
}

/// Resolve target harnesses: explicit `--agent` wins; else auto-detect by marker
/// (`.claude/` → Claude; `.codex/`, or an `AGENTS.md` that is not merely Claude's
/// inode-alias → Codex); ≥1 required (mirrors `skills::resolve_agents`). A repo
/// with `.claude/` and a separate-inode `AGENTS.md` wires both; this repo's
/// `AGENTS.md` symlink onto `CLAUDE.md` is Claude's import target via one inode,
/// not a codex surface, so it stays Claude-only (SL-063 §3.1).
pub(crate) fn resolve_harnesses(explicit: &[String], root: &Path) -> anyhow::Result<Vec<Harness>> {
    if !explicit.is_empty() {
        return explicit.iter().map(|s| parse_harness(s)).collect();
    }
    let claude = root.join(".claude").exists();
    let mut found = Vec::new();
    if claude {
        found.push(Harness::Claude);
    }
    // AGENTS.md is "merely Claude's alias" only when Claude is detected AND
    // AGENTS.md resolves to CLAUDE.md's inode (this repo's symlink) — then it is
    // Claude's import target, not a codex surface, so it does not trigger codex.
    // Otherwise a present AGENTS.md is a real codex surface — including a lone
    // symlinked pair with no `.claude/`, where alias-suppression is off because
    // Claude is not detected (SL-063 §3.1).
    let agents = root.join("AGENTS.md");
    let agents_is_claude_alias = claude
        && agents.exists()
        && resolve_target(&agents) == resolve_target(&root.join("CLAUDE.md"));
    if root.join(".codex").exists() || (agents.exists() && !agents_is_claude_alias) {
        found.push(Harness::Codex);
    }
    if found.is_empty() {
        bail!(
            "No --agent given and no .claude/ or .codex/ (or AGENTS.md) found. \
             Pass --agent <claude|codex>."
        );
    }
    Ok(found)
}

// ---------------------------------------------------------------------------
// Shared `@`-import wiring (harness-agnostic): pure plan + imperative apply.
// ---------------------------------------------------------------------------

/// The reportable result of wiring the import ref into one committed file.
#[derive(Debug, PartialEq, Eq)]
enum RefOutcome {
    /// The file was absent and created carrying just the ref.
    Created(PathBuf),
    /// The ref was prepended ahead of existing content.
    Added(PathBuf),
    /// The ref was already present — no write.
    Present(PathBuf),
}

/// The reference line that connects the committed import target to the boot
/// snapshot — the line `plan_boot_import` searches for and the idempotency
/// check still matches on (unchanged semantics across the guard expansion).
const BOOT_REF_LINE: &str = "@.doctrine/state/boot.md";

/// The sentinel-aware guard block appended right after the reference line.
/// Together with `BOOT_REF_LINE` it forms a multi-line block that tells the
/// agent whether the boot snapshot is already in-context and it can skip the
/// disk read (MUST NOT read) or MUST read it now (has not seen the sentinel).
const BOOT_REF_GUARD: &str = "\
If you have NOT seen `BOOT-SENTINEL: doctrine-governance-snapshot` anywhere \
in your context (system prompt or preceding messages), you MUST read the file \
referenced above now. If you HAVE seen it, you MUST NOT — the content is \
already in context.\
";

/// The full two-line boot-import block written into committed targets.
fn boot_guard_block() -> String {
    format!("{BOOT_REF_LINE}\n{BOOT_REF_GUARD}")
}

/// Replace the bare `@.doctrine/state/boot.md` line (old-style) with the
/// full sentinel-aware guard block, preserving the rest of the content.
fn upgrade_bare_boot_ref(content: &str) -> String {
    let guard = boot_guard_block();
    // Find the exact line that matches the reference line and replace it.
    for line in content.lines() {
        if line.trim() == BOOT_REF_LINE {
            return content.replacen(line, &guard, 1);
        }
    }
    // Caller guarantees the ref line is present.
    content.to_string()
}

/// Pure decision for one target's current content. Returns the action and the
/// new file body to write (`None` when nothing should be written). Idempotent:
/// a body already carrying the full guard block plans no write; a body with
/// only the bare `@` ref line (old-style, pre-guard) plans an upgrade to the
/// full sentinel-aware block.
fn plan_boot_import(existing: Option<&str>, reference: &str) -> (RefAction, Option<String>) {
    match existing {
        None => (RefAction::Create, Some(format!("{}\n", boot_guard_block()))),
        Some(content) if content.lines().any(|l| l.trim() == reference) => {
            if content.contains(BOOT_SENTINEL) {
                (RefAction::Present, None)
            } else {
                // Old-style bare `@` line — upgrade to the full guard block.
                (RefAction::Add, Some(upgrade_bare_boot_ref(content)))
            }
        }
        Some(content) => (
            RefAction::Add,
            Some(format!("{}\n\n{content}", boot_guard_block())),
        ),
    }
}

/// The pure action `plan_boot_import` chose; the wrapper pairs it with the path.
enum RefAction {
    Create,
    Add,
    Present,
}

/// The planned action for `.pi/APPEND_SYSTEM.md`.
#[cfg_attr(
    not(test),
    expect(dead_code, reason = "retained for future non-pi harness use")
)]
enum SymlinkAction {
    /// Create .pi/ dir then create the symlink.
    CreateDirAndLink,
    /// Create the symlink.
    CreateLink,
    /// Replace existing file/symlink with correct symlink.
    ReplaceLink,
    /// Already correct — no work.
    NoOp,
}

/// The reportable result of the append-system symlink install.
#[derive(Debug, PartialEq, Eq)]
#[cfg_attr(
    not(test),
    expect(dead_code, reason = "retained for future non-pi harness use")
)]
enum AppendSystemOutcome {
    /// Created .pi/ directory and symlink.
    CreatedDirAndLink,
    /// Created symlink in existing .pi/ directory.
    CreatedLink,
    /// Replaced a stale file or wrong-target symlink.
    ReplacedLink,
    /// Already correct — no work.
    NoOp,
    /// Not applicable for this harness.
    NotApplicable,
}

/// The planned action for `.pi/extensions/doctrine/index.ts`.
enum ExtAction {
    /// No file — generate.
    Generate,
    /// First line matches our header but content differs — regenerate.
    Regenerate,
    /// Byte-identical to what we would generate — no-op.
    NoOp,
    /// File exists but first line is not our header — foreign, skip.
    SkipForeign,
}

/// The reportable result of the pi extension install.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum ExtOutcome {
    /// Generated extension file.
    Generated,
    /// Regenerated after exec path change.
    Regenerated,
    /// Already correct — no work.
    NoOp,
    /// Foreign file — left untouched.
    SkippedForeign,
    /// Not applicable for this harness.
    NotApplicable,
}

/// Resolve a target to the path actually written: its canonical real path when it
/// exists (so a symlink target — `CLAUDE.md → AGENTS.md` — is updated through to
/// the one inode, never replaced by a regular file), else the path as given (a
/// to-be-created file has no inode to canonicalize). The dedup key is this same
/// resolved path, so two views of one inode collapse to a single write (A5/§5.5).
fn resolve_target(target: &Path) -> PathBuf {
    fs::canonicalize(target).unwrap_or_else(|_| target.to_path_buf())
}

/// Idempotent prepend of `reference` into each committed `target`: create the
/// file when absent, dedup same-inode targets to one write, preserve existing
/// content. `dry_run` computes outcomes but writes nothing (A7).
fn ensure_boot_import(
    targets: &[PathBuf],
    reference: &str,
    dry_run: bool,
) -> anyhow::Result<Vec<RefOutcome>> {
    let mut seen = BTreeSet::new();
    let mut outcomes = Vec::new();
    for target in targets {
        let resolved = resolve_target(target);
        if !seen.insert(resolved.clone()) {
            continue;
        }
        let existing = fs::read_to_string(&resolved).ok();
        let (action, new_body) = plan_boot_import(existing.as_deref(), reference);
        if let (Some(body), false) = (&new_body, dry_run) {
            if let Some(parent) = resolved.parent() {
                fs::create_dir_all(parent)
                    .with_context(|| format!("Failed to create {}", parent.display()))?;
            }
            fsutil::write_atomic(&resolved, body.as_bytes())?;
        }
        outcomes.push(match action {
            RefAction::Create => RefOutcome::Created(resolved),
            RefAction::Add => RefOutcome::Added(resolved),
            RefAction::Present => RefOutcome::Present(resolved),
        });
    }
    Ok(outcomes)
}

// ---------------------------------------------------------------------------
// Claude SessionStart hook merge: pure plan + imperative apply.
// ---------------------------------------------------------------------------

/// What the hook merge did, for reporting. The carried string is the command.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum RefreshOutcome {
    /// No doctrine hook existed; one was appended.
    Wired(String),
    /// An owned hook existed and was normalized to canonical (stale command, stale
    /// matcher, `(deleted)` poison, duplicate collapse, or foreign-sibling extraction).
    Refreshed(String),
    /// The settings file was malformed (or oddly shaped); left untouched, snippet
    /// printed by the caller — never clobbered (§5.3/D3).
    PrintedFallback {
        hook_file: &'static str,
        snippet: String,
    },
    /// Nothing to do: the doctrine hook is already current, or pi (import-only).
    None,
}

/// A planned merge: the outcome plus the new settings JSON to write (`None` when
/// no write is needed).
struct HookPlan {
    outcome: RefreshOutcome,
    new_json: Option<String>,
}

/// The hook command for `exec`: `<exec> boot`. A **single** space-free argument
/// (`boot`) — the invariant the ownership match leans on (see
/// `is_doctrine_boot_command`).
fn boot_command(exec: &Path) -> String {
    format!("{} boot", exec.display())
}

/// The resolve-based session-start emit args (SL-187 PHASE-04, CHR-033 seam):
/// the pi extension + Claude/Codex `SessionStart` hooks exec `<doctrine> prompt
/// resolve --role orchestrator`, delivering the SL-186 resolver world in place of
/// the legacy `boot --emit`.
const RESOLVE_EMIT_ARGS: &str = "prompt resolve --role orchestrator";
/// The legacy emit args — still recognised by `is_doctrine_emit_command` so an
/// already-installed `boot --emit` hook is detected as ours and self-heals to the
/// resolve command on the next install (INV-D4 continuity), never abandoned.
const LEGACY_EMIT_ARGS: &str = "boot --emit";

/// The emit command for `exec`: `<exec> prompt resolve --role orchestrator`.
fn emit_command(exec: &Path) -> String {
    format!("{} {RESOLVE_EMIT_ARGS}", exec.display())
}

/// The canonical hook entry doctrine writes for `spec` — the matcher is the
/// spec's own (not hardcoded), so a `SubagentStart` spec emits its agent-type
/// matcher while a `SessionStart` spec emits `startup|clear`.
fn desired_entry(spec: &HookSpec) -> Value {
    serde_json::json!({
        "matcher": spec.matcher,
        "hooks": [ { "type": "command", "command": spec.command } ],
    })
}

/// Whether `cmd` is doctrine's own `boot` hook — robust to spaces in the exec
/// path (Charge VII). The hook command is always `<program> boot`: a single
/// space-free trailing arg. So splitting on the **last** whitespace cleanly
/// separates the (possibly space-bearing) program from the `boot` arg — no shell
/// tokeniser needed while the command keeps that shape. Ours iff the trailing arg
/// is `boot` and the program's file name is `doctrine`. A foreign hook
/// (`tool boot`, `/x/doctrine-helper run`) never matches. NOTE: if the hook ever
/// grows a second/spaced arg, this must move to a real shell-word split.
fn is_doctrine_boot_command(cmd: &str) -> bool {
    let Some((program, arg)) = cmd.trim().rsplit_once(char::is_whitespace) else {
        return false;
    };
    arg == "boot" && is_doctrine_program(program)
}

/// A program path is doctrine's iff — after dropping a trailing ` (deleted)`
/// (the `/proc/self/exe` poison, SL-124 Defect B) — its file name is `doctrine`.
/// Shared by all three ownership predicates so a poisoned command is still
/// recognised as ours and healed, never abandoned beside a fresh duplicate.
fn is_doctrine_program(program: &str) -> bool {
    let p = program.trim_end();
    let p = p.strip_suffix(" (deleted)").unwrap_or(p);
    Path::new(p.trim_end()).file_name() == Some(OsStr::new("doctrine"))
}

/// The hook command for `exec`: `<exec> memory sync`. TWO trailing args, so the
/// single-arg `rsplit_once` shape `is_doctrine_boot_command` leans on does NOT
/// apply — ownership matches by suffix-strip (see `is_doctrine_sync_command`).
fn sync_command(exec: &Path) -> String {
    format!("{} memory sync", exec.display())
}

/// Whether `cmd` is doctrine's own `memory sync` hook. The command is
/// `<program> memory sync` where `program` may bear spaces, so strip the fixed
/// ` memory sync` suffix and check the remaining program's file name is
/// `doctrine`. Disjoint from `is_doctrine_boot_command` (trailing arg `boot`):
/// neither predicate matches the other's command, so the two `SessionStart`
/// entries never clobber one another.
fn is_doctrine_sync_command(cmd: &str) -> bool {
    let Some(program) = cmd.trim().strip_suffix(" memory sync") else {
        return false;
    };
    is_doctrine_program(program)
}

/// Whether `cmd` is doctrine's own session-start emit command. Ours iff it ends
/// with either the current `prompt resolve --role orchestrator` args OR the legacy
/// `boot --emit` args (self-heal — a stale hook is still recognised and refreshed),
/// preceded by a space, and the remaining program's file name is `doctrine`.
fn is_doctrine_emit_command(cmd: &str) -> bool {
    let trimmed = cmd.trim();
    [RESOLVE_EMIT_ARGS, LEGACY_EMIT_ARGS].iter().any(|args| {
        trimmed
            .strip_suffix(args)
            .and_then(|program| program.strip_suffix(' '))
            .is_some_and(is_doctrine_program)
    })
}

/// The `WorktreeCreate` hook command for `exec`:
/// `<exec> worktree create-fork` (SL-152 PHASE-04). Multi-arg, so ownership
/// matches by suffix-strip (see `is_doctrine_create_fork_command`).
fn create_fork_command(exec: &Path) -> String {
    format!("{} worktree create-fork", exec.display())
}

/// Whether `cmd` is doctrine's own `worktree create-fork` hook. Strip the fixed
/// ` worktree create-fork` suffix and check the remaining program's file name is
/// `doctrine`. Disjoint from the boot/sync predicates (neither owns the other's
/// command), so the `WorktreeCreate` entry never clobbers the `SessionStart` ones.
fn is_doctrine_create_fork_command(cmd: &str) -> bool {
    let Some(program) = cmd.trim().strip_suffix(" worktree create-fork") else {
        return false;
    };
    is_doctrine_program(program)
}

/// A `SessionStart` hook doctrine owns: its canonical `command` plus the predicate
/// that recognizes a prior (possibly stale) copy in foreign settings JSON. The
/// merge core (`plan_hook`/`owned_positions`/`fallback_for`/`install_claude_hook`) is
/// generic over this; `boot install` and `memory sync install` are the two thin
/// callers (no-parallel-impl), differing only in command string + ownership.
pub(crate) struct HookSpec {
    command: String,
    is_ours: fn(&str) -> bool,
    /// The Claude hooks event this spec wires under (`hooks.<event>`).
    event: &'static str,
    /// The matcher token for this spec's entry.
    matcher: &'static str,
}

impl HookSpec {
    /// The `<exec> boot` hook (SL-011) — a `SessionStart` entry.
    #[cfg_attr(
        not(test),
        expect(
            dead_code,
            reason = "SL-152 PHASE-06: boot hook ships via the plugin; retained as fallback, test-only callers"
        )
    )]
    fn boot(exec: &Path) -> Self {
        Self {
            command: boot_command(exec),
            is_ours: is_doctrine_boot_command,
            event: "SessionStart",
            matcher: SESSION_MATCHER,
        }
    }

    /// The Codex `SessionStart` emit hook (SL-014). The command is `<exec>
    /// prompt resolve --role orchestrator` since SL-187 PHASE-04 (was `boot
    /// --emit`); the constructor name is retained for call-site continuity.
    fn boot_emit(exec: &Path) -> Self {
        Self {
            command: emit_command(exec),
            is_ours: is_doctrine_emit_command,
            event: "SessionStart",
            matcher: SESSION_MATCHER_CODEX,
        }
    }

    /// The `<exec> memory sync` hook (SL-018) — a SEPARATE `SessionStart` entry.
    pub(crate) fn sync(exec: &Path) -> Self {
        Self {
            command: sync_command(exec),
            is_ours: is_doctrine_sync_command,
            event: "SessionStart",
            matcher: SESSION_MATCHER,
        }
    }

    /// The `<exec> worktree create-fork` hook (SL-152 PHASE-04) — a
    /// `WorktreeCreate` entry. `WorktreeCreate` has no matcher support
    /// (hooks.md:237), so `WORKTREE_CREATE_MATCHER` is cosmetic — it only feeds the
    /// merge core's canonical-entry identity, never runtime scoping.
    #[cfg_attr(
        not(test),
        expect(
            dead_code,
            reason = "SL-152 PHASE-06: create-fork hook ships via the plugin; retained as fallback, test-only callers"
        )
    )]
    pub(crate) fn create_fork(exec: &Path) -> Self {
        Self {
            command: create_fork_command(exec),
            is_ours: is_doctrine_create_fork_command,
            event: "WorktreeCreate",
            matcher: WORKTREE_CREATE_MATCHER,
        }
    }
}

/// All doctrine-owned `(entry_idx, hook_idx)` positions for `is_ours`, in array
/// order. A hook is owned iff its `command` is recognised by the spec's
/// poison-tolerant predicate. More than one owned position is never legitimate —
/// the normalize collapses them to a single canonical entry (D2/D3).
fn owned_positions(arr: &[Value], is_ours: fn(&str) -> bool) -> Vec<(usize, usize)> {
    let mut out = Vec::new();
    for (ei, entry) in arr.iter().enumerate() {
        let Some(inner) = entry.get("hooks").and_then(Value::as_array) else {
            continue;
        };
        for (hi, hook) in inner.iter().enumerate() {
            if hook
                .get("command")
                .and_then(Value::as_str)
                .is_some_and(is_ours)
            {
                out.push((ei, hi));
            }
        }
    }
    out
}

/// The owned hook at `(ei, hi)` is canonical iff its entry's `matcher` and its
/// `command` both equal the spec's — the no-write short-circuit's precondition.
fn entry_is_canonical(arr: &[Value], ei: usize, hi: usize, spec: &HookSpec) -> bool {
    let Some(entry) = arr.get(ei) else {
        return false;
    };
    let matcher_ok = entry.get("matcher").and_then(Value::as_str) == Some(spec.matcher);
    let command_ok = entry
        .get("hooks")
        .and_then(Value::as_array)
        .and_then(|h| h.get(hi))
        .and_then(|h| h.get("command"))
        .and_then(Value::as_str)
        == Some(spec.command.as_str());
    matcher_ok && command_ok
}

/// The entry at `ei` carries exactly one hook — so it has no foreign sibling and
/// a canonical owned hook there is doctrine-sole. Distinct from
/// `entry_has_foreign_hook`: this counts hooks for the no-write short-circuit.
fn hook_is_sole(arr: &[Value], ei: usize) -> bool {
    arr.get(ei)
        .and_then(|e| e.get("hooks"))
        .and_then(Value::as_array)
        .is_some_and(|h| h.len() == 1)
}

/// The entry at `ei` has at least one hook that is NOT doctrine-owned — the
/// precise "survives the drop" predicate (a foreign or command-less hook is
/// preserved, so the entry remains after `drop_owned_hooks`). A multi-hook entry
/// of purely owned hooks is fully removed, so hook count alone is NOT survival
/// (codex round-4).
fn entry_has_foreign_hook(arr: &[Value], ei: usize, is_ours: fn(&str) -> bool) -> bool {
    arr.get(ei)
        .and_then(|e| e.get("hooks"))
        .and_then(Value::as_array)
        .is_some_and(|hooks| {
            hooks.iter().any(|h| {
                !h.get("command")
                    .and_then(Value::as_str)
                    .is_some_and(is_ours)
            })
        })
}

/// Remove every doctrine-owned hook from its entry, preserving all other
/// entry-level keys — clone-and-rebuild only the `hooks` array with the retained
/// non-owned hooks (D4/m1: the entry-level `matcher` and any unknown keys always
/// survive). An entry whose `hooks` array becomes empty is dropped; a foreign
/// sibling always survives. No descending-index surgery, no `matcher` rewrite.
fn drop_owned_hooks(arr: &mut Vec<Value>, is_ours: fn(&str) -> bool) {
    arr.retain_mut(|entry| {
        let Some(hooks) = entry.get_mut("hooks").and_then(Value::as_array_mut) else {
            return true; // not a hooks-bearing entry — leave untouched
        };
        hooks.retain(|h| {
            !h.get("command")
                .and_then(Value::as_str)
                .is_some_and(is_ours)
        });
        !hooks.is_empty()
    });
}

/// Navigate (creating as needed) to the `hooks.<event>` array. Returns `None` —
/// caller treats as `PrintedFallback` — when `hooks` or `<event>` exists but is
/// the wrong JSON type, so a malformed-but-valid structure is never clobbered.
/// Generic over the event so the same merge core wires `SessionStart` (boot/sync)
/// and `SubagentStart` (stamp) without branching.
fn hook_array_mut<'a>(value: &'a mut Value, event: &str) -> Option<&'a mut Vec<Value>> {
    let obj = value.as_object_mut()?;
    let hooks = obj
        .entry("hooks")
        .or_insert_with(|| Value::Object(Map::new()));
    let entries = hooks
        .as_object_mut()?
        .entry(event)
        .or_insert_with(|| Value::Array(Vec::new()));
    entries.as_array_mut()
}

/// `boot`'s thin caller over the generic merge core (`plan_hook`) — exercised by
/// boot's hook test matrix (the production refresh path goes via
/// `install_claude_hook`, so this wrapper is test-only).
#[cfg(test)]
fn plan_session_hook(existing_json: Option<&str>, exec: &Path) -> HookPlan {
    plan_hook(existing_json, &HookSpec::boot(exec))
}

/// The generic merge core: plan `spec`'s `SessionStart` hook into existing
/// settings `JSON`. Preserves every foreign hook and unrelated key (mutates a
/// `serde_json::Value` at the narrow path — never a typed round-trip that could
/// drop unknown keys). Malformed JSON or an unexpectedly-typed
/// `hooks`/`SessionStart` → fail soft. Both `boot install` and `memory sync
/// install` ride this — see `HookSpec`.
fn plan_hook(existing_json: Option<&str>, spec: &HookSpec) -> HookPlan {
    let command = spec.command.clone();
    let mut value: Value = match existing_json.map(str::trim) {
        None | Some("") => Value::Object(Map::new()),
        Some(text) => match serde_json::from_str(text) {
            Ok(parsed) => parsed,
            Err(_) => {
                return HookPlan {
                    outcome: RefreshOutcome::PrintedFallback {
                        hook_file: "",
                        snippet: String::new(),
                    },
                    new_json: None,
                };
            }
        },
    };
    let Some(arr) = hook_array_mut(&mut value, spec.event) else {
        return HookPlan {
            outcome: RefreshOutcome::PrintedFallback {
                hook_file: "",
                snippet: String::new(),
            },
            new_json: None,
        };
    };
    // Normalize to exactly one canonical, doctrine-sole entry (D2). No-write iff a
    // single canonical sole entry already exists; otherwise drop every owned hook
    // and insert one fresh canonical entry at the first owned hook's execution slot.
    let owned = owned_positions(arr, spec.is_ours);
    if let [(ei, hi)] = owned[..]
        && entry_is_canonical(arr, ei, hi, spec)
        && hook_is_sole(arr, ei)
    {
        return HookPlan {
            outcome: RefreshOutcome::None,
            new_json: None,
        };
    }
    let outcome = match owned.first() {
        None => {
            arr.push(desired_entry(spec)); // Wired — append at tail
            RefreshOutcome::Wired(command)
        }
        Some(&(first, _)) => {
            // Insert at the first owned hook's slot: `first` if that entry is fully
            // removed (all-owned), `first + 1` if it survives (retains a foreign hook).
            let survives = entry_has_foreign_hook(arr, first, spec.is_ours);
            drop_owned_hooks(arr, spec.is_ours);
            let ins = (first + usize::from(survives)).min(arr.len());
            arr.insert(ins, desired_entry(spec));
            RefreshOutcome::Refreshed(command)
        }
    };
    match serde_json::to_string_pretty(&value) {
        Ok(json) => HookPlan {
            outcome,
            new_json: Some(json),
        },
        Err(_) => HookPlan {
            outcome: RefreshOutcome::PrintedFallback {
                hook_file: "",
                snippet: String::new(),
            },
            new_json: None,
        },
    }
}

/// The snippet printed when the settings file can't be merged automatically —
/// generic over a `HookSpec`.
pub(crate) fn fallback_for(spec: &HookSpec) -> String {
    let entry = desired_entry(spec);
    serde_json::to_string_pretty(&entry).unwrap_or_else(|_| spec.command.clone())
}

/// Check whether `.codex/hooks.json` carries more than one `SessionStart` entry
/// with the codex matcher — a spike coexistence that will fire both hooks
/// concurrently.
fn check_spike_coexistence(root: &Path) -> bool {
    let hooks_path = root.join(CODE_HOOKS_REL);
    if let Ok(raw) = fs::read_to_string(&hooks_path)
        && let Ok(val) = serde_json::from_str::<Value>(&raw)
        && let Some(arr) = val
            .get("hooks")
            .and_then(|h| h.get("SessionStart"))
            .and_then(Value::as_array)
    {
        return arr
            .iter()
            .filter(|e| e.get("matcher").and_then(Value::as_str) == Some(SESSION_MATCHER_CODEX))
            .count()
            > 1;
    }
    false
}

/// Imperative per-harness refresh. Claude merges the `SessionStart` hook into
/// `.claude/settings.local.json` (writes only on change, `!dry_run`) AND sets
/// `worktree.baseRef="head"` (SL-064 §8 — so the claude `/dispatch` arm's isolated
/// worker forks the orchestrator session HEAD, base==B by placement); pi is
/// import-only → no hook, no baseRef (its `AGENTS.md` ref is the shared
/// `ensure_boot_import` work). A malformed settings file is left untouched
/// (`PrintedFallback`).
fn install_refresh(
    h: &Harness,
    root: &Path,
    exec: &Path,
    dry_run: bool,
) -> anyhow::Result<RefreshReport> {
    match h {
        Harness::Codex => {
            let hook = install_codex_hook(root, &HookSpec::boot_emit(exec), dry_run)?;
            let spike_warning = if dry_run {
                false
            } else {
                check_spike_coexistence(root)
            };
            let append_system = AppendSystemOutcome::NotApplicable;
            let extension = install_pi_extension(root, exec, dry_run)?;
            let mcp_extension = install_mcp_extension(root, exec, dry_run)?;
            Ok(RefreshReport {
                hook,
                baseref: BaseRefOutcome::NotApplicable,
                mcp: RefreshOutcome::None,
                append_system,
                extension,
                mcp_extension,
                spike_warning,
            })
        }
        Harness::Claude => {
            // SL-152 PHASE-06: the Claude boot (SessionStart) hook now ships via
            // the doctrine plugin, NOT settings-wired (it double-fired with the
            // plugin). The boot hook is no longer written to settings here; the
            // baseRef write below rides BESIDE the (retained, now-unused-for-Claude)
            // HookSpec merge core. baseRef + .mcp.json wiring are UNCHANGED.
            let hook = RefreshOutcome::None;
            let baseref = install_baseref(root, dry_run)?;
            // `.mcp.json` registration (CHR-013) — a SEPARATE project-root file,
            // not the settings file; its own narrow-path merge core.
            let mcp = install_mcp(root, dry_run)?;
            Ok(RefreshReport {
                hook,
                baseref,
                mcp,
                append_system: AppendSystemOutcome::NotApplicable,
                extension: ExtOutcome::NotApplicable,
                mcp_extension: ExtOutcome::NotApplicable,
                spike_warning: false,
            })
        }
    }
}

/// The combined Claude refresh outcome: the `SessionStart` hook merge plus the
/// `worktree.baseRef` set (SL-064 §8). Pi carries `None`/`NotApplicable`.
struct RefreshReport {
    hook: RefreshOutcome,
    baseref: BaseRefOutcome,
    /// The `.mcp.json` doctrine server registration outcome (CHR-013); pi
    /// carries `None` (no `.mcp.json` wiring on the import-only arm).
    mcp: RefreshOutcome,
    /// The `.pi/APPEND_SYSTEM.md` symlink outcome (PHASE-02); Claude carries
    /// `NotApplicable`.
    append_system: AppendSystemOutcome,
    /// The `.pi/extensions/doctrine/index.ts` extension outcome (PHASE-03);
    /// Claude carries `NotApplicable`.
    extension: ExtOutcome,
    /// The `.pi/extensions/doctrine/mcp.ts` MCP extension outcome (SL-120);
    /// Claude carries `NotApplicable`.
    mcp_extension: ExtOutcome,
    /// Whether a foreign `SessionStart` hook with the same codex matcher
    /// coexists — surfaced as a warning.
    spike_warning: bool,
}

/// What the `worktree.baseRef="head"` install did, for reporting (SL-064 §8.3).
/// Mirrors `RefreshOutcome` but for the distinct baseRef concern.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum BaseRefOutcome {
    /// `worktree.baseRef` was absent; `"head"` was written.
    Set,
    /// Already `"head"` — no write (idempotent).
    AlreadyHead,
    /// Present with a NON-`head` value; left untouched, reported (no clobber —
    /// a per-operator deliberate override is respected, §8.3).
    Conflict(String),
    /// The settings file (or its `worktree` key) is malformed — left untouched.
    /// The hook leg flags the malformed file loudly, so this stays silent.
    PrintedFallback,
    /// Pi — no worktree baseRef concern.
    NotApplicable,
}

/// A planned baseRef set: the outcome plus the new settings JSON (`None` when no
/// write is needed). Mirrors `HookPlan`.
struct BaseRefPlan {
    outcome: BaseRefOutcome,
    new_json: Option<String>,
}

/// PURE planner for `worktree.baseRef="head"` in settings `JSON` (SL-064 §8.3).
/// Sets the nested `worktree.baseRef` key WITHOUT disturbing `hooks` or any other
/// `worktree` key (mutates a `serde_json::Value` at the narrow path). Absent ⇒
/// write `"head"`; already `"head"` ⇒ no-op; a non-`head` value or a malformed
/// file/`worktree` type ⇒ leave untouched (no clobber). Rides BESIDE the hook
/// merge core — it never touches `plan_hook`.
fn plan_baseref(existing_json: Option<&str>) -> BaseRefPlan {
    let mut value: Value = match existing_json.map(str::trim) {
        None | Some("") => Value::Object(Map::new()),
        Some(text) => match serde_json::from_str(text) {
            Ok(parsed) => parsed,
            Err(_) => {
                return BaseRefPlan {
                    outcome: BaseRefOutcome::PrintedFallback,
                    new_json: None,
                };
            }
        },
    };
    let Some(obj) = value.as_object_mut() else {
        return BaseRefPlan {
            outcome: BaseRefOutcome::PrintedFallback,
            new_json: None,
        };
    };
    let worktree = obj
        .entry("worktree")
        .or_insert_with(|| Value::Object(Map::new()));
    let Some(wt) = worktree.as_object_mut() else {
        // `worktree` present but not an object ⇒ malformed; never clobber.
        return BaseRefPlan {
            outcome: BaseRefOutcome::PrintedFallback,
            new_json: None,
        };
    };
    match wt.get("baseRef") {
        Some(Value::String(s)) if s == "head" => {
            return BaseRefPlan {
                outcome: BaseRefOutcome::AlreadyHead,
                new_json: None,
            };
        }
        Some(Value::String(s)) => {
            return BaseRefPlan {
                outcome: BaseRefOutcome::Conflict(s.clone()),
                new_json: None,
            };
        }
        Some(_non_string) => {
            return BaseRefPlan {
                outcome: BaseRefOutcome::Conflict("(non-string)".to_string()),
                new_json: None,
            };
        }
        None => {}
    }
    wt.insert("baseRef".to_string(), Value::String("head".to_string()));
    match serde_json::to_string_pretty(&value) {
        Ok(json) => BaseRefPlan {
            outcome: BaseRefOutcome::Set,
            new_json: Some(json),
        },
        Err(_) => BaseRefPlan {
            outcome: BaseRefOutcome::PrintedFallback,
            new_json: None,
        },
    }
}

/// Set `worktree.baseRef="head"` in `.claude/settings.local.json`, writing only on
/// change (unless `dry_run`). Reads → [`plan_baseref`] → atomic write, mirroring
/// [`install_claude_hook`]. Rides beside the hook installer in the Claude arm.
fn install_baseref(root: &Path, dry_run: bool) -> anyhow::Result<BaseRefOutcome> {
    let path = root.join(SETTINGS_REL);
    let existing = fs::read_to_string(&path).ok();
    let plan = plan_baseref(existing.as_deref());
    if let (Some(json), false) = (&plan.new_json, dry_run) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("Failed to create {}", parent.display()))?;
        }
        fsutil::write_atomic(&path, json.as_bytes())?;
    }
    Ok(plan.outcome)
}

// ---------------------------------------------------------------------------
// `.mcp.json` doctrine MCP server registration (CHR-013).
// ---------------------------------------------------------------------------

/// A planned `.mcp.json` merge: the outcome plus the new JSON to write (`None`
/// when no write is needed). Mirrors `HookPlan` / `BaseRefPlan`.
struct McpPlan {
    outcome: RefreshOutcome,
    new_json: Option<String>,
}

fn mcp_fallback() -> McpPlan {
    McpPlan {
        outcome: RefreshOutcome::PrintedFallback {
            hook_file: "",
            snippet: String::new(),
        },
        new_json: None,
    }
}

/// The canonical server entry doctrine writes under `mcpServers.doctrine`:
/// `{ "command": "${DOCTRINE_BIN:-doctrine}", "args": ["serve", "--mcp"] }`. The
/// command is the portable env-expansion literal ([`MCP_COMMAND`]), NOT a host
/// abspath — the committed `.mcp.json` must carry no per-machine path (POL-002,
/// SL-195). `DOCTRINE_BIN` overrides when `doctrine` is off the harness PATH.
fn desired_mcp_entry() -> Value {
    serde_json::json!({
        "command": MCP_COMMAND,
        "args": ["serve", "--mcp"],
    })
}

/// Whether `entry` is doctrine's own MCP server entry — the command is EITHER
/// the portable env form ([`MCP_COMMAND`], the new shape) OR a legacy abspath
/// whose file name is `doctrine` (the pre-SL-195 baked shape), AND the args are
/// exactly `["serve", "--mcp"]`. Owning both forms lets `plan_mcp` migrate an old
/// abs entry to the env form without treating it as foreign or double-registering
/// (SL-195 R1). Robust to a space-bearing legacy path. A foreign command or
/// customised args is NOT ours — a deliberate user override is never clobbered.
fn is_doctrine_mcp_entry(entry: &Value) -> bool {
    let is_doctrine = entry
        .get("command")
        .and_then(Value::as_str)
        .is_some_and(|c| {
            c == MCP_COMMAND || Path::new(c).file_name() == Some(OsStr::new("doctrine"))
        });
    let args_ok = entry
        .get("args")
        .and_then(Value::as_array)
        .is_some_and(|a| matches!(a.as_slice(), [s, m] if s == "serve" && m == "--mcp"));
    is_doctrine && args_ok
}

/// PURE planner for the `mcpServers.doctrine` entry in `.mcp.json` (CHR-013).
/// Sets the nested key WITHOUT disturbing sibling servers or unrelated keys
/// (mutates a `serde_json::Value` at the narrow path). Absent ⇒ write; ours and
/// ALREADY the portable env form ⇒ no-op; ours but a legacy abspath (or any other
/// non-env command) ⇒ refresh to the env form (SL-195 migration); a foreign-shaped
/// `doctrine` entry, a non-object `mcpServers`, or malformed JSON ⇒ leave
/// untouched (no clobber). The desired command is the const [`MCP_COMMAND`], NOT a
/// host path — so the no-op test compares against it (SL-195 F-1: comparing
/// against the abspath here made an already-env entry never no-op ⇒ thrash).
/// Rides BESIDE the hook merge core.
fn plan_mcp(existing_json: Option<&str>) -> McpPlan {
    let command = MCP_COMMAND.to_string();
    let mut value: Value = match existing_json.map(str::trim) {
        None | Some("") => Value::Object(Map::new()),
        Some(text) => match serde_json::from_str(text) {
            Ok(parsed) => parsed,
            Err(_) => return mcp_fallback(),
        },
    };
    let Some(obj) = value.as_object_mut() else {
        return mcp_fallback();
    };
    let servers = obj
        .entry("mcpServers")
        .or_insert_with(|| Value::Object(Map::new()));
    let Some(servers) = servers.as_object_mut() else {
        // `mcpServers` present but not an object ⇒ malformed; never clobber.
        return mcp_fallback();
    };
    let outcome = match servers.get(MCP_SERVER_KEY) {
        None => RefreshOutcome::Wired(command),
        Some(entry) if is_doctrine_mcp_entry(entry) => {
            if entry.get("command").and_then(Value::as_str) == Some(command.as_str()) {
                return McpPlan {
                    outcome: RefreshOutcome::None,
                    new_json: None,
                };
            }
            RefreshOutcome::Refreshed(command)
        }
        // A `doctrine` key that is not our shape is a deliberate user entry.
        Some(_foreign) => return mcp_fallback(),
    };
    servers.insert(MCP_SERVER_KEY.to_string(), desired_mcp_entry());
    match serde_json::to_string_pretty(&value) {
        Ok(json) => McpPlan {
            outcome,
            new_json: Some(json),
        },
        Err(_) => mcp_fallback(),
    }
}

/// The snippet printed when `.mcp.json` carries a foreign/malformed `doctrine`
/// entry that can't be merged automatically — the full `mcpServers` block to add.
fn mcp_fallback_snippet() -> String {
    let block = serde_json::json!({ "mcpServers": { MCP_SERVER_KEY: desired_mcp_entry() } });
    serde_json::to_string_pretty(&block).unwrap_or_else(|_| MCP_COMMAND.to_string())
}

/// Register the doctrine MCP server in the project-root `.mcp.json`, writing only
/// on change (unless `dry_run`). Reads → [`plan_mcp`] → atomic write, mirroring
/// [`install_baseref`]. Rides beside the hook installer in the Claude arm.
fn install_mcp(root: &Path, dry_run: bool) -> anyhow::Result<RefreshOutcome> {
    let path = root.join(MCP_REL);
    let existing = fs::read_to_string(&path).ok();
    let plan = plan_mcp(existing.as_deref());
    if let (Some(json), false) = (&plan.new_json, dry_run) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("Failed to create {}", parent.display()))?;
        }
        fsutil::write_atomic(&path, json.as_bytes())?;
    }
    Ok(match plan.outcome {
        RefreshOutcome::PrintedFallback { .. } => RefreshOutcome::PrintedFallback {
            hook_file: MCP_REL,
            snippet: mcp_fallback_snippet(),
        },
        other => other,
    })
}

/// Annotate a refresh outcome with a hook file path and a fallback snippet.
fn annotate_fallback(
    outcome: RefreshOutcome,
    hook_file: &'static str,
    spec: &HookSpec,
) -> RefreshOutcome {
    match outcome {
        RefreshOutcome::PrintedFallback { .. } => RefreshOutcome::PrintedFallback {
            hook_file,
            snippet: fallback_for(spec),
        },
        other => other,
    }
}

/// Generic hook installer: merge `spec` into `rel_path` under `root`, writing
/// only on change (unless `dry_run`).
fn install_hook_to_file(
    root: &Path,
    rel_path: &'static str,
    spec: &HookSpec,
    dry_run: bool,
) -> anyhow::Result<RefreshOutcome> {
    let path = root.join(rel_path);
    let existing = fs::read_to_string(&path).ok();
    let plan = plan_hook(existing.as_deref(), spec);
    if let (Some(json), false) = (&plan.new_json, dry_run) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("Failed to create {}", parent.display()))?;
        }
        fsutil::write_atomic(&path, json.as_bytes())?;
    }
    Ok(annotate_fallback(plan.outcome, rel_path, spec))
}

/// Merge a `HookSpec`'s `SessionStart` hook into `.claude/settings.local.json`,
/// writing only on change (unless `dry_run`). The generic Claude installer behind
/// both `boot install`'s refresh and `memory sync install` (SL-018).
pub(crate) fn install_claude_hook(
    root: &Path,
    spec: &HookSpec,
    dry_run: bool,
) -> anyhow::Result<RefreshOutcome> {
    install_hook_to_file(root, SETTINGS_REL, spec, dry_run)
}

/// Merge a `HookSpec`'s `SessionStart` hook into `.codex/hooks.json`,
/// writing only on change (unless `dry_run`). The Codex hook installer for
/// `boot install` (SL-014).
fn install_codex_hook(
    root: &Path,
    spec: &HookSpec,
    dry_run: bool,
) -> anyhow::Result<RefreshOutcome> {
    install_hook_to_file(root, CODE_HOOKS_REL, spec, dry_run)
}

// ---------------------------------------------------------------------------
// Append-system symlink: pure plan + imperative apply.
// ---------------------------------------------------------------------------

/// Pure decision for `.pi/APPEND_SYSTEM.md` symlink state.
/// Reads only Path existence/metadata — no mutation.
#[cfg_attr(
    not(test),
    expect(dead_code, reason = "retained for future non-pi harness use")
)]
fn plan_append_system(root: &Path) -> SymlinkAction {
    let pi_dir = root.join(".pi");
    let link_path = pi_dir.join("APPEND_SYSTEM.md");
    let target = "../.doctrine/state/boot.md";

    if !pi_dir.exists() {
        return SymlinkAction::CreateDirAndLink;
    }
    // Use symlink_metadata, not Path::exists(): the symlink target may
    // not exist yet, but the symlink entry itself is what matters.
    if std::fs::symlink_metadata(&link_path).is_err() {
        return SymlinkAction::CreateLink;
    }
    // Check if it's a symlink pointing to the correct target
    if let Ok(existing_target) = std::fs::read_link(&link_path)
        && existing_target == Path::new(target)
    {
        return SymlinkAction::NoOp;
    }
    SymlinkAction::ReplaceLink
}

/// Imperative apply for the append-system symlink.
#[cfg_attr(
    not(test),
    expect(dead_code, reason = "retained for future non-pi harness use")
)]
fn install_append_system(root: &Path, dry_run: bool) -> anyhow::Result<AppendSystemOutcome> {
    let action = plan_append_system(root);
    let pi_dir = root.join(".pi");
    let link_path = pi_dir.join("APPEND_SYSTEM.md");
    let target = "../.doctrine/state/boot.md";

    let outcome = match action {
        SymlinkAction::CreateDirAndLink => {
            if !dry_run {
                std::fs::create_dir_all(&pi_dir)
                    .with_context(|| format!("Failed to create {}", pi_dir.display()))?;
                unix_fs::symlink(target, &link_path)
                    .with_context(|| format!("Failed to symlink {}", link_path.display()))?;
            }
            AppendSystemOutcome::CreatedDirAndLink
        }
        SymlinkAction::CreateLink => {
            if !dry_run {
                unix_fs::symlink(target, &link_path)
                    .with_context(|| format!("Failed to symlink {}", link_path.display()))?;
            }
            AppendSystemOutcome::CreatedLink
        }
        SymlinkAction::ReplaceLink => {
            if !dry_run {
                std::fs::remove_file(&link_path)
                    .with_context(|| format!("Failed to remove {}", link_path.display()))?;
                unix_fs::symlink(target, &link_path)
                    .with_context(|| format!("Failed to symlink {}", link_path.display()))?;
            }
            AppendSystemOutcome::ReplacedLink
        }
        SymlinkAction::NoOp => AppendSystemOutcome::NoOp,
    };
    Ok(outcome)
}

// ---------------------------------------------------------------------------
// Pi session_start extension: pure plan + imperative apply.
// ---------------------------------------------------------------------------

/// Generate the candidate extension file content with the baked exec path.
fn generate_pi_extension(exec: &Path) -> String {
    let exec_str = exec.display();
    format!(
        r#"// Generated by `doctrine boot install` — do not edit.
import {{ execSync }} from "node:child_process";

let cachedBoot: string | null = null;

function resolveBoot(): string {{
  try {{
    return execSync("{exec_str} {RESOLVE_EMIT_ARGS}", {{
      encoding: "utf-8",
      cwd: process.cwd(),
      timeout: 5_000,
    }}).trimEnd();
  }} catch (err) {{
    console.error("[doctrine-boot] resolve failed:", err);
    return "";
  }}
}}

export default async function (pi) {{
  pi.on("session_start", () => {{
    cachedBoot = resolveBoot();
  }});

  pi.on("before_agent_start", async (event) => {{
    if (!cachedBoot) return;
    return {{
      systemPrompt: event.systemPrompt + "\n\n" + cachedBoot,
    }};
  }});

  try {{
    const mcp = await import("./mcp.ts");
    const ext = mcp.default || mcp;
    await ext(pi);
  }} catch {{
    // MCP bridge extension not available — non-fatal
  }}
}}
"#
    )
}

/// Pure decision for `.pi/extensions/doctrine/index.ts`.
/// Reads from disk (Path existence, file contents) but never mutates.
fn plan_pi_extension(root: &Path, exec: &Path) -> ExtAction {
    let ext_dir = root.join(".pi").join("extensions").join("doctrine");
    let ext_path = ext_dir.join("index.ts");

    if !ext_path.exists() {
        return ExtAction::Generate;
    }

    let Ok(existing) = std::fs::read_to_string(&ext_path) else {
        return ExtAction::SkipForeign; // can't read, don't touch
    };

    // Check ownership via first line
    let first_line = existing.lines().next().unwrap_or("");
    if first_line != PI_EXT_HEADER {
        return ExtAction::SkipForeign;
    }

    let candidate = generate_pi_extension(exec);
    if existing == candidate {
        ExtAction::NoOp
    } else {
        ExtAction::Regenerate
    }
}

// ---------------------------------------------------------------------------
// Pi MCP extension (SL-120): pure plan + imperative apply.
// ---------------------------------------------------------------------------

/// The PHASE-01 MCP extension template — embedded at compile time.
const MCP_EXT_TEMPLATE: &str = include_str!("../templates/mcp.ts");

/// The marker line in the template that gets replaced with the baked exec path.
const MCP_BIN_PATH_MARKER: &str = "declare const BIN_PATH: string;";

/// Generate the candidate MCP extension file content with the baked exec path.
fn generate_mcp_extension(exec: &Path) -> String {
    let exec_str = exec.display();
    let baked_line = format!("const BIN_PATH = \"{exec_str}\";");
    let body = MCP_EXT_TEMPLATE.replace(MCP_BIN_PATH_MARKER, &baked_line);
    format!("{}\n\n{}", PI_EXT_HEADER, body.trim())
}

/// Pure decision for `.pi/extensions/doctrine/mcp.ts`.
/// Reads from disk (Path existence, file contents) but never mutates.
fn plan_mcp_extension(root: &Path, exec: &Path) -> ExtAction {
    let ext_dir = root.join(".pi").join("extensions").join("doctrine");
    let ext_path = ext_dir.join("mcp.ts");

    if !ext_path.exists() {
        return ExtAction::Generate;
    }

    let Ok(existing) = std::fs::read_to_string(&ext_path) else {
        return ExtAction::SkipForeign; // can't read, don't touch
    };

    // Check ownership via first line
    let first_line = existing.lines().next().unwrap_or("");
    if first_line != PI_EXT_HEADER {
        return ExtAction::SkipForeign;
    }

    let candidate = generate_mcp_extension(exec);
    if existing == candidate {
        ExtAction::NoOp
    } else {
        ExtAction::Regenerate
    }
}

/// Imperative apply for the MCP extension.
/// Respects `dry_run`: reports the correct outcome without writing to disk.
fn install_mcp_extension(root: &Path, exec: &Path, dry_run: bool) -> anyhow::Result<ExtOutcome> {
    let action = plan_mcp_extension(root, exec);
    let ext_dir = root.join(".pi").join("extensions").join("doctrine");
    let ext_path = ext_dir.join("mcp.ts");

    let outcome = match action {
        ExtAction::Generate => {
            if !dry_run {
                std::fs::create_dir_all(&ext_dir)
                    .with_context(|| format!("Failed to create {}", ext_dir.display()))?;
                let content = generate_mcp_extension(exec);
                fsutil::write_atomic(&ext_path, content.as_bytes())?;
            }
            ExtOutcome::Generated
        }
        ExtAction::Regenerate => {
            if !dry_run {
                let content = generate_mcp_extension(exec);
                fsutil::write_atomic(&ext_path, content.as_bytes())?;
            }
            ExtOutcome::Regenerated
        }
        ExtAction::NoOp => ExtOutcome::NoOp,
        ExtAction::SkipForeign => ExtOutcome::SkippedForeign,
    };
    Ok(outcome)
}

/// Imperative apply for the pi extension.
/// Respects `dry_run`: reports the correct outcome without writing to disk.
fn install_pi_extension(root: &Path, exec: &Path, dry_run: bool) -> anyhow::Result<ExtOutcome> {
    let action = plan_pi_extension(root, exec);
    let ext_dir = root.join(".pi").join("extensions").join("doctrine");
    let ext_path = ext_dir.join("index.ts");

    let outcome = match action {
        ExtAction::Generate => {
            if !dry_run {
                std::fs::create_dir_all(&ext_dir)
                    .with_context(|| format!("Failed to create {}", ext_dir.display()))?;
                let content = generate_pi_extension(exec);
                fsutil::write_atomic(&ext_path, content.as_bytes())?;
            }
            ExtOutcome::Generated
        }
        ExtAction::Regenerate => {
            if !dry_run {
                let content = generate_pi_extension(exec);
                fsutil::write_atomic(&ext_path, content.as_bytes())?;
            }
            ExtOutcome::Regenerated
        }
        ExtAction::NoOp => ExtOutcome::NoOp,
        ExtAction::SkipForeign => ExtOutcome::SkippedForeign,
    };
    Ok(outcome)
}

// ---------------------------------------------------------------------------
// `doctrine boot install` orchestration.
// ---------------------------------------------------------------------------

/// `doctrine boot install` — resolve harnesses, union + dedup their import
/// targets, prepend the `@`-import **once**, then refresh each harness, isolating
/// a single harness's failure (it is printed; the others still run — A9/§5.5).
pub(crate) fn run_install(
    path: Option<PathBuf>,
    agents: &[String],
    dry_run: bool,
    yes: bool,
) -> anyhow::Result<()> {
    let root = root::find(path, &root::default_markers())?;
    let exec = resolve_exec()?;
    let harnesses = resolve_harnesses(agents, &root)?;

    if !yes && !dry_run {
        let label: Vec<&str> = harnesses.iter().map(harness_label).collect();
        let proceed = install::prompt_confirm(&format!(
            "Wire doctrine boot ({}) into {}? [y/N] ",
            label.join(", "),
            root.display()
        ))?;
        if !proceed {
            writeln!(io::stdout(), "Aborted.")?;
            return Ok(());
        }
    }
    wire(&root, &exec, &harnesses, dry_run)
}

/// The exec-injected orchestration core: the shell (`run_install`) resolves
/// `current_exe()` and prompts; this does the work — union + dedup import
/// targets, prepend the `@`-import **once**, then refresh each harness, isolating
/// a single harness's failure (it is printed; the others still run — A9/§5.5).
pub(crate) fn wire(
    root: &Path,
    exec: &Path,
    harnesses: &[Harness],
    dry_run: bool,
) -> anyhow::Result<()> {
    let reference = format!("@{BOOT_REL}");
    let targets: Vec<PathBuf> = harnesses
        .iter()
        .flat_map(|h| import_targets(h, root))
        .collect();

    let mut stdout = io::stdout();
    let tag = if dry_run { "[dry-run] " } else { "" };

    for outcome in ensure_boot_import(&targets, &reference, dry_run)? {
        let (verb, target) = match &outcome {
            RefOutcome::Created(p) => ("created", p),
            RefOutcome::Added(p) => ("added ref", p),
            RefOutcome::Present(p) => ("ref present", p),
        };
        writeln!(stdout, "  {tag}{verb}: {}", target.display())?;
    }

    for h in harnesses {
        match install_refresh(h, root, exec, dry_run) {
            Ok(report) => {
                match report.hook {
                    RefreshOutcome::Wired(cmd) => {
                        writeln!(stdout, "  {tag}{}: wired hook: {cmd}", harness_label(h))?;
                        if matches!(h, Harness::Codex) {
                            writeln!(
                                stdout,
                                "  {tag}{}: wrote {CODE_HOOKS_REL}. To activate:",
                                harness_label(h)
                            )?;
                            writeln!(
                                stdout,
                                "    1. Ensure [features] hooks = true in .codex/config.toml."
                            )?;
                            writeln!(
                                stdout,
                                "    2. Start codex in this project and accept the project trust prompt."
                            )?;
                            writeln!(
                                stdout,
                                "    3. Run /hooks in codex to trust the doctrine hook."
                            )?;
                        }
                    }
                    RefreshOutcome::Refreshed(cmd) => {
                        writeln!(stdout, "  {tag}{}: refreshed hook: {cmd}", harness_label(h))?;
                        if matches!(h, Harness::Codex) {
                            writeln!(
                                stdout,
                                "  {tag}{}: wrote {CODE_HOOKS_REL}. To activate:",
                                harness_label(h)
                            )?;
                            writeln!(
                                stdout,
                                "    1. Ensure [features] hooks = true in .codex/config.toml."
                            )?;
                            writeln!(
                                stdout,
                                "    2. Start codex in this project and accept the project trust prompt."
                            )?;
                            writeln!(
                                stdout,
                                "    3. Run /hooks in codex to trust the doctrine hook."
                            )?;
                        }
                    }
                    RefreshOutcome::PrintedFallback { hook_file, snippet } => {
                        writeln!(
                            stdout,
                            "  {}: {hook_file} is malformed — add this hook manually:",
                            harness_label(h)
                        )?;
                        writeln!(stdout, "{snippet}")?;
                    }
                    RefreshOutcome::None => {}
                }
                if report.spike_warning {
                    writeln!(
                        stdout,
                        "  {}: warning — a foreign SessionStart hook with the same matcher",
                        harness_label(h)
                    )?;
                    writeln!(
                        stdout,
                        "    ({SESSION_MATCHER_CODEX}) exists alongside the doctrine hook."
                    )?;
                    writeln!(
                        stdout,
                        "    Both will fire concurrently. Remove the old entry to avoid"
                    )?;
                    writeln!(stdout, "    duplicate snapshot injection.")?;
                }
                // baseRef leg (SL-064 §8): report a fresh set or a respected
                // non-head override. AlreadyHead / PrintedFallback / NotApplicable
                // stay silent (no-op, or the hook leg already flagged a bad file).
                match report.baseref {
                    BaseRefOutcome::Set => {
                        writeln!(
                            stdout,
                            "  {tag}{}: set worktree.baseRef=head",
                            harness_label(h)
                        )?;
                    }
                    BaseRefOutcome::Conflict(value) => {
                        writeln!(
                            stdout,
                            "  {}: worktree.baseRef is '{value}', not 'head' — left as-is (claude isolated-worker base==B needs 'head'; set it manually if intended)",
                            harness_label(h)
                        )?;
                    }
                    BaseRefOutcome::AlreadyHead
                    | BaseRefOutcome::PrintedFallback
                    | BaseRefOutcome::NotApplicable => {}
                }
                // MCP registration leg (CHR-013): report a fresh wire or a stale
                // refresh; a foreign/malformed `.mcp.json` entry prints the manual
                // snippet. None (current, or pi) stays silent.
                match report.mcp {
                    RefreshOutcome::Wired(cmd) => {
                        writeln!(
                            stdout,
                            "  {tag}{}: registered MCP server in {MCP_REL}: {cmd} serve --mcp",
                            harness_label(h)
                        )?;
                    }
                    RefreshOutcome::Refreshed(cmd) => {
                        writeln!(
                            stdout,
                            "  {tag}{}: refreshed MCP server in {MCP_REL}: {cmd} serve --mcp",
                            harness_label(h)
                        )?;
                    }
                    RefreshOutcome::PrintedFallback { hook_file, snippet } => {
                        writeln!(
                            stdout,
                            "  {}: {hook_file} has a foreign or malformed 'doctrine' entry — register manually:",
                            harness_label(h)
                        )?;
                        writeln!(stdout, "{snippet}")?;
                    }
                    RefreshOutcome::None => {}
                }
                // Append-system symlink leg (PHASE-02): pi creates a symlink so
                // the boot snapshot is ingested as append-only context. Claude
                // carries NotApplicable and stays silent.
                match report.append_system {
                    AppendSystemOutcome::CreatedDirAndLink | AppendSystemOutcome::CreatedLink => {
                        writeln!(
                            stdout,
                            "  {tag}{}: created .pi/APPEND_SYSTEM.md symlink",
                            harness_label(h)
                        )?;
                    }
                    AppendSystemOutcome::ReplacedLink => {
                        writeln!(
                            stdout,
                            "  {tag}{}: replaced .pi/APPEND_SYSTEM.md symlink",
                            harness_label(h)
                        )?;
                    }
                    AppendSystemOutcome::NoOp | AppendSystemOutcome::NotApplicable => {}
                }
                // Pi extension leg (PHASE-03): pi generates/refreshes a
                // session_start extension that resolves `doctrine prompt resolve
                // --role orchestrator` (SL-187 PHASE-04, CHR-033 seam) and injects
                // the output via before_agent_start. Claude carries NotApplicable
                // and stays silent.
                match report.extension {
                    ExtOutcome::Generated => {
                        writeln!(
                            stdout,
                            "  {tag}{}: generated extension .pi/extensions/doctrine/index.ts",
                            harness_label(h)
                        )?;
                    }
                    ExtOutcome::Regenerated => {
                        writeln!(
                            stdout,
                            "  {tag}{}: regenerated extension .pi/extensions/doctrine/index.ts",
                            harness_label(h)
                        )?;
                    }
                    ExtOutcome::NoOp | ExtOutcome::NotApplicable => {}
                    ExtOutcome::SkippedForeign => {
                        writeln!(
                            stdout,
                            "  {}: skipped foreign file .pi/extensions/doctrine/index.ts",
                            harness_label(h)
                        )?;
                    }
                }
                // MCP extension leg (SL-120): pi generates/refreshes the MCP
                // bridge extension. Claude carries NotApplicable and stays silent.
                match report.mcp_extension {
                    ExtOutcome::Generated => {
                        writeln!(
                            stdout,
                            "  {tag}{}: generated extension .pi/extensions/doctrine/mcp.ts",
                            harness_label(h)
                        )?;
                    }
                    ExtOutcome::Regenerated => {
                        writeln!(
                            stdout,
                            "  {tag}{}: regenerated extension .pi/extensions/doctrine/mcp.ts",
                            harness_label(h)
                        )?;
                    }
                    ExtOutcome::NoOp | ExtOutcome::NotApplicable => {}
                    ExtOutcome::SkippedForeign => {
                        writeln!(
                            stdout,
                            "  {}: skipped foreign file .pi/extensions/doctrine/mcp.ts",
                            harness_label(h)
                        )?;
                    }
                }
            }
            Err(e) => writeln!(stdout, "  {}: refresh failed: {e:#}", harness_label(h))?,
        }
    }
    Ok(())
}

// ── CLI dispatch ───────────────────────────────────────────────────────────

use clap::Subcommand;

#[derive(Subcommand)]
pub(crate) enum BootCommand {
    /// Wire the `@`-import into CLAUDE.md/AGENTS.md and refresh each harness's
    /// session hook.
    Install {
        /// Explicit project root (default: auto-detect).
        #[arg(short = 'p', long)]
        path: Option<PathBuf>,

        /// Target harness(es): claude, codex. Repeatable. Default: auto-detect.
        #[arg(long = "agent")]
        agent: Vec<String>,

        /// Compute and report the plan without writing anything.
        #[arg(long)]
        dry_run: bool,

        /// Skip the confirmation prompt.
        #[arg(short = 'y', long)]
        yes: bool,
    },
}

/// The bare (non-`install`) `boot --emit` stdout encoding. Collapses the
/// `--emit`/`--json` flag pair into one param on [`dispatch`] (clippy
/// `fn_params_excessive_bools` — a 4th bare bool tips the pedantic ceiling);
/// as a bonus this also makes "`--json` without `--emit`" unrepresentable in
/// Rust, not just CLI-guarded by clap's `requires`.
pub(crate) enum EmitMode {
    /// `--emit` alone — raw markdown to stdout.
    Raw,
    /// `--emit --json` — Cursor `sessionStart` hook envelope (IMP-245).
    Json,
}

pub(crate) fn dispatch(
    command: Option<BootCommand>,
    check: bool,
    emit: Option<EmitMode>,
    path: Option<PathBuf>,
    _color: bool,
    command_map: fn() -> String,
) -> anyhow::Result<()> {
    match command {
        None => match emit {
            Some(mode) => run_emit(path, command_map, matches!(mode, EmitMode::Json)),
            None if check => run_check(path, command_map),
            None => run(path, command_map),
        },
        Some(BootCommand::Install {
            path: install_path,
            agent,
            dry_run,
            yes,
        }) => run_install(install_path, &agent, dry_run, yes),
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    fn headings() -> Vec<&'static str> {
        boot_sequence().iter().map(|(h, _)| *h).collect()
    }

    /// A trivial injected command-map renderer for sections that are NOT the
    /// CommandMap under test — keeps the boot assembly callable without dragging
    /// `cli` into these tests (the injection seam, D9).
    fn noop_map() -> String {
        String::new()
    }

    // --- VT-1: section order (ExecPath last) ---

    #[test]
    fn boot_sequence_orders_exec_path_last() {
        let seq = boot_sequence();
        // the build-volatile section is last; the four governance sections precede it.
        let last = seq.last().expect("non-empty sequence");
        assert!(
            matches!(last.1, SourceKind::ExecPath),
            "ExecPath must be last"
        );
        assert!(
            seq[..seq.len() - 1]
                .iter()
                .all(|(_, k)| !matches!(k, SourceKind::ExecPath)),
            "ExecPath must appear exactly once, at the tail",
        );
    }

    // --- VT-3 (SL-150): CommandMap sits immediately after Routing, before Governance ---

    #[test]
    fn boot_sequence_places_command_map_after_routing_before_governance() {
        let h = headings();
        let pos = |needle: &str| {
            h.iter()
                .position(|x| *x == needle)
                .unwrap_or_else(|| panic!("{needle} present"))
        };
        let routing = pos("Routing & Process");
        let commands = pos("Commands");
        let governance = pos("Governance (project)");
        assert_eq!(
            commands,
            routing + 1,
            "Commands must sit immediately after Routing & Process"
        );
        assert!(
            commands < governance,
            "Commands must precede Governance (project)"
        );
        // and its source is the marker variant.
        let seq = boot_sequence();
        assert!(
            matches!(seq[commands].1, SourceKind::CommandMap),
            "the Commands section's source is SourceKind::CommandMap"
        );
    }

    // --- VT-4 (SL-150): produce(CommandMap) renders the INJECTED renderer's body (D9) ---

    #[test]
    fn produce_command_map_renders_the_injected_renderer_body() {
        let root = Path::new("/r");
        let exec = Path::new("/abs/target/debug/doctrine");
        let section = produce("Commands", &SourceKind::CommandMap, root, exec, || {
            "SENTINEL".to_string()
        });
        assert_eq!(section.heading, "Commands", "heading is exactly `Commands`");
        assert_eq!(
            section.body, "SENTINEL",
            "the body is the injected command_map output verbatim — boot never names cli (D9)"
        );
    }

    // --- VT-2 (SL-150): boot is byte-stable + check clean with the CommandMap present ---

    #[test]
    fn boot_is_byte_stable_and_check_clean_with_command_map() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");
        // a non-empty, deterministic injected map.
        let map: fn() -> String = || "change  slice\n".to_string();

        let first = build_and_render(root, exec, map);
        let second = build_and_render(root, exec, map);
        assert_eq!(first, second, "two renders are byte-identical (INV-3/VT-2)");
        assert!(
            first.contains("## Commands\nchange  slice"),
            "the CommandMap body is projected under the `## Commands` heading"
        );

        regenerate(root, exec, map).unwrap();
        let report = boot_check(root, exec, map);
        assert!(
            !report.stale,
            "freshly regenerated snapshot is not stale with CommandMap present"
        );
        assert!(
            !report.marker_sections.contains(&"Commands".to_string()),
            "a populated CommandMap is not flagged as an unpopulated section"
        );
    }

    // --- VT-1: section order (Active Policies immediately after Accepted ADRs — EX-1) ---

    #[test]
    fn boot_sequence_orders_active_policies_after_accepted_adrs() {
        let h = headings();
        let adrs = h
            .iter()
            .position(|h| *h == "Accepted ADRs")
            .expect("Accepted ADRs present");
        let policies = h
            .iter()
            .position(|h| *h == "Active Policies")
            .expect("Active Policies present");
        let memory = h
            .iter()
            .position(|h| *h == "Memory")
            .expect("Memory present");
        assert_eq!(
            policies,
            adrs + 1,
            "Active Policies must sit immediately after Accepted ADRs"
        );
        assert!(policies < memory, "Active Policies must precede Memory");
    }

    // --- VT-2: section order (Active Standards between Active Policies and Memory) ---

    #[test]
    fn boot_sequence_orders_active_standards_after_active_policies() {
        let h = headings();
        let pos = |needle: &str| {
            h.iter()
                .position(|x| *x == needle)
                .unwrap_or_else(|| panic!("{needle} present"))
        };
        let adrs = pos("Accepted ADRs");
        let policies = pos("Active Policies");
        let standards = pos("Active Standards");
        let memory = pos("Memory");
        assert!(adrs < policies, "ADRs precede Policies");
        assert_eq!(
            standards,
            policies + 1,
            "Active Standards must sit immediately after Active Policies"
        );
        assert!(standards < memory, "Active Standards must precede Memory");
    }

    // --- VT-1: render determinism + structure ---

    #[test]
    fn render_boot_is_byte_deterministic_and_structured() {
        let sections = vec![
            Section {
                heading: "Alpha".into(),
                body: "one".into(),
            },
            Section {
                heading: "Beta".into(),
                body: "two".into(),
            },
        ];
        let once = render_boot(&sections);
        assert_eq!(
            once,
            render_boot(&sections),
            "repeated render must be byte-identical"
        );
        assert_eq!(
            once,
            format!(
                "{BOOT_HEADER}\n# Doctrine Boot Context\n<!-- {BOOT_SENTINEL} -->\n\n## Alpha\none\n\n## Beta\ntwo\n"
            ),
        );
        // The sentinel appears in the output exactly once (after the title line).
        assert_eq!(
            once.matches(BOOT_SENTINEL).count(),
            1,
            "sentinel must appear exactly once"
        );
    }

    // --- VT-1: missing source renders a marker, not a crash ---

    #[test]
    fn produce_markers_a_non_exec_source_and_carries_the_exec_path() {
        let root = Path::new("/r");
        let exec = Path::new("/abs/target/debug/doctrine");

        let memo = produce("Memory", &SourceKind::Memories, root, exec, noop_map);
        assert_eq!(memo.body, "<!-- Memory: not yet populated -->");

        let invoke = produce(
            "Invoking doctrine",
            &SourceKind::ExecPath,
            root,
            exec,
            noop_map,
        );
        assert_eq!(invoke.body, "/abs/target/debug/doctrine");
    }

    // --- VT-2: produce(Static) reads the embed; a missing asset → marker ---

    #[test]
    fn produce_static_reads_the_embed_and_markers_a_missing_asset() {
        let root = Path::new("/r");
        let exec = Path::new("/abs/target/debug/doctrine");

        let digest = produce(
            "Routing & Process",
            &SourceKind::Static("routing-process.md"),
            root,
            exec,
            noop_map,
        );
        assert!(
            digest.body.contains("Route before you act"),
            "embedded digest projected:\n{}",
            digest.body
        );

        let missing = produce(
            "Routing & Process",
            &SourceKind::Static("no-such-asset.md"),
            root,
            exec,
            noop_map,
        );
        assert_eq!(
            missing.body, "<!-- Routing & Process: not yet populated -->",
            "a missing embed asset is a marker, never a crash",
        );
    }

    // --- EX-1 (SL-187): build_and_render is callable as a shared entry point ---

    #[test]
    fn build_and_render_is_callable_outside_the_boot_command_and_returns_the_snapshot() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        let snapshot = build_and_render(root, exec, noop_map);
        assert!(
            snapshot.contains(BOOT_SENTINEL),
            "the assembled snapshot carries the boot sentinel:\n{snapshot}"
        );
    }

    // --- EX-2/VT-1 (SL-187): the universal "Model band" section renders, positioned
    // before the build-volatile ExecPath tail, and carries the resolve directive ---

    #[test]
    fn model_band_section_renders_before_exec_path_and_carries_the_resolve_directive() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        let snapshot = build_and_render(root, exec, noop_map);

        let model_band_pos = snapshot
            .find("## Model band")
            .expect("Model band heading present in the snapshot");
        let exec_path_pos = snapshot
            .find("## Invoking doctrine")
            .expect("Invoking doctrine heading present in the snapshot");
        assert!(
            model_band_pos < exec_path_pos,
            "Model band must render before the build-volatile Invoking doctrine tail"
        );

        assert!(
            snapshot.contains("universal"),
            "carries the universal keyword"
        );
        assert!(snapshot.contains("hymns"), "carries the hymns keyword");
        assert!(
            snapshot.contains("prompt resolve --band model"),
            "carries the resolve directive"
        );
    }

    // --- EX-2/VT-1 (SL-187): the Model band body is byte-identical to the pristine
    // authored embed — nothing resolved (no model-id list, no hymn payload) is spliced in ---

    #[test]
    fn model_band_section_body_is_exactly_the_pristine_embed_never_resolved_content() {
        let root = Path::new("/r");
        let exec = Path::new("/abs/target/debug/doctrine");

        let section = produce(
            "Model band",
            &SourceKind::Static("model-band.md"),
            root,
            exec,
            noop_map,
        );
        assert_eq!(
            section.body,
            install::asset_text("model-band.md").unwrap().trim_end(),
            "the section body is exactly the authored embed — no resolved band content spliced in"
        );
    }

    /// Write a valid onboarding-tagged `memory.toml` + `memory.md` body under
    /// `<base>/<uid>/` — a boot-test-local fixture (memory.rs's `full_toml`
    /// helper is private to that module). `key` drives the boot ordering; `body`
    /// is the text inlined into the Onboarding section.
    fn write_onboarding_memory(base: &Path, uid: &str, key: &str, body: &str) {
        let dir = base.join(uid);
        fs::create_dir_all(&dir).unwrap();
        let toml = format!(
            "memory_uid = \"{uid}\"\n\
             memory_key = \"{key}\"\n\
             schema_version = 1\n\
             memory_type = \"signpost\"\n\
             status = \"active\"\n\
             title = \"Onboarding fixture\"\n\
             summary = \"\"\n\
             created = \"2026-06-04\"\n\
             updated = \"2026-06-04\"\n\
             \n\
             [scope]\n\
             paths = []\n\
             globs = []\n\
             commands = []\n\
             tags = [\"onboarding\"]\n\
             workspace = \"default\"\n\
             repo = \"\"\n\
             \n\
             [ranking]\n\
             severity = \"none\"\n\
             weight = 0\n"
        );
        fs::write(dir.join("memory.toml"), toml).unwrap();
        fs::write(dir.join("memory.md"), body).unwrap();
    }

    // --- SL-187 PHASE-02 VT-2: onboarding memory bodies inline into the
    // Onboarding section in key order; the footer round-trip is retired ---

    #[test]
    fn onboarding_section_inlines_tagged_memory_bodies_in_key_order() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        // a shipped and a local onboarding-tagged memory, distinguishable bodies.
        write_onboarding_memory(
            &root.join(memory::MEMORY_SHIPPED_DIR),
            "mem_018f3a1b2c3d4e5f60718293a4b5c6a0",
            "mem.onboard.alpha",
            "ALPHA-ONBOARDING-BODY",
        );
        write_onboarding_memory(
            &root.join(memory::MEMORY_ITEMS_DIR),
            "mem_018f3a1b2c3d4e5f60718293a4b5c6b0",
            "mem.onboard.beta",
            "BETA-ONBOARDING-BODY",
        );

        let snapshot = build_and_render(root, exec, noop_map);

        let onboard_pos = snapshot
            .find("## Onboarding")
            .expect("Onboarding heading present");
        let band_pos = snapshot
            .find("## Model band")
            .expect("Model band heading present");
        let exec_pos = snapshot
            .find("## Invoking doctrine")
            .expect("Invoking doctrine heading present");
        assert!(
            onboard_pos < band_pos && band_pos < exec_pos,
            "Onboarding precedes Model band precedes Invoking doctrine:\n{snapshot}"
        );

        // the Onboarding section carries both bodies inline, key-ascending
        // (alpha before beta).
        let section = &snapshot[onboard_pos..band_pos];
        let alpha = section
            .find("ALPHA-ONBOARDING-BODY")
            .expect("alpha body inline in Onboarding");
        let beta = section
            .find("BETA-ONBOARDING-BODY")
            .expect("beta body inline in Onboarding");
        assert!(
            alpha < beta,
            "key-ascending: alpha before beta in the section:\n{section}"
        );

        // the footer round-trip is retired: the section is populated by memories,
        // not the not-yet-populated marker.
        assert!(
            !section.contains("not yet populated"),
            "Onboarding populated by memories, not a marker:\n{section}"
        );

        // no Model band / resolved content bleeds into the Onboarding section.
        assert!(
            !section.contains("prompt resolve --band model") && !section.contains("hymns"),
            "model-band content must not leak into Onboarding:\n{section}"
        );
    }

    // --- VT-2: produce(Governance) reads disk; absent → marker ---

    #[test]
    fn produce_governance_reads_disk_or_markers_when_absent() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        let absent = produce(
            "Governance (project)",
            &SourceKind::Governance,
            root,
            exec,
            noop_map,
        );
        assert_eq!(
            absent.body, "<!-- Governance (project): not yet populated -->",
            "absent governance.md → marker",
        );

        let gov = root.join(GOVERNANCE_REL);
        fs::create_dir_all(gov.parent().unwrap()).unwrap();
        fs::write(
            &gov,
            "# Governance (project)\n\nlive once, in the prefix.\n",
        )
        .unwrap();
        let present = produce(
            "Governance (project)",
            &SourceKind::Governance,
            root,
            exec,
            noop_map,
        );
        assert!(
            present.body.contains("live once, in the prefix."),
            "governance.md body projected:\n{}",
            present.body
        );
    }

    // --- VT-2: write_if_changed ---

    #[test]
    fn write_if_changed_writes_on_new_and_change_noops_on_equal() {
        let dir = tempfile::tempdir().unwrap();
        // nested path proves the parent dir is created.
        let path = dir.path().join(".doctrine/state/boot.md");

        assert!(write_if_changed(&path, "v1").unwrap(), "new file → wrote");
        assert_eq!(fs::read_to_string(&path).unwrap(), "v1");

        assert!(
            !write_if_changed(&path, "v1").unwrap(),
            "byte-equal → no-op"
        );
        assert!(write_if_changed(&path, "v2").unwrap(), "changed → wrote");
        assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
    }

    // --- VT-3: in-process integration (no spawn — dodges stale CARGO_BIN_EXE) ---

    #[test]
    fn regenerate_emits_header_all_headings_exec_path_then_noops() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        assert!(
            regenerate(root, exec, noop_map).unwrap(),
            "first regenerate writes"
        );
        let snapshot = fs::read_to_string(root.join(BOOT_REL)).unwrap();

        assert!(snapshot.starts_with(BOOT_HEADER), "header present");
        for heading in headings() {
            assert!(
                snapshot.contains(&format!("## {heading}")),
                "heading {heading} present"
            );
        }
        assert!(
            snapshot.contains("/abs/target/debug/doctrine"),
            "exec path present"
        );

        assert!(
            !regenerate(root, exec, noop_map).unwrap(),
            "second regenerate is a no-op write"
        );
    }

    // --- IMP-245: `boot --emit --json` — Cursor sessionStart hook envelope ---

    #[test]
    fn session_start_hook_json_wraps_content_in_additional_context() {
        let out = session_start_hook_json("# Heading\n\nsome text\n").unwrap();
        let parsed: Value = serde_json::from_str(out.trim_end()).unwrap();
        assert_eq!(
            parsed.get("additional_context").and_then(Value::as_str),
            Some("# Heading\n\nsome text\n")
        );
    }

    #[test]
    fn session_start_hook_json_escapes_special_characters() {
        // Quotes, backticks, embedded newlines, and unicode all round-trip —
        // serde_json is the escaping mechanism, not hand-rolled string surgery.
        let tricky = "a \"quoted\" `code span`\nline two — emoji 🎉 and \\backslash\\";
        let out = session_start_hook_json(tricky).unwrap();
        let parsed: Value = serde_json::from_str(out.trim_end()).unwrap();
        assert_eq!(
            parsed.get("additional_context").and_then(Value::as_str),
            Some(tricky)
        );
    }

    #[test]
    fn session_start_hook_json_is_single_line_plus_newline() {
        let out = session_start_hook_json("multi\nline\ncontent").unwrap();
        assert_eq!(out.matches('\n').count(), 1, "one trailing newline only");
        assert!(out.ends_with('\n'));
    }

    #[test]
    fn run_emit_json_writes_disk_identically_to_plain_emit() {
        // Two separate roots, one per branch — proves `--json` only changes
        // stdout encoding, never the disk artifact `write_if_changed` produces.
        let dir_plain = tempfile::tempdir().unwrap();
        let dir_json = tempfile::tempdir().unwrap();

        run_emit(Some(dir_plain.path().to_path_buf()), noop_map, false).unwrap();
        run_emit(Some(dir_json.path().to_path_buf()), noop_map, true).unwrap();

        let disk_plain = fs::read_to_string(dir_plain.path().join(BOOT_REL)).unwrap();
        let disk_json = fs::read_to_string(dir_json.path().join(BOOT_REL)).unwrap();
        assert_eq!(disk_plain, disk_json, "disk artifact is byte-identical");
    }

    // --- VT-1: section_or_marker — real rows, else the benign marker ---

    #[test]
    fn section_or_marker_keeps_rows_and_falls_back_on_empty_or_error() {
        assert_eq!(
            section_or_marker("Accepted ADRs", Ok("a\nb\n".into())),
            "a\nb"
        );
        assert_eq!(
            section_or_marker("Accepted ADRs", Ok("  \n".into())),
            "<!-- Accepted ADRs: not yet populated -->",
            "empty listing → marker",
        );
        assert_eq!(
            section_or_marker("Memory", Err(anyhow::anyhow!("boom"))),
            "<!-- Memory: not yet populated -->",
            "producer error → marker, never a crash",
        );
    }

    // --- VT-3: real producers — accepted ADRs (filtered) + memory pointers ---

    #[test]
    fn regenerate_projects_accepted_adrs_and_memory_pointers() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        // two ADRs, only the first accepted.
        adr::run_new(Some(root.to_path_buf()), Some("Use Rust".into()), None).unwrap();
        adr::run_new(Some(root.to_path_buf()), Some("Adopt CI".into()), None).unwrap();
        adr::run_status(Some(root.to_path_buf()), 1, adr::AdrStatus::Accepted, false).unwrap();

        // one memory pointer.
        memory::run_record(
            Some(root.to_path_buf()),
            &memory::RecordArgs {
                title: "Boot pointer note",
                memory_type: memory::MemoryType::Signpost,
                key: None,
                lifespan: None,
                status: memory::Status::Active,
                summary: None,
                review_by: None,
                sources: &[],
                trust_level: None,
                severity: None,
                tags: &[],
                paths: &[],
                globs: &[],
                commands: &[],
                repo: None,
                global: false,
            },
            &mut io::stdout(),
        )
        .unwrap();

        assert!(regenerate(root, exec, noop_map).unwrap());
        let snap = fs::read_to_string(root.join(BOOT_REL)).unwrap();

        // SL-025: the ADR section now renders via the migrated spine path —
        // prefixed ADR- ids + a header row.
        assert!(
            snap.contains("ADR-001 │ accepted"),
            "accepted ADR row projected with prefixed id:\n{snap}"
        );
        assert!(
            snap.lines()
                .any(|l| l.starts_with("id") && l.contains("status")),
            "ADR section carries the header row:\n{snap}"
        );
        assert!(
            !snap.contains("adopt-ci"),
            "non-accepted ADR filtered:\n{snap}"
        );
        // New format: reference line + key list. Key is None, so uid is the
        // fallback key line. Use collect_all to find the uid by title.
        let all = memory::collect_all(root).unwrap();
        let mem_uid = all
            .iter()
            .find(|m| m.title == "Boot pointer note")
            .map(|m| m.uid.as_str())
            .expect("memory recorded");
        assert!(
            snap.contains("Run /retrieve-memory"),
            "reference line projected:\n{snap}"
        );
        assert!(
            snap.contains(mem_uid),
            "memory uid projected as keyless fallback: {mem_uid}\n{snap}"
        );
        assert!(!snap.contains("<!-- Accepted ADRs:"), "ADR marker replaced");
        assert!(!snap.contains("<!-- Memory:"), "memory marker replaced");
    }

    // --- VT-1/VT-2: real producer — required policies (filtered) + empty marker ---

    #[test]
    fn regenerate_projects_required_policies_filtered() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        // four policies, one per status — only the `required` one is in force.
        policy::run_new(
            Some(root.to_path_buf()),
            Some("Commit cadence".into()),
            None,
        )
        .unwrap();
        policy::run_new(
            Some(root.to_path_buf()),
            Some("Branch hygiene".into()),
            None,
        )
        .unwrap();
        policy::run_new(Some(root.to_path_buf()), Some("Old rule".into()), None).unwrap();
        policy::run_new(Some(root.to_path_buf()), Some("Dead rule".into()), None).unwrap();
        policy::run_status(
            Some(root.to_path_buf()),
            1,
            policy::PolicyStatus::Required,
            false,
        )
        .unwrap();
        policy::run_status(
            Some(root.to_path_buf()),
            3,
            policy::PolicyStatus::Deprecated,
            false,
        )
        .unwrap();
        policy::run_status(
            Some(root.to_path_buf()),
            4,
            policy::PolicyStatus::Retired,
            false,
        )
        .unwrap();
        // policy 2 stays `draft`.

        assert!(regenerate(root, exec, noop_map).unwrap());
        let snap = fs::read_to_string(root.join(BOOT_REL)).unwrap();

        // the required policy projects with a prefixed POL- id + header row.
        assert!(
            snap.contains("POL-001 │ required"),
            "required policy row projected with prefixed id:\n{snap}"
        );
        // draft / deprecated / retired are all absent — boot is the in-force view.
        // scope the negative checks to the Active Policies section body only.
        let section = snap
            .split_once("## Active Policies\n")
            .map(|(_, tail)| tail.split_once("\n## ").map_or(tail, |(body, _)| body))
            .expect("Active Policies section present");
        for hidden in ["branch-hygiene", "old-rule", "dead-rule"] {
            assert!(
                !section.contains(hidden),
                "non-required policy {hidden} must not project:\n{section}"
            );
        }
        assert!(
            !snap.contains("<!-- Active Policies:"),
            "policy marker replaced when a required policy exists"
        );
    }

    // --- VT-3/VT-4: real producer — in-force standards (default + required), the
    // first kind with a two-element in-force set. A one-char edit dropping a
    // status from the GovRows set turns this red (VT-4 — the gate bites). ---

    #[test]
    fn regenerate_projects_in_force_standards_filtered() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        // five standards, one per status — only `default` + `required` are in force.
        standard::run_new(Some(root.to_path_buf()), Some("A draft rule".into()), None).unwrap();
        standard::run_new(
            Some(root.to_path_buf()),
            Some("A default rule".into()),
            None,
        )
        .unwrap();
        standard::run_new(
            Some(root.to_path_buf()),
            Some("A required rule".into()),
            None,
        )
        .unwrap();
        standard::run_new(Some(root.to_path_buf()), Some("An old rule".into()), None).unwrap();
        standard::run_new(Some(root.to_path_buf()), Some("A dead rule".into()), None).unwrap();
        standard::run_status(
            Some(root.to_path_buf()),
            2,
            standard::StandardStatus::Default,
            false,
        )
        .unwrap();
        standard::run_status(
            Some(root.to_path_buf()),
            3,
            standard::StandardStatus::Required,
            false,
        )
        .unwrap();
        standard::run_status(
            Some(root.to_path_buf()),
            4,
            standard::StandardStatus::Deprecated,
            false,
        )
        .unwrap();
        standard::run_status(
            Some(root.to_path_buf()),
            5,
            standard::StandardStatus::Retired,
            false,
        )
        .unwrap();
        // standard 1 stays `draft`.

        assert!(regenerate(root, exec, noop_map).unwrap());
        let snap = fs::read_to_string(root.join(BOOT_REL)).unwrap();

        // both in-force standards project with prefixed STD- ids.
        assert!(
            snap.contains("STD-002 │ default"),
            "default standard row projected with prefixed id:\n{snap}"
        );
        assert!(
            snap.contains("STD-003 │ required"),
            "required standard row projected with prefixed id:\n{snap}"
        );
        // draft / deprecated / retired are absent — boot is the in-force view.
        // scope the negative checks to the Active Standards section body only.
        let section = snap
            .split_once("## Active Standards\n")
            .map(|(_, tail)| tail.split_once("\n## ").map_or(tail, |(body, _)| body))
            .expect("Active Standards section present");
        for hidden in ["a-draft-rule", "an-old-rule", "a-dead-rule"] {
            assert!(
                !section.contains(hidden),
                "out-of-force standard {hidden} must not project:\n{section}"
            );
        }
        assert!(
            !snap.contains("<!-- Active Standards:"),
            "standard marker replaced when an in-force standard exists"
        );
    }

    #[test]
    fn regenerate_empty_policy_corpus_renders_marker() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        // a single draft policy — zero `required`, so the section is empty-in-force.
        policy::run_new(Some(root.to_path_buf()), Some("Just a draft".into()), None).unwrap();

        assert!(regenerate(root, exec, noop_map).unwrap());
        let snap = fs::read_to_string(root.join(BOOT_REL)).unwrap();
        assert!(
            snap.contains(
                "<!-- No active policies yet. See mem.signpost.doctrine.policies-standards -->"
            ),
            "zero required policies → governance nudge, never a partial/stale row:\n{snap}"
        );
        assert!(
            !snap.contains("just-a-draft"),
            "draft policy must not leak into the in-force section:\n{snap}"
        );
    }

    /// VT-3 (SL-025 §5.1 / C-4): boot's memory section is rendered ACTIVE-ONLY via
    /// an explicit predicate, DECOUPLED from the CLI `memory list` default. The
    /// fixture spans active + draft + each hidden lifecycle state. Boot must show
    /// ONLY the active memory (no `draft` leak into agent context), while the CLI
    /// list (`memory::list_rows` default) hides the four terminal states but KEEPS
    /// draft — two different visibility rules, both asserted here.
    #[test]
    fn boot_memory_section_is_active_only_decoupled_from_the_list_default() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        // an active + a draft via the real record path (born-frame capture is fine
        // in a non-git temp dir — anchor `none`, unscoped).
        let rec = |title: &str, status: memory::Status| {
            memory::run_record(
                Some(root.to_path_buf()),
                &memory::RecordArgs {
                    title,
                    memory_type: memory::MemoryType::Signpost,
                    key: None,
                    lifespan: None,
                    status,
                    summary: None,
                    review_by: None,
                    sources: &[],
                    trust_level: None,
                    severity: None,
                    tags: &[],
                    paths: &[],
                    globs: &[],
                    commands: &[],
                    repo: None,
                    global: false,
                },
                &mut io::stdout(),
            )
            .unwrap();
        };
        rec("Active note", memory::Status::Active);
        rec("Draft note", memory::Status::Draft);
        rec("Superseded note", memory::Status::Superseded);
        rec("Retracted note", memory::Status::Retracted);
        rec("Archived note", memory::Status::Archived);
        rec("Quarantined note", memory::Status::Quarantined);

        // boot section: ACTIVE ONLY — no draft, no terminal.
        assert!(regenerate(root, exec, noop_map).unwrap());
        let snap = fs::read_to_string(root.join(BOOT_REL)).unwrap();
        // New format: reference line + key list. Find active memory's uid.
        let all = memory::collect_all(root).unwrap();
        let active_uid = all
            .iter()
            .find(|m| m.title == "Active note")
            .map(|m| m.uid.as_str())
            .expect("active memory recorded");
        assert!(snap.contains(active_uid), "boot shows active uid: {}", snap);
        assert!(
            snap.contains("Run /retrieve-memory"),
            "reference line present: {}",
            snap
        );
        // Collect uids of non-active memories and assert they don't appear.
        let leaked_uids: Vec<&str> = all
            .iter()
            .filter(|m| m.title != "Active note")
            .map(|m| m.uid.as_str())
            .collect();
        for uid in &leaked_uids {
            assert!(
                !snap.contains(uid),
                "boot must not leak non-active uid {uid}:\n{snap}"
            );
        }

        // CLI list DEFAULT: hides the four terminal but KEEPS draft (decoupled rule).
        let listed = memory::list_rows(root, None, crate::listing::ListArgs::default()).unwrap();
        assert!(
            listed.contains("Active note"),
            "list shows active:\n{listed}"
        );
        assert!(listed.contains("Draft note"), "list KEEPS draft:\n{listed}");
        for hidden in [
            "Superseded note",
            "Retracted note",
            "Archived note",
            "Quarantined note",
        ] {
            assert!(
                !listed.contains(hidden),
                "list hides terminal {hidden}:\n{listed}"
            );
        }
    }

    /// VT-1 (SL-069): the Memory section renders ONLY signpost-type memories —
    /// concepts, patterns, facts, and other types are excluded. This ensures the
    /// boot snapshot stays compact and carries only navigational pointers.
    #[test]
    fn boot_memory_section_is_signpost_only_excludes_other_types() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        let rec = |title: &str, ty: memory::MemoryType| {
            memory::run_record(
                Some(root.to_path_buf()),
                &memory::RecordArgs {
                    title,
                    memory_type: ty,
                    key: None,
                    lifespan: None,
                    status: memory::Status::Active,
                    summary: None,
                    review_by: None,
                    sources: &[],
                    trust_level: None,
                    severity: None,
                    tags: &[],
                    paths: &[],
                    globs: &[],
                    commands: &[],
                    repo: None,
                    global: false,
                },
                &mut io::stdout(),
            )
            .unwrap();
        };
        rec("Signpost pointer", memory::MemoryType::Signpost);
        rec("Concept note", memory::MemoryType::Concept);
        rec("Pattern note", memory::MemoryType::Pattern);
        rec("Fact note", memory::MemoryType::Fact);

        assert!(regenerate(root, exec, noop_map).unwrap());
        let snap = fs::read_to_string(root.join(BOOT_REL)).unwrap();
        // New format: reference line + key list. Find signpost memory's uid.
        let all = memory::collect_all(root).unwrap();
        let signpost_uid = all
            .iter()
            .find(|m| m.title == "Signpost pointer")
            .map(|m| m.uid.as_str())
            .expect("signpost memory recorded");
        assert!(
            snap.contains(signpost_uid),
            "signpost uid projected: {}",
            snap
        );
        assert!(
            snap.contains("Run /retrieve-memory"),
            "reference line present: {}",
            snap
        );
        // Collect uids of non-signpost memories and assert they don't appear.
        let excluded_uids: Vec<&str> = all
            .iter()
            .filter(|m| m.title != "Signpost pointer")
            .map(|m| m.uid.as_str())
            .collect();
        for uid in &excluded_uids {
            assert!(
                !snap.contains(uid),
                "non-signpost uid {uid} must not appear in boot:\n{snap}"
            );
        }
    }

    // --- VT-3: integration — routing digest (embed) + governance body (disk) ---

    #[test]
    fn regenerate_projects_routing_digest_and_governance_body() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        // seed the user-owned governance layer.
        let gov = root.join(GOVERNANCE_REL);
        fs::create_dir_all(gov.parent().unwrap()).unwrap();
        fs::write(
            &gov,
            "# Governance (project)\n\npoint at .doctrine/spec/tech/\n",
        )
        .unwrap();

        assert!(regenerate(root, exec, noop_map).unwrap());
        let snap = fs::read_to_string(root.join(BOOT_REL)).unwrap();

        assert!(
            snap.contains("Route before you act"),
            "routing digest (embed) projected:\n{snap}"
        );
        // ADR-005 / SL-023 PHASE-03 (VT-2): the reference-forms rules + the
        // reference-docs pointer ride the push digest.
        assert!(
            snap.contains("Reference forms"),
            "reference-forms block projected onto the snapshot:\n{snap}"
        );
        assert!(
            snap.contains("Reference docs") && snap.contains("using-doctrine.md"),
            "reference-docs pointer projected onto the snapshot:\n{snap}"
        );
        assert!(
            snap.contains("point at .doctrine/spec/tech/"),
            "governance body (disk) projected:\n{snap}"
        );
        assert!(
            !snap.contains("<!-- Routing & Process:"),
            "routing marker replaced"
        );
        assert!(
            !snap.contains("<!-- Governance (project):"),
            "governance marker replaced"
        );
    }

    // =======================================================================
    // PHASE-05 — `boot --check` disk sentry
    // =======================================================================

    // --- VT-1: a fully-populated, in-sync file reports clean ---

    #[test]
    fn boot_check_reports_clean_when_populated_and_in_sync() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        // populate every disk/store-backed source so no section markers.
        let gov = root.join(GOVERNANCE_REL);
        fs::create_dir_all(gov.parent().unwrap()).unwrap();
        fs::write(&gov, "# Governance\n\npoint at .doctrine/spec/tech/\n").unwrap();
        adr::run_new(Some(root.to_path_buf()), Some("Use Rust".into()), None).unwrap();
        adr::run_status(Some(root.to_path_buf()), 1, adr::AdrStatus::Accepted, false).unwrap();
        policy::run_new(
            Some(root.to_path_buf()),
            Some("Commit cadence".into()),
            None,
        )
        .unwrap();
        policy::run_status(
            Some(root.to_path_buf()),
            1,
            policy::PolicyStatus::Required,
            false,
        )
        .unwrap();
        standard::run_new(
            Some(root.to_path_buf()),
            Some("Two-space indent".into()),
            None,
        )
        .unwrap();
        standard::run_status(
            Some(root.to_path_buf()),
            1,
            standard::StandardStatus::Required,
            false,
        )
        .unwrap();
        memory::run_record(
            Some(root.to_path_buf()),
            &memory::RecordArgs {
                title: "Boot pointer note",
                memory_type: memory::MemoryType::Signpost,
                key: None,
                lifespan: None,
                status: memory::Status::Active,
                summary: None,
                review_by: None,
                sources: &[],
                trust_level: None,
                severity: None,
                // onboarding-tagged so it also populates the Onboarding section
                // (the retired footer no longer does — SL-187).
                tags: &["onboarding".to_string()],
                paths: &[],
                globs: &[],
                commands: &[],
                repo: None,
                global: false,
            },
            &mut io::stdout(),
        )
        .unwrap();

        assert!(
            regenerate(root, exec, noop_map).unwrap(),
            "seed the on-disk snapshot"
        );
        let report = boot_check(root, exec, noop_map);
        assert!(
            report.is_clean(),
            "fully populated + in sync → clean: stale={}, markers={:?}",
            report.stale,
            report.marker_sections
        );
    }

    // --- VT-1: an edited / absent on-disk file trips stale ---

    #[test]
    fn boot_check_flags_disk_staleness() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        // absent file: recompute differs from nothing → stale.
        assert!(
            boot_check(root, exec, noop_map).stale,
            "absent boot.md → stale"
        );

        // a hand-edited file diverges from the recompute → stale.
        regenerate(root, exec, noop_map).unwrap();
        let dest = root.join(BOOT_REL);
        fs::write(&dest, "# tampered\n").unwrap();
        assert!(
            boot_check(root, exec, noop_map).stale,
            "edited boot.md → stale"
        );

        // regenerate restores byte-equality → clean.
        regenerate(root, exec, noop_map).unwrap();
        assert!(
            !boot_check(root, exec, noop_map).stale,
            "regenerated → not stale"
        );
    }

    // --- VT-1: unpopulated sources surface as marker_sections ---

    #[test]
    fn boot_check_tallies_marker_sections() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        // bare root: no ADRs, no memory, no governance.md → those sections marker.
        regenerate(root, exec, noop_map).unwrap();
        let bare = boot_check(root, exec, noop_map);
        assert!(!bare.stale, "freshly written → not stale");
        assert!(
            bare.marker_sections
                .contains(&"Governance (project)".to_string()),
            "absent governance.md is an unpopulated section: {:?}",
            bare.marker_sections
        );
        // the exec-path section is always populated — never a marker.
        assert!(
            !bare
                .marker_sections
                .contains(&"Invoking doctrine".to_string()),
            "exec path is always populated: {:?}",
            bare.marker_sections
        );
        assert!(!bare.is_clean(), "marker sections present → not clean");

        // seed governance.md → that section is no longer a marker.
        let gov = root.join(GOVERNANCE_REL);
        fs::create_dir_all(gov.parent().unwrap()).unwrap();
        fs::write(&gov, "# Governance\n\npoint at .doctrine/spec/tech/\n").unwrap();
        regenerate(root, exec, noop_map).unwrap();
        let seeded = boot_check(root, exec, noop_map);
        assert!(
            !seeded
                .marker_sections
                .contains(&"Governance (project)".to_string()),
            "seeded governance.md drops the marker: {:?}",
            seeded.marker_sections
        );
    }

    /// VT-3 (IMP-015): `boot --check` does NOT report empty governance
    /// GovRows sections (Accepted ADRs, Active Policies, Active Standards)
    /// as unpopulated — zero entities of these kinds is a legitimate
    /// terminal state. Other empty sections (Memory, etc.) are still flagged.
    #[test]
    fn boot_check_governance_govrows_not_flagged_when_empty() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        // fresh root: zero policies, zero standards, zero ADRs.
        regenerate(root, exec, noop_map).unwrap();
        let report = boot_check(root, exec, noop_map);
        assert!(
            !report
                .marker_sections
                .contains(&"Accepted ADRs".to_string()),
            "empty ADRs NOT flagged: {:?}",
            report.marker_sections
        );
        assert!(
            !report
                .marker_sections
                .contains(&"Active Policies".to_string()),
            "empty policies NOT flagged: {:?}",
            report.marker_sections
        );
        assert!(
            !report
                .marker_sections
                .contains(&"Active Standards".to_string()),
            "empty standards NOT flagged: {:?}",
            report.marker_sections
        );
        // Non-governance empty sections (Memory) are still flagged.
        assert!(report.marker_sections.contains(&"Memory".to_string()));
    }

    /// VT-4 (SL-069): `boot --check` does NOT report Policies/Standards as
    /// unpopulated when they carry in-force rows (populated governance).
    #[test]
    fn boot_check_populated_governance_does_not_warn() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");

        // seed one required policy + one required standard.
        policy::run_new(
            Some(root.to_path_buf()),
            Some("Commit cadence".into()),
            None,
        )
        .unwrap();
        policy::run_status(
            Some(root.to_path_buf()),
            1,
            policy::PolicyStatus::Required,
            false,
        )
        .unwrap();
        standard::run_new(
            Some(root.to_path_buf()),
            Some("Two-space indent".into()),
            None,
        )
        .unwrap();
        standard::run_status(
            Some(root.to_path_buf()),
            1,
            standard::StandardStatus::Required,
            false,
        )
        .unwrap();

        regenerate(root, exec, noop_map).unwrap();
        let report = boot_check(root, exec, noop_map);
        assert!(
            !report
                .marker_sections
                .contains(&"Active Policies".to_string()),
            "populated Policies → not a marker: {:?}",
            report.marker_sections
        );
        assert!(
            !report
                .marker_sections
                .contains(&"Active Standards".to_string()),
            "populated Standards → not a marker: {:?}",
            report.marker_sections
        );
        // Memory is still a marker (no signpost memories created).
        assert!(report.marker_sections.contains(&"Memory".to_string()));
    }

    // --- determinism: no clock — repeated checks are byte-identical ---

    #[test]
    fn boot_check_is_deterministic() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/target/debug/doctrine");
        regenerate(root, exec, noop_map).unwrap();
        assert_eq!(
            boot_check(root, exec, noop_map),
            boot_check(root, exec, noop_map),
            "no clock/rng → identical reports"
        );
    }

    // =======================================================================
    // PHASE-04 — harness seam + `boot install`
    // =======================================================================

    const REF: &str = "@.doctrine/state/boot.md";

    fn session_entries(json: &str) -> Vec<Value> {
        // Absent SessionStart → empty (SL-152 PHASE-06: Claude no longer wires the
        // boot hook into settings, so a settings file may carry only baseRef).
        let value: Value = serde_json::from_str(json).expect("valid JSON");
        value
            .get("hooks")
            .and_then(|h| h.get("SessionStart"))
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default()
    }

    fn commands(json: &str) -> Vec<String> {
        session_entries(json)
            .iter()
            .filter_map(|e| e.get("hooks").and_then(Value::as_array))
            .flatten()
            .filter_map(|h| h.get("command").and_then(Value::as_str))
            .map(str::to_string)
            .collect()
    }

    // --- T3: parse / resolve / import_targets ---

    #[test]
    fn parse_harness_known_and_unknown() {
        assert_eq!(parse_harness("claude").unwrap(), Harness::Claude);
        assert_eq!(parse_harness("CODEX").unwrap(), Harness::Codex);
        assert!(
            parse_harness("cursor")
                .unwrap_err()
                .to_string()
                .contains("Unknown harness")
        );
    }

    #[test]
    fn import_targets_is_one_file_per_harness() {
        let root = Path::new("/r");
        assert_eq!(
            import_targets(&Harness::Claude, root),
            vec![root.join("CLAUDE.md")]
        );
        assert_eq!(
            import_targets(&Harness::Codex, root),
            vec![root.join("AGENTS.md")]
        );
    }

    #[test]
    fn resolve_harnesses_explicit_wins() {
        let root = Path::new("/r");
        let got = resolve_harnesses(&["codex".into(), "claude".into()], root).unwrap();
        assert_eq!(got, vec![Harness::Codex, Harness::Claude]);
    }

    #[test]
    fn resolve_harnesses_auto_detects_by_marker() {
        // Case 1 — bare AGENTS.md, no .claude → codex (a lone codex surface).
        let bare = tempfile::tempdir().unwrap();
        fs::write(bare.path().join("AGENTS.md"), "x").unwrap();
        assert_eq!(
            resolve_harnesses(&[], bare.path()).unwrap(),
            vec![Harness::Codex]
        );

        // Case 2 — .claude/ + separate-inode AGENTS.md → both. The SL-063 fix: a
        // genuine codex surface beside Claude must be wired, not suppressed.
        let split = tempfile::tempdir().unwrap();
        fs::create_dir(split.path().join(".claude")).unwrap();
        fs::write(split.path().join("AGENTS.md"), "real pi surface").unwrap();
        assert_eq!(
            resolve_harnesses(&[], split.path()).unwrap(),
            vec![Harness::Claude, Harness::Codex]
        );

        // Case 3 — .claude/ + AGENTS.md symlink→CLAUDE.md → Claude only. This repo's
        // pattern: AGENTS.md is merely Claude's inode-alias, not a codex surface.
        let alias = tempfile::tempdir().unwrap();
        fs::create_dir(alias.path().join(".claude")).unwrap();
        fs::write(alias.path().join("CLAUDE.md"), "real").unwrap();
        std::os::unix::fs::symlink(
            alias.path().join("CLAUDE.md"),
            alias.path().join("AGENTS.md"),
        )
        .unwrap();
        assert_eq!(
            resolve_harnesses(&[], alias.path()).unwrap(),
            vec![Harness::Claude]
        );

        // Case 4 — .codex/ + .claude/ → both (the explicit codex marker path).
        let codex_marker = tempfile::tempdir().unwrap();
        fs::create_dir(codex_marker.path().join(".claude")).unwrap();
        fs::create_dir(codex_marker.path().join(".codex")).unwrap();
        assert_eq!(
            resolve_harnesses(&[], codex_marker.path()).unwrap(),
            vec![Harness::Claude, Harness::Codex]
        );

        // Case 5 — CLAUDE↔AGENTS symlink pair, NO .claude/ → codex. The adversarial
        // edge: alias-suppression is gated on Claude-detected, so a lone symlinked
        // pair (no .claude/ for Claude to claim) still wires the present AGENTS.md.
        let lone_pair = tempfile::tempdir().unwrap();
        fs::write(lone_pair.path().join("CLAUDE.md"), "real").unwrap();
        std::os::unix::fs::symlink(
            lone_pair.path().join("CLAUDE.md"),
            lone_pair.path().join("AGENTS.md"),
        )
        .unwrap();
        assert_eq!(
            resolve_harnesses(&[], lone_pair.path()).unwrap(),
            vec![Harness::Codex]
        );
    }

    #[test]
    fn resolve_harnesses_errors_when_none() {
        let dir = tempfile::tempdir().unwrap();
        let err = resolve_harnesses(&[], dir.path()).unwrap_err();
        assert!(err.to_string().contains("No --agent given"));
    }

    // --- T1: plan_boot_import (pure) ---

    #[test]
    fn plan_boot_import_creates_adds_and_is_idempotent() {
        let guard = format!("{}\n", boot_guard_block());

        // absent → create with the full guard block.
        let (action, body) = plan_boot_import(None, REF);
        assert!(matches!(action, RefAction::Create));
        assert_eq!(body.unwrap(), guard);

        // present without the ref → prepend guard block ahead of existing content.
        let (action, body) = plan_boot_import(Some("# Title\n\nbody\n"), REF);
        assert!(matches!(action, RefAction::Add));
        assert_eq!(
            body.unwrap(),
            format!("{}\n\n# Title\n\nbody\n", boot_guard_block()),
        );

        // old-style bare @ line (no sentinel) → upgrade to full guard block.
        let (action, body) = plan_boot_import(Some("@.doctrine/state/boot.md\n\nrest\n"), REF);
        assert!(matches!(action, RefAction::Add));
        assert_eq!(body.unwrap(), format!("{}\n\nrest\n", boot_guard_block()));

        // new guard block present (has sentinel) → no write.
        let guard_content = format!("{}\nrest\n", boot_guard_block());
        let (action, body) = plan_boot_import(Some(&guard_content), REF);
        assert!(matches!(action, RefAction::Present));
        assert!(body.is_none());
    }

    // --- T1: ensure_boot_import (imperative) ---

    #[test]
    fn ensure_boot_import_creates_prepends_and_is_idempotent() {
        let dir = tempfile::tempdir().unwrap();
        let target = dir.path().join("CLAUDE.md");
        let guard_block = format!("{}\n", boot_guard_block());

        let out = ensure_boot_import(&[target.clone()], REF, false).unwrap();
        assert_eq!(out, vec![RefOutcome::Created(target.clone())]);
        assert_eq!(fs::read_to_string(&target).unwrap(), guard_block);

        // a second run is a no-op (Present), content unchanged. The file now
        // exists, so the outcome carries the canonicalized real path (on macOS
        // tempdir's /var resolves to /private/var) — compare against the same
        // resolution the code applies, not the raw tempdir path.
        let out = ensure_boot_import(&[target.clone()], REF, false).unwrap();
        assert_eq!(out, vec![RefOutcome::Present(resolve_target(&target))]);
        assert_eq!(fs::read_to_string(&target).unwrap(), guard_block);
    }

    #[test]
    fn ensure_boot_import_preserves_existing_content() {
        let dir = tempfile::tempdir().unwrap();
        let target = dir.path().join("AGENTS.md");
        fs::write(&target, "# Existing\n\nkeep me\n").unwrap();

        // file pre-exists, so the outcome carries the canonicalized real path
        // (macOS /var → /private/var); resolve the expected path to match.
        let out = ensure_boot_import(&[target.clone()], REF, false).unwrap();
        assert_eq!(out, vec![RefOutcome::Added(resolve_target(&target))]);
        let body = fs::read_to_string(&target).unwrap();
        assert_eq!(
            body,
            format!("{}\n\n# Existing\n\nkeep me\n", boot_guard_block())
        );
    }

    #[test]
    fn ensure_boot_import_dedups_same_inode_to_one_write() {
        let dir = tempfile::tempdir().unwrap();
        let agents = dir.path().join("AGENTS.md");
        let claude = dir.path().join("CLAUDE.md");
        fs::write(&agents, "real\n").unwrap();
        std::os::unix::fs::symlink(&agents, &claude).unwrap();

        // union order is [CLAUDE.md, AGENTS.md]; both canonicalize to one inode.
        let out = ensure_boot_import(&[claude.clone(), agents.clone()], REF, false).unwrap();
        assert_eq!(out.len(), 1, "same inode → exactly one outcome");

        let body = fs::read_to_string(&agents).unwrap();
        assert_eq!(body.matches(REF).count(), 1, "ref written exactly once");
        // the symlink survives (we wrote through to the real file, not over the link).
        assert!(
            fs::symlink_metadata(&claude)
                .unwrap()
                .file_type()
                .is_symlink()
        );
        assert_eq!(fs::read_to_string(&claude).unwrap(), body);
    }

    #[test]
    fn ensure_boot_import_dry_run_writes_nothing() {
        let dir = tempfile::tempdir().unwrap();
        let target = dir.path().join("CLAUDE.md");

        let out = ensure_boot_import(&[target.clone()], REF, true).unwrap();
        assert_eq!(out, vec![RefOutcome::Created(target.clone())]);
        assert!(!target.exists(), "dry-run must not write");
    }

    // --- T2: is_doctrine_boot_command (ownership, spaced paths) ---

    #[test]
    fn ownership_match_survives_spaces_and_rejects_foreign() {
        assert!(is_doctrine_boot_command("/usr/bin/doctrine boot"));
        assert!(is_doctrine_boot_command("doctrine boot"));
        // spaced exec path — the Charge VII case.
        assert!(is_doctrine_boot_command("/nix/store/a b/doctrine boot"));
        // foreign hooks never match.
        assert!(!is_doctrine_boot_command("/usr/bin/tool boot"));
        assert!(!is_doctrine_boot_command("/x/doctrine-helper run"));
        assert!(!is_doctrine_boot_command("/x/doctrine check"));
        assert!(!is_doctrine_boot_command("doctrine"));
    }

    // --- exec-path sanitize (SL-124 PHASE-01) ---

    #[test]
    fn strip_deleted_strips_only_a_trailing_kernel_suffix() {
        // suffix present → Some(stripped)
        assert_eq!(
            strip_deleted(Path::new("/x/doctrine (deleted)")),
            Some(PathBuf::from("/x/doctrine"))
        );
        // clean path → None
        assert_eq!(strip_deleted(Path::new("/x/doctrine")), None);
        // no suffix (different tail) → None
        assert_eq!(strip_deleted(Path::new("/x/doctrine-helper")), None);
        // `(deleted)` mid-string, not trailing → None
        assert_eq!(strip_deleted(Path::new("/x/(deleted)/doctrine")), None);
    }

    #[test]
    fn pick_exec_prefers_raw_then_stripped_then_bails() {
        let raw = Path::new("/x/doctrine");
        // raw exists → raw
        assert_eq!(
            pick_exec(PathBuf::from("/x/doctrine"), |p| p == raw).unwrap(),
            PathBuf::from("/x/doctrine")
        );
        // raw absent + stripped exists → stripped
        assert_eq!(
            pick_exec(PathBuf::from("/x/doctrine (deleted)"), |p| p == raw).unwrap(),
            PathBuf::from("/x/doctrine")
        );
        // neither exists → bail
        assert!(pick_exec(PathBuf::from("/x/doctrine (deleted)"), |_| false).is_err());
    }

    #[test]
    fn is_doctrine_program_is_poison_tolerant() {
        assert!(is_doctrine_program("/x/doctrine"));
        assert!(is_doctrine_program("/x/doctrine (deleted)")); // kernel poison
        assert!(!is_doctrine_program("/x/doctrine-helper"));
        assert!(!is_doctrine_program("(deleted)")); // bare token is not ours
    }

    // --- T2: plan_session_hook (pure merge matrix) ---

    #[test]
    fn plan_session_hook_wires_into_empty_or_missing() {
        let exec = Path::new("/abs/doctrine");
        for existing in [None, Some(""), Some("{}")] {
            let plan = plan_session_hook(existing, exec);
            assert!(matches!(plan.outcome, RefreshOutcome::Wired(_)));
            let json = plan.new_json.unwrap();
            assert_eq!(commands(&json), vec!["/abs/doctrine boot".to_string()]);
            assert!(json.contains(SESSION_MATCHER));
        }
    }

    #[test]
    fn plan_session_hook_noops_when_current() {
        let exec = Path::new("/abs/doctrine");
        let wired = plan_session_hook(None, exec).new_json.unwrap();
        let again = plan_session_hook(Some(&wired), exec);
        assert!(matches!(again.outcome, RefreshOutcome::None));
        assert!(again.new_json.is_none(), "current hook → no rewrite");
    }

    #[test]
    fn plan_session_hook_refreshes_on_path_change_preserving_foreign() {
        let old_exec = Path::new("/old/doctrine");
        let new_exec = Path::new("/new path/doctrine"); // spaced new path

        // start from a settings file carrying BOTH a foreign hook and our stale one.
        let seeded = serde_json::to_string_pretty(&serde_json::json!({
            "model": "opus",
            "hooks": {
                "SessionStart": [
                    { "matcher": "startup", "hooks": [ { "type": "command", "command": "/usr/bin/notify start" } ] },
                    { "matcher": SESSION_MATCHER, "hooks": [ { "type": "command", "command": boot_command(old_exec) } ] }
                ]
            }
        })).unwrap();

        let plan = plan_session_hook(Some(&seeded), new_exec);
        assert!(matches!(plan.outcome, RefreshOutcome::Refreshed(_)));
        let json = plan.new_json.unwrap();

        let cmds = commands(&json);
        assert!(
            cmds.contains(&"/usr/bin/notify start".to_string()),
            "foreign hook preserved"
        );
        assert!(
            cmds.contains(&"/new path/doctrine boot".to_string()),
            "command refreshed"
        );
        assert!(
            !cmds.contains(&"/old/doctrine boot".to_string()),
            "stale command gone"
        );
        assert!(json.contains("\"model\""), "unrelated key preserved");
    }

    #[test]
    fn plan_session_hook_fails_soft_on_malformed_json() {
        let plan = plan_session_hook(Some("{ not json"), Path::new("/abs/doctrine"));
        assert!(matches!(
            plan.outcome,
            RefreshOutcome::PrintedFallback { .. }
        ));
        assert!(plan.new_json.is_none(), "malformed → never clobber");
    }

    // --- T2: install_refresh (imperative — dry_run + pi) ---

    #[test]
    fn install_refresh_writes_settings_and_respects_dry_run() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/doctrine");
        let settings = root.join(SETTINGS_REL);
        let mcp = root.join(MCP_REL);

        // SL-152 PHASE-06: the Claude boot hook now ships via the plugin — the
        // refresh no longer wires it into settings (hook == None). baseRef + .mcp
        // wiring are UNCHANGED.
        // dry-run plans a baseRef/mcp write but writes nothing.
        let out = install_refresh(&Harness::Claude, root, exec, true).unwrap();
        assert!(matches!(out.hook, RefreshOutcome::None));
        assert!(matches!(out.baseref, BaseRefOutcome::Set));
        assert!(matches!(out.mcp, RefreshOutcome::Wired(_)));
        assert!(!settings.exists(), "dry-run must not write settings");
        assert!(!mcp.exists(), "dry-run must not write .mcp.json");

        // real run creates the settings file with the baseRef key but NO boot hook.
        let out = install_refresh(&Harness::Claude, root, exec, false).unwrap();
        assert!(matches!(out.hook, RefreshOutcome::None));
        assert!(matches!(out.baseref, BaseRefOutcome::Set));
        assert!(matches!(out.mcp, RefreshOutcome::Wired(_)));
        let json = fs::read_to_string(&settings).unwrap();
        assert!(
            commands(&json).is_empty(),
            "no boot hook wired for Claude (ships via plugin): {json}"
        );
        let parsed: Value = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed["worktree"]["baseRef"], Value::String("head".into()));
        // ...and registers the MCP server in .mcp.json with the PORTABLE env
        // command — no host abspath in the committed file (SL-195, POL-002).
        let mcp_json: Value = serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap();
        assert_eq!(
            mcp_json["mcpServers"]["doctrine"]["command"],
            Value::String(MCP_COMMAND.into())
        );
        assert_eq!(
            mcp_json["mcpServers"]["doctrine"]["args"],
            serde_json::json!(["serve", "--mcp"])
        );

        // a second real run is idempotent — no MCP change.
        let out = install_refresh(&Harness::Claude, root, exec, false).unwrap();
        assert!(matches!(out.mcp, RefreshOutcome::None));

        // Codex arm: hook is wired into .codex/hooks.json, no Claude settings/MCP.
        let out = install_refresh(&Harness::Codex, root, exec, false).unwrap();
        assert!(matches!(
            out.hook,
            RefreshOutcome::Wired(_) | RefreshOutcome::None
        ));
        assert!(matches!(out.baseref, BaseRefOutcome::NotApplicable));
        assert!(matches!(out.mcp, RefreshOutcome::None));
    }

    // --- SL-064 PHASE-08 T4 / VT-1: worktree.baseRef="head" installer ---

    #[test]
    fn plan_baseref_writes_head_when_absent() {
        // Empty/absent settings ⇒ Set, with the nested key written.
        let plan = plan_baseref(None);
        assert!(matches!(plan.outcome, BaseRefOutcome::Set));
        let json = plan.new_json.expect("a write is planned");
        let parsed: Value = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed["worktree"]["baseRef"], Value::String("head".into()));
    }

    #[test]
    fn plan_baseref_idempotent_when_already_head() {
        let existing = r#"{"worktree":{"baseRef":"head"}}"#;
        let plan = plan_baseref(Some(existing));
        assert!(matches!(plan.outcome, BaseRefOutcome::AlreadyHead));
        assert!(plan.new_json.is_none(), "no write when already head");
    }

    #[test]
    fn plan_baseref_conflict_reports_no_clobber() {
        // A deliberate non-head override is respected — reported, never clobbered.
        let existing = r#"{"worktree":{"baseRef":"trunk"}}"#;
        let plan = plan_baseref(Some(existing));
        match plan.outcome {
            BaseRefOutcome::Conflict(v) => assert_eq!(v, "trunk"),
            other => panic!("expected Conflict, got {other:?}"),
        }
        assert!(plan.new_json.is_none(), "conflict must not write");
    }

    #[test]
    fn plan_baseref_preserves_hooks_and_other_worktree_keys() {
        // A realistic settings file: boot + create-fork hooks plus an unrelated
        // worktree key. The baseRef set must leave ALL of them intact.
        let existing = r#"{
          "hooks": {
            "SessionStart": [
              {"matcher":"startup|clear","hooks":[{"type":"command","command":"/abs/doctrine boot"}]}
            ],
            "WorktreeCreate": [
              {"matcher":"*","hooks":[{"type":"command","command":"/abs/doctrine worktree create-fork"}]}
            ]
          },
          "worktree": {"autoFetch": true}
        }"#;
        let plan = plan_baseref(Some(existing));
        assert!(matches!(plan.outcome, BaseRefOutcome::Set));
        let parsed: Value = serde_json::from_str(&plan.new_json.unwrap()).unwrap();
        // baseRef added...
        assert_eq!(parsed["worktree"]["baseRef"], Value::String("head".into()));
        // ...the sibling worktree key survives...
        assert_eq!(parsed["worktree"]["autoFetch"], Value::Bool(true));
        // ...and BOTH hook entries are intact.
        assert_eq!(
            parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"],
            Value::String("/abs/doctrine boot".into())
        );
        assert_eq!(
            parsed["hooks"]["WorktreeCreate"][0]["hooks"][0]["command"],
            Value::String("/abs/doctrine worktree create-fork".into())
        );
    }

    #[test]
    fn plan_baseref_malformed_left_untouched() {
        let plan = plan_baseref(Some("{ not json"));
        assert!(matches!(plan.outcome, BaseRefOutcome::PrintedFallback));
        assert!(
            plan.new_json.is_none(),
            "malformed file must not be clobbered"
        );
    }

    // --- CHR-013: `.mcp.json` doctrine server registration (Claude arm) ---

    #[test]
    fn plan_mcp_writes_entry_when_absent() {
        // Empty/absent .mcp.json ⇒ Wired, with the nested server entry written.
        // SL-195: the command is the portable env literal, NEVER a host abspath.
        assert_eq!(
            MCP_COMMAND, "${DOCTRINE_BIN:-doctrine}",
            "the portable command literal is pinned (POL-002, mcp.md:384)"
        );
        let plan = plan_mcp(None);
        assert!(matches!(plan.outcome, RefreshOutcome::Wired(_)));
        let parsed: Value =
            serde_json::from_str(&plan.new_json.expect("a write is planned")).unwrap();
        assert_eq!(
            parsed["mcpServers"]["doctrine"]["command"],
            Value::String(MCP_COMMAND.into())
        );
        assert_eq!(
            parsed["mcpServers"]["doctrine"]["args"],
            serde_json::json!(["serve", "--mcp"])
        );
    }

    #[test]
    fn plan_mcp_idempotent_when_current() {
        // SL-195 F-1 (the blocker): an entry ALREADY in the portable env form is a
        // true no-op — no rewrite. The comparator must weigh the existing command
        // against MCP_COMMAND, not a host abspath (which would never match ⇒ thrash).
        let existing = r#"{"mcpServers":{"doctrine":{"command":"${DOCTRINE_BIN:-doctrine}","args":["serve","--mcp"]}}}"#;
        let plan = plan_mcp(Some(existing));
        assert!(matches!(plan.outcome, RefreshOutcome::None));
        assert!(plan.new_json.is_none(), "no write when already env-form");
    }

    #[test]
    fn plan_mcp_migrates_legacy_abs_to_env_form() {
        // SL-195 migration: a legacy entry baked by the OLD installer (abspath whose
        // file name is `doctrine`) is still ours (widened predicate) ⇒ Refreshed to
        // the portable env form — never foreign, never double-registered (R1).
        let existing = r#"{"mcpServers":{"doctrine":{"command":"/old/abs/doctrine","args":["serve","--mcp"]}}}"#;
        let plan = plan_mcp(Some(existing));
        assert!(matches!(plan.outcome, RefreshOutcome::Refreshed(_)));
        let parsed: Value = serde_json::from_str(&plan.new_json.unwrap()).unwrap();
        assert_eq!(
            parsed["mcpServers"]["doctrine"]["command"],
            Value::String(MCP_COMMAND.into())
        );
    }

    #[test]
    fn plan_mcp_preserves_foreign_servers() {
        // A sibling MCP server under mcpServers must survive our write intact.
        let existing = r#"{"mcpServers":{"other":{"command":"/x/other","args":["go"]}}}"#;
        let plan = plan_mcp(Some(existing));
        assert!(matches!(plan.outcome, RefreshOutcome::Wired(_)));
        let parsed: Value = serde_json::from_str(&plan.new_json.unwrap()).unwrap();
        assert_eq!(
            parsed["mcpServers"]["other"]["command"],
            Value::String("/x/other".into())
        );
        assert_eq!(
            parsed["mcpServers"]["doctrine"]["command"],
            Value::String(MCP_COMMAND.into())
        );
    }

    #[test]
    fn plan_mcp_foreign_doctrine_key_not_clobbered() {
        // A `doctrine` key that is NOT our shape (foreign command / customised args)
        // is a deliberate user entry — leave it untouched, surface a snippet.
        let existing = r#"{"mcpServers":{"doctrine":{"command":"/x/tool","args":["run"]}}}"#;
        let plan = plan_mcp(Some(existing));
        assert!(matches!(
            plan.outcome,
            RefreshOutcome::PrintedFallback { .. }
        ));
        assert!(
            plan.new_json.is_none(),
            "foreign entry must not be clobbered"
        );
    }

    #[test]
    fn plan_mcp_malformed_left_untouched() {
        let plan = plan_mcp(Some("{ not json"));
        assert!(matches!(
            plan.outcome,
            RefreshOutcome::PrintedFallback { .. }
        ));
        assert!(
            plan.new_json.is_none(),
            "malformed file must not be clobbered"
        );
    }

    #[test]
    fn plan_mcp_non_object_servers_left_untouched() {
        let existing = r#"{"mcpServers":[1,2,3]}"#;
        let plan = plan_mcp(Some(existing));
        assert!(matches!(
            plan.outcome,
            RefreshOutcome::PrintedFallback { .. }
        ));
        assert!(plan.new_json.is_none());
    }

    #[test]
    fn is_doctrine_mcp_entry_recognises_only_our_shape() {
        // SL-195: BOTH the new env form and a legacy abspath (file name `doctrine`)
        // are ours; foreign command or customised args is not.
        let env_form: Value = serde_json::json!({"command": MCP_COMMAND, "args":["serve","--mcp"]});
        assert!(is_doctrine_mcp_entry(&env_form), "env form is ours");
        let legacy_abs: Value =
            serde_json::json!({"command":"/nix/store/a b/doctrine","args":["serve","--mcp"]});
        assert!(is_doctrine_mcp_entry(&legacy_abs), "legacy abspath is ours");
        // foreign command file name
        let foreign: Value = serde_json::json!({"command":"/x/tool","args":["serve","--mcp"]});
        assert!(!is_doctrine_mcp_entry(&foreign));
        // doctrine command but customised args
        let custom: Value =
            serde_json::json!({"command":"/x/doctrine","args":["serve","--mcp","--port","9"]});
        assert!(!is_doctrine_mcp_entry(&custom));
    }

    // --- T6 (SL-018): the generalized seam carries a SEPARATE `memory sync` hook.

    #[test]
    fn sync_ownership_is_disjoint_from_boot() {
        // sync predicate recognises its own command (spaced exec included)...
        assert!(is_doctrine_sync_command("/usr/bin/doctrine memory sync"));
        assert!(is_doctrine_sync_command("doctrine memory sync"));
        assert!(is_doctrine_sync_command(
            "/nix/store/a b/doctrine memory sync"
        ));
        // ...and rejects foreign / boot commands.
        assert!(!is_doctrine_sync_command("/usr/bin/tool memory sync"));
        assert!(!is_doctrine_sync_command("/abs/doctrine boot"));
        assert!(!is_doctrine_sync_command("/abs/doctrine memory"));
        // the two predicates never cross: neither owns the other's command.
        assert!(!is_doctrine_boot_command("/abs/doctrine memory sync"));
        assert!(!is_doctrine_sync_command("/abs/doctrine boot"));
    }

    #[test]
    fn install_claude_hook_wires_boot_and_sync_as_two_entries() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/doctrine");

        // wire boot, then sync — two independent SessionStart entries.
        install_claude_hook(root, &HookSpec::boot(exec), false).unwrap();
        let out = install_claude_hook(root, &HookSpec::sync(exec), false).unwrap();
        assert!(matches!(out, RefreshOutcome::Wired(_)));

        let json = fs::read_to_string(root.join(SETTINGS_REL)).unwrap();
        let cmds = commands(&json);
        assert!(
            cmds.contains(&"/abs/doctrine boot".to_string()),
            "boot kept"
        );
        assert!(
            cmds.contains(&"/abs/doctrine memory sync".to_string()),
            "sync wired"
        );

        // re-running sync is a no-op; the boot entry is untouched.
        let again = install_claude_hook(root, &HookSpec::sync(exec), false).unwrap();
        assert!(matches!(again, RefreshOutcome::None));
        let json = fs::read_to_string(root.join(SETTINGS_REL)).unwrap();
        assert_eq!(
            commands(&json),
            vec![
                "/abs/doctrine boot".to_string(),
                "/abs/doctrine memory sync".to_string(),
            ],
            "two entries, neither duplicated nor clobbered"
        );
    }

    #[test]
    fn install_claude_hook_sync_respects_dry_run() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let out =
            install_claude_hook(root, &HookSpec::sync(Path::new("/abs/doctrine")), true).unwrap();
        assert!(matches!(out, RefreshOutcome::Wired(_)));
        assert!(
            !root.join(SETTINGS_REL).exists(),
            "dry-run must not write settings"
        );
    }

    /// Entries under an arbitrary `hooks.<event>` key (`None` if absent).
    fn event_entries(json: &str, event: &str) -> Option<Vec<Value>> {
        let value: Value = serde_json::from_str(json).expect("valid JSON");
        value
            .get("hooks")
            .and_then(|h| h.get(event))
            .and_then(Value::as_array)
            .cloned()
    }

    // SL-152 VT-1: the create-fork hook merges into a settings file that already
    // carries unrelated SessionStart hooks — the SessionStart entries are
    // untouched and a NEW WorktreeCreate array with the cosmetic-matcher entry is
    // appended. The retired SubagentStart stamp hook is NEVER written. Reinstall
    // is idempotent (no dup).
    #[test]
    fn install_claude_create_fork_hook_appends_worktreecreate_leaves_sessionstart_intact() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/doctrine");

        // Pre-existing SessionStart hooks (boot + a foreign hook).
        install_claude_hook(root, &HookSpec::boot(exec), false).unwrap();
        let settings_path = root.join(SETTINGS_REL);
        let seeded = fs::read_to_string(&settings_path).unwrap();
        let mut value: Value = serde_json::from_str(&seeded).unwrap();
        hook_array_mut(&mut value, "SessionStart")
            .unwrap()
            .push(serde_json::json!({
                "matcher": "startup",
                "hooks": [ { "type": "command", "command": "/usr/bin/foreign hook" } ],
            }));
        fs::write(
            &settings_path,
            serde_json::to_string_pretty(&value).unwrap(),
        )
        .unwrap();

        // Wire the create-fork hook.
        let out = install_claude_hook(root, &HookSpec::create_fork(exec), false).unwrap();
        assert!(matches!(out, RefreshOutcome::Wired(_)), "create-fork wired");

        let json = fs::read_to_string(&settings_path).unwrap();

        // SessionStart entries untouched: boot + the foreign hook still present.
        let session = event_entries(&json, "SessionStart").expect("SessionStart kept");
        let session_cmds: Vec<String> = session
            .iter()
            .filter_map(|e| e.get("hooks").and_then(Value::as_array))
            .flatten()
            .filter_map(|h| h.get("command").and_then(Value::as_str))
            .map(str::to_string)
            .collect();
        assert_eq!(
            session_cmds,
            vec![
                "/abs/doctrine boot".to_string(),
                "/usr/bin/foreign hook".to_string(),
            ],
            "SessionStart untouched"
        );

        // A new WorktreeCreate array carries the cosmetic-matcher create-fork entry.
        let wc = event_entries(&json, "WorktreeCreate").expect("WorktreeCreate created");
        assert_eq!(wc.len(), 1, "one WorktreeCreate entry");
        assert_eq!(
            wc[0].get("matcher").and_then(Value::as_str),
            Some(WORKTREE_CREATE_MATCHER),
            "cosmetic matcher pinned"
        );
        let wc_cmd = wc[0]
            .get("hooks")
            .and_then(Value::as_array)
            .and_then(|a| a.first())
            .and_then(|h| h.get("command"))
            .and_then(Value::as_str);
        assert_eq!(wc_cmd, Some("/abs/doctrine worktree create-fork"));

        // VT-1 (negative): the retired SubagentStart stamp hook is never emitted.
        assert!(
            event_entries(&json, "SubagentStart").is_none(),
            "no SubagentStart stamp hook (D2 retirement)"
        );

        // Reinstall is idempotent — no duplicate WorktreeCreate entry.
        let again = install_claude_hook(root, &HookSpec::create_fork(exec), false).unwrap();
        assert!(matches!(again, RefreshOutcome::None), "reinstall no-op");
        let json = fs::read_to_string(&settings_path).unwrap();
        assert_eq!(
            event_entries(&json, "WorktreeCreate").unwrap().len(),
            1,
            "no duplicate on reinstall"
        );
    }

    // Drift pin: the create-fork spec wires WorktreeCreate with the cosmetic
    // matcher and the canonical `worktree create-fork` command.
    #[test]
    fn create_fork_spec_shape() {
        let spec = HookSpec::create_fork(Path::new("/abs/doctrine"));
        assert_eq!(spec.matcher, WORKTREE_CREATE_MATCHER);
        assert_eq!(spec.event, "WorktreeCreate");
        assert_eq!(spec.command, "/abs/doctrine worktree create-fork");
    }

    // The ownership predicates are mutually disjoint — none owns another's
    // command, so the entries never clobber one another.
    #[test]
    fn create_fork_ownership_is_disjoint_from_boot_and_sync() {
        assert!(is_doctrine_create_fork_command(
            "/abs/doctrine worktree create-fork"
        ));
        assert!(!is_doctrine_create_fork_command("/abs/doctrine boot"));
        assert!(!is_doctrine_create_fork_command(
            "/abs/doctrine memory sync"
        ));
        assert!(!is_doctrine_boot_command(
            "/abs/doctrine worktree create-fork"
        ));
        assert!(!is_doctrine_sync_command(
            "/abs/doctrine worktree create-fork"
        ));
        // a foreign create-fork-shaped command is not ours.
        assert!(!is_doctrine_create_fork_command(
            "/usr/bin/tool worktree create-fork"
        ));
    }

    // --- SL-124 PHASE-02: merge normalize (Defects A + B-prune) ---

    /// Ordered command list under `hooks.<event>`, array order (execution order).
    fn event_commands(json: &str, event: &str) -> Vec<String> {
        event_entries(json, event)
            .unwrap_or_default()
            .iter()
            .filter_map(|e| e.get("hooks").and_then(Value::as_array))
            .flatten()
            .filter_map(|h| h.get("command").and_then(Value::as_str))
            .map(str::to_string)
            .collect()
    }

    const POISON_CREATE_FORK: &str = "/abs/doctrine (deleted) worktree create-fork";

    // VT-1 (Defect A): a clean create-fork command under the WRONG matcher is
    // healed — the surviving entry's matcher becomes the cosmetic WorktreeCreate
    // matcher, the command is preserved. Reinstall is a no-op.
    #[test]
    fn vt1_stale_matcher_is_healed() {
        let exec = Path::new("/abs/doctrine");
        let spec = HookSpec::create_fork(exec);
        let seed = serde_json::to_string(&serde_json::json!({
            "hooks": { "WorktreeCreate": [
                { "matcher": "old-matcher",
                  "hooks": [ { "type": "command", "command": create_fork_command(exec) } ] }
            ]}
        }))
        .unwrap();

        let plan = plan_hook(Some(&seed), &spec);
        assert!(matches!(plan.outcome, RefreshOutcome::Refreshed(_)));
        let json = plan.new_json.unwrap();
        let wc = event_entries(&json, "WorktreeCreate").unwrap();
        assert_eq!(wc.len(), 1, "one canonical entry");
        assert_eq!(
            wc[0].get("matcher").and_then(Value::as_str),
            Some(WORKTREE_CREATE_MATCHER),
            "matcher healed to the cosmetic WorktreeCreate matcher"
        );
        assert_eq!(
            event_commands(&json, "WorktreeCreate"),
            vec![create_fork_command(exec)],
            "command preserved"
        );
        assert!(matches!(
            plan_hook(Some(&json), &spec).outcome,
            RefreshOutcome::None
        ));
    }

    // VT-2: three owned create-fork entries (two `(deleted)`-poisoned, one clean)
    // under assorted matchers converge to one canonical entry; the rest are gone.
    #[test]
    fn vt2_three_owned_entries_converge_to_one() {
        let exec = Path::new("/abs/doctrine");
        let spec = HookSpec::create_fork(exec);
        let seed = serde_json::to_string(&serde_json::json!({
            "hooks": { "WorktreeCreate": [
                { "matcher": "m0", "hooks": [ { "type": "command", "command": POISON_CREATE_FORK } ] },
                { "matcher": "m1", "hooks": [ { "type": "command", "command": create_fork_command(exec) } ] },
                { "matcher": "m2", "hooks": [ { "type": "command", "command": POISON_CREATE_FORK } ] }
            ]}
        }))
        .unwrap();

        let plan = plan_hook(Some(&seed), &spec);
        assert!(matches!(plan.outcome, RefreshOutcome::Refreshed(_)));
        let json = plan.new_json.unwrap();
        assert_eq!(
            event_commands(&json, "WorktreeCreate"),
            vec![create_fork_command(exec)],
            "collapsed to one clean canonical entry"
        );
        assert_eq!(
            event_entries(&json, "WorktreeCreate").unwrap()[0]
                .get("matcher")
                .and_then(Value::as_str),
            Some(WORKTREE_CREATE_MATCHER)
        );
        assert!(matches!(
            plan_hook(Some(&json), &spec).outcome,
            RefreshOutcome::None
        ));
    }

    // VT-3: a single poisoned create-fork entry is normalized to one clean canonical entry.
    #[test]
    fn vt3_single_poisoned_entry_normalized() {
        let exec = Path::new("/abs/doctrine");
        let spec = HookSpec::create_fork(exec);
        let seed = serde_json::to_string(&serde_json::json!({
            "hooks": { "WorktreeCreate": [
                { "matcher": WORKTREE_CREATE_MATCHER,
                  "hooks": [ { "type": "command", "command": POISON_CREATE_FORK } ] }
            ]}
        }))
        .unwrap();

        let plan = plan_hook(Some(&seed), &spec);
        assert!(matches!(plan.outcome, RefreshOutcome::Refreshed(_)));
        let json = plan.new_json.unwrap();
        assert_eq!(
            event_commands(&json, "WorktreeCreate"),
            vec![create_fork_command(exec)],
            "poison stripped to clean canonical command"
        );
        assert!(matches!(
            plan_hook(Some(&json), &spec).outcome,
            RefreshOutcome::None
        ));
    }

    // VT-4 (codex round-4 shape d): two owned create-fork hooks in ONE entry,
    // followed by a separate foreign entry → the all-owned entry is removed and the
    // fresh canonical entry inserts at `first` (NOT first+1); the foreign entry
    // keeps its position. Survival is `entry_has_foreign_hook`, not hook count.
    #[test]
    fn vt4_all_owned_entry_removed_inserts_at_first() {
        let exec = Path::new("/abs/doctrine");
        let spec = HookSpec::create_fork(exec);
        let seed = serde_json::to_string(&serde_json::json!({
            "hooks": { "WorktreeCreate": [
                { "matcher": "m", "hooks": [
                    { "type": "command", "command": create_fork_command(exec) },
                    { "type": "command", "command": POISON_CREATE_FORK }
                ] },
                { "matcher": "fm", "hooks": [ { "type": "command", "command": "/usr/bin/foreign wc" } ] }
            ]}
        }))
        .unwrap();

        let plan = plan_hook(Some(&seed), &spec);
        assert!(matches!(plan.outcome, RefreshOutcome::Refreshed(_)));
        let json = plan.new_json.unwrap();
        assert_eq!(
            event_commands(&json, "WorktreeCreate"),
            vec![create_fork_command(exec), "/usr/bin/foreign wc".to_string()],
            "fresh canonical at first, foreign entry after"
        );
        let wc = event_entries(&json, "WorktreeCreate").unwrap();
        assert_eq!(wc.len(), 2);
        assert_eq!(
            wc[0].get("matcher").and_then(Value::as_str),
            Some(WORKTREE_CREATE_MATCHER),
            "canonical inserted at first"
        );
        assert_eq!(
            wc[1].get("matcher").and_then(Value::as_str),
            Some("fm"),
            "foreign entry keeps its position"
        );
        assert!(matches!(
            plan_hook(Some(&json), &spec).outcome,
            RefreshOutcome::None
        ));
    }

    // VT-5 / VT-6 (D4 non-clobber + order): an owned create-fork hook sharing an
    // entry with a foreign sibling → the owned hook is extracted into a fresh entry
    // inserted AFTER the retained remnant; the foreign hook, the entry-level
    // matcher, and unknown keys all survive. A foreign hook listed BEFORE the
    // doctrine hook still executes before the doctrine entry (ordered assertion).
    #[test]
    fn vt5_foreign_sibling_preserved_doctrine_extracted_after() {
        let exec = Path::new("/abs/doctrine");
        let spec = HookSpec::create_fork(exec);
        let seed = serde_json::to_string(&serde_json::json!({
            "hooks": { "WorktreeCreate": [
                { "matcher": "shared", "customKey": "keep-me", "hooks": [
                    { "type": "command", "command": "/usr/bin/foreign wc" },
                    { "type": "command", "command": create_fork_command(exec) }
                ] }
            ]}
        }))
        .unwrap();

        let plan = plan_hook(Some(&seed), &spec);
        assert!(matches!(plan.outcome, RefreshOutcome::Refreshed(_)));
        let json = plan.new_json.unwrap();
        let wc = event_entries(&json, "WorktreeCreate").unwrap();
        assert_eq!(wc.len(), 2, "remnant + fresh doctrine entry");
        assert_eq!(
            wc[0].get("matcher").and_then(Value::as_str),
            Some("shared"),
            "foreign entry-level matcher preserved"
        );
        assert_eq!(
            wc[0].get("customKey").and_then(Value::as_str),
            Some("keep-me"),
            "unknown entry-level key preserved"
        );
        assert_eq!(
            event_commands(&json, "WorktreeCreate"),
            vec!["/usr/bin/foreign wc".to_string(), create_fork_command(exec)],
            "foreign executes before the extracted doctrine entry (order)"
        );
        assert_eq!(
            wc[1].get("matcher").and_then(Value::as_str),
            Some(WORKTREE_CREATE_MATCHER),
            "doctrine survivor is canonical + sole"
        );
        assert!(matches!(
            plan_hook(Some(&json), &spec).outcome,
            RefreshOutcome::None
        ));
    }

    /// The foreign-sibling shared-entry case for an arbitrary `SessionStart` spec —
    /// proves the shared merge core has no event-specific behaviour (VT-7).
    fn assert_session_foreign_sibling_extraction(spec: &HookSpec) {
        assert_eq!(spec.event, "SessionStart");
        let foreign = "/usr/bin/foreign hook";
        let seed = serde_json::to_string(&serde_json::json!({
            "hooks": { "SessionStart": [
                { "matcher": "shared", "keepKey": "v", "hooks": [
                    { "type": "command", "command": foreign },
                    { "type": "command", "command": spec.command }
                ] }
            ]}
        }))
        .unwrap();

        let plan = plan_hook(Some(&seed), spec);
        assert!(matches!(plan.outcome, RefreshOutcome::Refreshed(_)));
        let json = plan.new_json.unwrap();
        assert_eq!(
            commands(&json),
            vec![foreign.to_string(), spec.command.clone()],
            "foreign preserved before the extracted doctrine entry"
        );
        assert_eq!(
            session_entries(&json)[0]
                .get("keepKey")
                .and_then(Value::as_str),
            Some("v"),
            "unknown entry-level key preserved"
        );
        assert!(matches!(
            plan_hook(Some(&json), spec).outcome,
            RefreshOutcome::None
        ));
    }

    // VT-7: the shared-core foreign-sibling extraction holds for boot AND sync, not
    // stamp only — the merge core acquired no event-specific behaviour.
    #[test]
    fn vt7_foreign_sibling_extraction_holds_for_boot_and_sync() {
        let exec = Path::new("/abs/doctrine");
        assert_session_foreign_sibling_extraction(&HookSpec::boot(exec));
        assert_session_foreign_sibling_extraction(&HookSpec::sync(exec));
    }

    // Order preservation (design § codex M-2): a stale `boot` entry positioned
    // BEFORE a `sync` entry stays before `sync` after refresh (insert at the first
    // owned slot, not append-at-tail). Ordered `commands()` assertion.
    #[test]
    fn boot_refresh_keeps_position_before_sync() {
        let old_exec = Path::new("/old/doctrine");
        let new_exec = Path::new("/abs/doctrine");
        let seed = serde_json::to_string(&serde_json::json!({
            "hooks": { "SessionStart": [
                { "matcher": SESSION_MATCHER,
                  "hooks": [ { "type": "command", "command": boot_command(old_exec) } ] },
                { "matcher": SESSION_MATCHER,
                  "hooks": [ { "type": "command", "command": sync_command(new_exec) } ] }
            ]}
        }))
        .unwrap();

        let plan = plan_hook(Some(&seed), &HookSpec::boot(new_exec));
        assert!(matches!(plan.outcome, RefreshOutcome::Refreshed(_)));
        let json = plan.new_json.unwrap();
        assert_eq!(
            commands(&json),
            vec![boot_command(new_exec), sync_command(new_exec),],
            "refreshed boot stays before sync"
        );
    }

    // =======================================================================
    // PHASE-02 — append-system symlink
    // =======================================================================

    #[test]
    fn plan_append_system_dir_absent_is_create_dir_and_link() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        // .pi/ does not exist
        assert!(matches!(
            plan_append_system(root),
            SymlinkAction::CreateDirAndLink
        ));
    }

    #[test]
    fn plan_append_system_file_absent_is_create_link() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        fs::create_dir(root.join(".pi")).unwrap();
        assert!(matches!(
            plan_append_system(root),
            SymlinkAction::CreateLink
        ));
    }

    #[test]
    fn plan_append_system_correct_symlink_is_noop() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let pi_dir = root.join(".pi");
        fs::create_dir(&pi_dir).unwrap();
        let target = "../.doctrine/state/boot.md";
        unix_fs::symlink(target, pi_dir.join("APPEND_SYSTEM.md")).unwrap();
        assert!(matches!(plan_append_system(root), SymlinkAction::NoOp));
    }

    #[test]
    fn plan_append_system_regular_file_is_replace_link() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let pi_dir = root.join(".pi");
        fs::create_dir(&pi_dir).unwrap();
        fs::write(pi_dir.join("APPEND_SYSTEM.md"), "not a symlink").unwrap();
        assert!(matches!(
            plan_append_system(root),
            SymlinkAction::ReplaceLink
        ));
    }

    #[test]
    fn plan_append_system_wrong_target_symlink_is_replace_link() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let pi_dir = root.join(".pi");
        fs::create_dir(&pi_dir).unwrap();
        unix_fs::symlink(
            "../../.doctrine/state/boot_old.md",
            pi_dir.join("APPEND_SYSTEM.md"),
        )
        .unwrap();
        assert!(matches!(
            plan_append_system(root),
            SymlinkAction::ReplaceLink
        ));
    }

    #[test]
    fn install_append_system_creates_dir_and_link() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let outcome = install_append_system(root, false).unwrap();
        assert_eq!(outcome, AppendSystemOutcome::CreatedDirAndLink);
        let link = root.join(".pi/APPEND_SYSTEM.md");
        // symlink_metadata, not link.exists(): the target may not exist yet.
        assert!(fs::symlink_metadata(&link).is_ok());
        assert!(
            fs::symlink_metadata(&link)
                .unwrap()
                .file_type()
                .is_symlink()
        );
        assert_eq!(
            fs::read_link(&link).unwrap(),
            Path::new("../.doctrine/state/boot.md")
        );
    }

    #[test]
    fn install_append_system_idempotent_noop() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        // first install
        let outcome = install_append_system(root, false).unwrap();
        assert_eq!(outcome, AppendSystemOutcome::CreatedDirAndLink);
        // second install is NoOp
        let outcome = install_append_system(root, false).unwrap();
        assert_eq!(outcome, AppendSystemOutcome::NoOp);
    }

    #[test]
    fn install_append_system_dry_run_reports_without_touching_disk() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let outcome = install_append_system(root, true).unwrap();
        assert_eq!(outcome, AppendSystemOutcome::CreatedDirAndLink);
        assert!(!root.join(".pi").exists());
    }

    // --- T4: `wire` orchestration (integration, tempdir). Exec is injected with
    //     a `doctrine` file name — the real verb resolves `current_exe()`, but the
    //     test binary is named `doctrine-<hash>`, which the ownership match would
    //     (correctly) not recognise as ours (mirrors PHASE-01 testing `regenerate`,
    //     not `run`). ---

    const FAKE_EXEC: &str = "/fake/doctrine";

    #[test]
    fn wire_adds_import_and_hook_then_is_idempotent() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new(FAKE_EXEC);

        wire(root, exec, &[Harness::Claude], false).unwrap();

        let claude_md = fs::read_to_string(root.join("CLAUDE.md")).unwrap();
        assert_eq!(claude_md.matches(REF).count(), 1, "import ref wired once");
        // SL-152 PHASE-06: the Claude boot hook ships via the plugin — `wire` no
        // longer settings-wires it. Only the baseRef key lands in settings.
        let settings = fs::read_to_string(root.join(SETTINGS_REL)).unwrap();
        assert!(
            commands(&settings).is_empty(),
            "no boot hook settings-wired for Claude (ships via plugin): {settings}"
        );
        let parsed: Value = serde_json::from_str(&settings).unwrap();
        assert_eq!(parsed["worktree"]["baseRef"], Value::String("head".into()));

        // re-run: import Present, still no hook, baseRef idempotent.
        wire(root, exec, &[Harness::Claude], false).unwrap();
        let claude_md = fs::read_to_string(root.join("CLAUDE.md")).unwrap();
        assert_eq!(
            claude_md.matches(REF).count(),
            1,
            "re-run does not duplicate ref"
        );
        let settings = fs::read_to_string(root.join(SETTINGS_REL)).unwrap();
        assert!(
            commands(&settings).is_empty(),
            "re-run still wires no boot hook for Claude"
        );
    }

    #[test]
    fn wire_dry_run_mutates_nothing() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();

        wire(root, Path::new(FAKE_EXEC), &[Harness::Claude], true).unwrap();
        assert!(!root.join("CLAUDE.md").exists(), "dry-run wrote no import");
        assert!(
            !root.join(SETTINGS_REL).exists(),
            "dry-run wrote no settings"
        );
    }

    #[test]
    fn wire_isolates_one_harness_failure() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();

        // force Claude's refresh to fail: a directory squatting the settings path
        // makes write_atomic's rename fail.
        fs::create_dir_all(root.join(SETTINGS_REL)).unwrap();

        // both harnesses; Claude refresh errs, pi import must still be wired and
        // the verb must not abort (A9).
        wire(
            root,
            Path::new(FAKE_EXEC),
            &[Harness::Claude, Harness::Codex],
            false,
        )
        .unwrap();

        let agents = fs::read_to_string(root.join("AGENTS.md")).unwrap();
        assert_eq!(
            agents.matches(REF).count(),
            1,
            "pi import survived the claude failure"
        );
    }

    // --- SL-087: produce(Memory) with active signpost memories ---

    fn write_memory_toml(items: &Path, uid: &str, kind: &str, st: &str, key: Option<&str>) {
        let key_line = key
            .map(|k| format!("memory_key = \"{k}\"\n"))
            .unwrap_or_default();
        let toml = format!(
            r#"memory_uid = "{uid}"
{key_line}schema_version = 1
memory_type = "{kind}"
status = "{st}"
title = "Test"
summary = ""
created = "2026-06-04"
updated = "2026-06-04"

[scope]
paths = []
globs = []
commands = []
tags = []
workspace = "default"
repo = ""

[git]
anchor_kind = "none"

[review]
verification_state = "unverified"

[trust]
trust_level = "medium"

[ranking]
severity = "low"
weight = 0
"#
        );
        let dir = items.join(uid);
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("memory.toml"), toml).unwrap();
        fs::write(dir.join("memory.md"), "").unwrap();
    }

    #[test]
    fn produce_memory_renders_reference_and_key_list() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let items = root.join(memory::MEMORY_ITEMS_DIR);
        fs::create_dir_all(&items).unwrap();

        let a = "mem_000000000000000000000000000000a1";
        let b = "mem_000000000000000000000000000000b2";
        let c = "mem_000000000000000000000000000000c3";
        let d = "mem_000000000000000000000000000000d4";
        let e = "mem_000000000000000000000000000000e5";
        let f = "mem_000000000000000000000000000000f6";

        write_memory_toml(&items, c, "signpost", "active", Some("mem.charlie"));
        write_memory_toml(&items, a, "signpost", "active", Some("mem.alpha"));
        write_memory_toml(&items, b, "signpost", "active", Some("mem.bravo"));
        write_memory_toml(&items, d, "signpost", "active", None);
        write_memory_toml(&items, e, "signpost", "draft", Some("mem.draft"));
        write_memory_toml(&items, f, "pattern", "active", Some("mem.pattern"));

        let exec = Path::new("/abs/target/debug/doctrine");
        let section = produce("Memory", &SourceKind::Memories, root, exec, noop_map);

        assert!(
            section.body.contains("Run /retrieve-memory"),
            "ref: {}",
            section.body
        );
        assert!(
            section.body.contains("mem.alpha"),
            "alpha: {}",
            section.body
        );
        assert!(
            section.body.contains("mem.bravo"),
            "bravo: {}",
            section.body
        );
        assert!(
            section.body.contains("mem.charlie"),
            "charlie: {}",
            section.body
        );
        assert!(section.body.contains(d), "keyless: {}", section.body);
        assert!(
            !section.body.contains("mem.draft"),
            "draft: {}",
            section.body
        );
        assert!(
            !section.body.contains("mem.pattern"),
            "pattern: {}",
            section.body
        );

        let ref_pos = section.body.find("Run /retrieve-memory").unwrap();
        let alpha_pos = section.body.find("mem.alpha").unwrap();
        let bravo_pos = section.body.find("mem.bravo").unwrap();
        let charlie_pos = section.body.find("mem.charlie").unwrap();
        assert!(
            ref_pos < alpha_pos && alpha_pos < bravo_pos && bravo_pos < charlie_pos,
            "sort: {}",
            section.body
        );
    }

    // =======================================================================
    // PHASE-03 — pi session_start extension
    // =======================================================================

    fn write_pi_ext_file(root: &Path, content: &str) -> PathBuf {
        let ext_path = root
            .join(".pi")
            .join("extensions")
            .join("doctrine")
            .join("index.ts");
        fs::create_dir_all(ext_path.parent().unwrap()).unwrap();
        fs::write(&ext_path, content).unwrap();
        ext_path
    }

    #[test]
    fn plan_pi_extension_no_file_is_generate() {
        let dir = tempfile::tempdir().unwrap();
        assert!(matches!(
            plan_pi_extension(dir.path(), Path::new("/abs/doctrine")),
            ExtAction::Generate
        ));
    }

    #[test]
    fn plan_pi_extension_identical_content_is_noop() {
        let dir = tempfile::tempdir().unwrap();
        let exec = Path::new("/abs/doctrine");
        let content = generate_pi_extension(exec);
        write_pi_ext_file(dir.path(), &content);
        assert!(matches!(
            plan_pi_extension(dir.path(), exec),
            ExtAction::NoOp
        ));
    }

    #[test]
    fn plan_pi_extension_different_exec_path_is_regenerate() {
        let dir = tempfile::tempdir().unwrap();
        let old_exec = Path::new("/old/doctrine");
        let new_exec = Path::new("/new/doctrine");
        let content = generate_pi_extension(old_exec);
        write_pi_ext_file(dir.path(), &content);
        assert!(matches!(
            plan_pi_extension(dir.path(), new_exec),
            ExtAction::Regenerate
        ));
    }

    #[test]
    fn plan_pi_extension_foreign_file_is_skip() {
        let dir = tempfile::tempdir().unwrap();
        write_pi_ext_file(
            dir.path(),
            "// hand-authored extension\nmodule.exports = function(pi) { pi.on('session_start', () => console.log('hi')); };\n",
        );
        assert!(matches!(
            plan_pi_extension(dir.path(), Path::new("/abs/doctrine")),
            ExtAction::SkipForeign
        ));
    }

    #[test]
    fn plan_pi_extension_missing_dir_is_generate() {
        let dir = tempfile::tempdir().unwrap();
        // .pi/ doesn't exist at all.
        assert!(!dir.path().join(".pi").exists());
        assert!(matches!(
            plan_pi_extension(dir.path(), Path::new("/abs/doctrine")),
            ExtAction::Generate
        ));
    }

    #[test]
    fn install_pi_extension_generates_file() {
        let dir = tempfile::tempdir().unwrap();
        let exec = Path::new("/abs/doctrine");
        let outcome = install_pi_extension(dir.path(), exec, false).unwrap();
        assert_eq!(outcome, ExtOutcome::Generated);
        let ext_path = dir
            .path()
            .join(".pi")
            .join("extensions")
            .join("doctrine")
            .join("index.ts");
        assert!(ext_path.exists());
        let generated = fs::read_to_string(&ext_path).unwrap();
        assert!(generated.starts_with(PI_EXT_HEADER));
        // VT-1 (SL-187 PHASE-04): the extension execs `prompt resolve --role
        // orchestrator`, NOT the legacy `boot --emit`.
        assert!(generated.contains("/abs/doctrine prompt resolve --role orchestrator"));
        assert!(!generated.contains("boot --emit"));
        assert!(generated.contains("await import(\"./mcp.ts\")"));
        assert!(generated.contains("// MCP bridge extension not available"));
    }

    #[test]
    fn install_pi_extension_idempotent_noop() {
        let dir = tempfile::tempdir().unwrap();
        let exec = Path::new("/abs/doctrine");
        // First install generates.
        let outcome = install_pi_extension(dir.path(), exec, false).unwrap();
        assert_eq!(outcome, ExtOutcome::Generated);
        // Second install is NoOp.
        let outcome = install_pi_extension(dir.path(), exec, false).unwrap();
        assert_eq!(outcome, ExtOutcome::NoOp);
    }

    #[test]
    fn install_pi_extension_dry_run_reports_without_touching_disk() {
        let dir = tempfile::tempdir().unwrap();
        let exec = Path::new("/abs/doctrine");
        let outcome = install_pi_extension(dir.path(), exec, true).unwrap();
        assert_eq!(outcome, ExtOutcome::Generated);
        assert!(!dir.path().join(".pi").exists());
    }

    #[test]
    fn install_pi_extension_skips_foreign() {
        let dir = tempfile::tempdir().unwrap();
        let foreign_content = "// hand-authored extension\nmodule.exports = function(pi) {};\n";
        write_pi_ext_file(dir.path(), foreign_content);
        let exec = Path::new("/abs/doctrine");
        let outcome = install_pi_extension(dir.path(), exec, false).unwrap();
        assert_eq!(outcome, ExtOutcome::SkippedForeign);
        // File untouched.
        let ext_path = dir
            .path()
            .join(".pi")
            .join("extensions")
            .join("doctrine")
            .join("index.ts");
        assert_eq!(fs::read_to_string(&ext_path).unwrap(), foreign_content);
    }

    #[test]
    fn generated_extension_roundtrip_is_noop() {
        let dir = tempfile::tempdir().unwrap();
        let exec = Path::new("/abs/doctrine");
        let content = generate_pi_extension(exec);
        write_pi_ext_file(dir.path(), &content);
        // Re-planning a freshly-written generated file is NoOp.
        assert!(matches!(
            plan_pi_extension(dir.path(), exec),
            ExtAction::NoOp
        ));
    }

    // =======================================================================
    // SL-120 — MCP extension generation
    // =======================================================================

    fn write_mcp_ext_file(root: &Path, content: &str) -> PathBuf {
        let ext_path = root
            .join(".pi")
            .join("extensions")
            .join("doctrine")
            .join("mcp.ts");
        fs::create_dir_all(ext_path.parent().unwrap()).unwrap();
        fs::write(&ext_path, content).unwrap();
        ext_path
    }

    // --- Unit: generation ---

    #[test]
    fn generate_mcp_extension_bakes_exec_path() {
        let exec = Path::new("/abs/doctrine");
        let content = generate_mcp_extension(exec);
        assert!(
            content.starts_with(PI_EXT_HEADER),
            "generated MCP extension must start with the generated header"
        );
        assert!(
            content.contains("const BIN_PATH = \"/abs/doctrine\";"),
            "BIN_PATH must be baked as const, got: {}",
            content
        );
        assert!(
            !content.contains("declare const BIN_PATH"),
            "declare placeholder must be replaced"
        );
    }

    // --- Unit: plan ---

    #[test]
    fn plan_mcp_extension_no_file_is_generate() {
        let dir = tempfile::tempdir().unwrap();
        assert!(matches!(
            plan_mcp_extension(dir.path(), Path::new("/abs/doctrine")),
            ExtAction::Generate
        ));
    }

    #[test]
    fn plan_mcp_extension_identical_content_is_noop() {
        let dir = tempfile::tempdir().unwrap();
        let exec = Path::new("/abs/doctrine");
        let content = generate_mcp_extension(exec);
        write_mcp_ext_file(dir.path(), &content);
        assert!(matches!(
            plan_mcp_extension(dir.path(), exec),
            ExtAction::NoOp
        ));
    }

    #[test]
    fn plan_mcp_extension_different_exec_path_is_regenerate() {
        let dir = tempfile::tempdir().unwrap();
        let old_exec = Path::new("/old/doctrine");
        let new_exec = Path::new("/new/doctrine");
        let content = generate_mcp_extension(old_exec);
        write_mcp_ext_file(dir.path(), &content);
        assert!(matches!(
            plan_mcp_extension(dir.path(), new_exec),
            ExtAction::Regenerate
        ));
    }

    #[test]
    fn plan_mcp_extension_foreign_file_is_skip() {
        let dir = tempfile::tempdir().unwrap();
        write_mcp_ext_file(
            dir.path(),
            "// hand-authored extension\nimport { spawn } from 'child_process';\n",
        );
        assert!(matches!(
            plan_mcp_extension(dir.path(), Path::new("/abs/doctrine")),
            ExtAction::SkipForeign
        ));
    }

    #[test]
    fn plan_mcp_extension_missing_dir_is_generate() {
        let dir = tempfile::tempdir().unwrap();
        // .pi/ doesn't exist at all.
        assert!(!dir.path().join(".pi").exists());
        assert!(matches!(
            plan_mcp_extension(dir.path(), Path::new("/abs/doctrine")),
            ExtAction::Generate
        ));
    }

    // --- Unit: install ---

    #[test]
    fn install_mcp_extension_generates_file() {
        let dir = tempfile::tempdir().unwrap();
        let exec = Path::new("/abs/doctrine");
        let outcome = install_mcp_extension(dir.path(), exec, false).unwrap();
        assert_eq!(outcome, ExtOutcome::Generated);
        let ext_path = dir
            .path()
            .join(".pi")
            .join("extensions")
            .join("doctrine")
            .join("mcp.ts");
        assert!(ext_path.exists());
        let generated = fs::read_to_string(&ext_path).unwrap();
        assert!(generated.starts_with(PI_EXT_HEADER));
        assert!(generated.contains("const BIN_PATH = \"/abs/doctrine\";"));
    }

    #[test]
    fn install_mcp_extension_idempotent_noop() {
        let dir = tempfile::tempdir().unwrap();
        let exec = Path::new("/abs/doctrine");
        // First install generates.
        let outcome = install_mcp_extension(dir.path(), exec, false).unwrap();
        assert_eq!(outcome, ExtOutcome::Generated);
        // Second install is NoOp.
        let outcome = install_mcp_extension(dir.path(), exec, false).unwrap();
        assert_eq!(outcome, ExtOutcome::NoOp);
    }

    #[test]
    fn install_mcp_extension_dry_run_reports_without_touching_disk() {
        let dir = tempfile::tempdir().unwrap();
        let exec = Path::new("/abs/doctrine");
        let outcome = install_mcp_extension(dir.path(), exec, true).unwrap();
        assert_eq!(outcome, ExtOutcome::Generated);
        assert!(!dir.path().join(".pi").exists());
    }

    #[test]
    fn install_mcp_extension_skips_foreign() {
        let dir = tempfile::tempdir().unwrap();
        let foreign_content =
            "// hand-authored extension\nimport { spawn } from 'child_process';\n";
        write_mcp_ext_file(dir.path(), foreign_content);
        let exec = Path::new("/abs/doctrine");
        let outcome = install_mcp_extension(dir.path(), exec, false).unwrap();
        assert_eq!(outcome, ExtOutcome::SkippedForeign);
        // File untouched.
        let ext_path = dir
            .path()
            .join(".pi")
            .join("extensions")
            .join("doctrine")
            .join("mcp.ts");
        assert_eq!(fs::read_to_string(&ext_path).unwrap(), foreign_content);
    }

    #[test]
    fn install_mcp_extension_creates_parent_dirs() {
        let dir = tempfile::tempdir().unwrap();
        let exec = Path::new("/abs/doctrine");
        // No .pi/ tree exists.
        assert!(!dir.path().join(".pi").exists());
        let outcome = install_mcp_extension(dir.path(), exec, false).unwrap();
        assert_eq!(outcome, ExtOutcome::Generated);
        // Parent dirs created.
        let ext_path = dir
            .path()
            .join(".pi")
            .join("extensions")
            .join("doctrine")
            .join("mcp.ts");
        assert!(ext_path.exists());
    }

    // --- Integration: install_refresh pi arm includes mcp_extension outcome ---

    #[test]
    fn install_refresh_pi_generates_mcp_extension() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/doctrine");

        let out = install_refresh(&Harness::Codex, root, exec, false).unwrap();
        assert_eq!(out.mcp_extension, ExtOutcome::Generated);
        assert!(
            root.join(".pi")
                .join("extensions")
                .join("doctrine")
                .join("mcp.ts")
                .exists()
        );
    }

    #[test]
    fn install_refresh_pi_idempotent_mcp_extension_noop() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/doctrine");

        // First run generates.
        let out1 = install_refresh(&Harness::Codex, root, exec, false).unwrap();
        assert_eq!(out1.mcp_extension, ExtOutcome::Generated);

        // Second run is no-op.
        let out2 = install_refresh(&Harness::Codex, root, exec, false).unwrap();
        assert_eq!(out2.mcp_extension, ExtOutcome::NoOp);
    }

    #[test]
    fn install_refresh_claude_carries_not_applicable_for_mcp_extension() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let exec = Path::new("/abs/doctrine");

        let out = install_refresh(&Harness::Claude, root, exec, false).unwrap();
        assert_eq!(out.mcp_extension, ExtOutcome::NotApplicable);
    }

    // --- VT-2: build_and_render determinism ---

    #[test]
    fn build_and_render_is_deterministic() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let exec = root.join("doctrine");
        std::fs::create_dir_all(root.join(".doctrine")).unwrap();
        std::fs::write(root.join("doctrine"), b"fake-exe").unwrap();
        let result1 = build_and_render(root, &exec, noop_map);
        let result2 = build_and_render(root, &exec, noop_map);
        assert_eq!(result1, result2);
    }

    #[test]
    fn regenerate_writes_same_bytes_as_build_and_render() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let exec = root.join("doctrine");
        std::fs::create_dir_all(root.join(".doctrine")).unwrap();
        std::fs::write(root.join("doctrine"), b"fake-exe").unwrap();
        regenerate(root, &exec, noop_map).unwrap();
        let on_disk = std::fs::read_to_string(root.join(".doctrine/state/boot.md")).unwrap();
        let built = build_and_render(root, &exec, noop_map);
        assert_eq!(on_disk, built);
    }

    #[test]
    fn resolve_universal_snapshot_is_axis_invariant_and_idempotent_on_disk() {
        // VT-2 (SL-187 PHASE-04) — disk half. `prompt resolve`'s disk seam takes
        // NO role/harness/model axis, so the on-disk boot.md is byte-identical
        // across calls (axis-invariance INV-D1) and idempotent under repetition
        // (self-healing INV-D2); it equals the shared generator (no second projector).
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let exec = root.join("doctrine");
        std::fs::create_dir_all(root.join(".doctrine")).unwrap();
        std::fs::write(&exec, b"fake-exe").unwrap();

        let first = resolve_universal_snapshot(root, &exec, noop_map).unwrap();
        let disk1 = std::fs::read_to_string(root.join(BOOT_REL)).unwrap();
        let second = resolve_universal_snapshot(root, &exec, noop_map).unwrap();
        let disk2 = std::fs::read_to_string(root.join(BOOT_REL)).unwrap();

        assert_eq!(
            first, second,
            "returned content is axis-invariant/idempotent"
        );
        assert_eq!(
            disk1, disk2,
            "on-disk boot.md is byte-identical across calls"
        );
        assert_eq!(first, disk1, "returned content is the on-disk content");
        assert_eq!(
            first,
            build_and_render(root, &exec, noop_map),
            "resolve reuses the shared generator, not a second projector"
        );
    }
    // =======================================================================
    // SL-014 PHASE-02 — codex SessionStart hook install + trust
    // =======================================================================

    fn codex_exec(root: &Path) -> PathBuf {
        let exec = root.join("doctrine");
        std::fs::create_dir_all(root.join(".doctrine")).unwrap();
        std::fs::write(&exec, b"fake").unwrap();
        exec
    }

    #[test]
    fn codex_hook_install_writes_expected_entry() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let exec = codex_exec(root);
        let out = install_codex_hook(root, &HookSpec::boot_emit(&exec), false).unwrap();
        assert!(matches!(out, RefreshOutcome::Wired(_)));
        let raw = std::fs::read_to_string(root.join(CODE_HOOKS_REL)).unwrap();
        let val: Value = serde_json::from_str(&raw).unwrap();
        let arr = val["hooks"]["SessionStart"].as_array().unwrap();
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0]["matcher"], SESSION_MATCHER_CODEX);
        let hooks = arr[0]["hooks"].as_array().unwrap();
        assert_eq!(hooks.len(), 1);
        assert_eq!(
            hooks[0]["command"],
            format!("{} prompt resolve --role orchestrator", exec.display())
        );
    }

    #[test]
    fn codex_hook_install_idempotent() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let exec = codex_exec(root);
        let out1 = install_codex_hook(root, &HookSpec::boot_emit(&exec), false).unwrap();
        assert!(matches!(out1, RefreshOutcome::Wired(_)));
        let out2 = install_codex_hook(root, &HookSpec::boot_emit(&exec), false).unwrap();
        assert!(matches!(out2, RefreshOutcome::None));
    }

    #[test]
    fn codex_hook_preserves_foreign_entry() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let exec = codex_exec(root);
        std::fs::create_dir_all(root.join(".codex")).unwrap();
        let foreign = serde_json::json!({
            "hooks": {
                "SessionStart": [{
                    "matcher": "startup",
                    "hooks": [{"type": "command", "command": "/usr/bin/other"}]
                }]
            }
        });
        std::fs::write(
            root.join(CODE_HOOKS_REL),
            serde_json::to_string_pretty(&foreign).unwrap(),
        )
        .unwrap();
        install_codex_hook(root, &HookSpec::boot_emit(&exec), false).unwrap();
        let raw = std::fs::read_to_string(root.join(CODE_HOOKS_REL)).unwrap();
        let val: Value = serde_json::from_str(&raw).unwrap();
        let arr = val["hooks"]["SessionStart"].as_array().unwrap();
        assert_eq!(arr.len(), 2);
    }

    #[test]
    fn codex_hook_malformed_json_printed_fallback() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let exec = codex_exec(root);
        std::fs::create_dir_all(root.join(".codex")).unwrap();
        std::fs::write(root.join(CODE_HOOKS_REL), "not json").unwrap();
        let out = install_codex_hook(root, &HookSpec::boot_emit(&exec), false).unwrap();
        match out {
            RefreshOutcome::PrintedFallback { hook_file, snippet } => {
                assert_eq!(hook_file, CODE_HOOKS_REL);
                assert!(!snippet.is_empty());
            }
            _ => panic!("expected PrintedFallback"),
        }
    }

    #[test]
    fn codex_hook_stale_exec_path_refreshed() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        std::fs::create_dir_all(root.join(".doctrine")).unwrap();
        std::fs::create_dir_all(root.join(".codex")).unwrap();
        // Use old and new subdirectories so the file name stays "doctrine"
        let old_dir = root.join("old");
        let new_dir = root.join("new");
        std::fs::create_dir_all(&old_dir).unwrap();
        std::fs::create_dir_all(&new_dir).unwrap();
        let old_exec = old_dir.join("doctrine");
        let new_exec = new_dir.join("doctrine");
        std::fs::write(&old_exec, b"fake").unwrap();
        std::fs::write(&new_exec, b"fake").unwrap();
        install_codex_hook(root, &HookSpec::boot_emit(&old_exec), false).unwrap();
        let out = install_codex_hook(root, &HookSpec::boot_emit(&new_exec), false).unwrap();
        assert!(matches!(out, RefreshOutcome::Refreshed(_)));
    }

    #[test]
    fn emit_ownership_predicate_full_matrix() {
        // Positive cases — current `prompt resolve --role orchestrator` args
        assert!(is_doctrine_emit_command(
            "/abs/doctrine prompt resolve --role orchestrator"
        ));
        assert!(is_doctrine_emit_command(
            "doctrine prompt resolve --role orchestrator"
        ));
        assert!(is_doctrine_emit_command(
            "/path with spaces/doctrine prompt resolve --role orchestrator"
        ));
        assert!(is_doctrine_emit_command(
            "/abs/doctrine (deleted) prompt resolve --role orchestrator"
        ));
        // Positive cases — legacy `boot --emit` still recognised (self-heal, INV-D4)
        assert!(is_doctrine_emit_command("/abs/doctrine boot --emit"));
        assert!(is_doctrine_emit_command("doctrine boot --emit"));
        assert!(is_doctrine_emit_command(
            "/path with spaces/doctrine boot --emit"
        ));
        assert!(is_doctrine_emit_command(
            "/abs/doctrine (deleted) boot --emit"
        ));
        // Negative cases
        assert!(!is_doctrine_emit_command("/abs/doctrine boot"));
        assert!(!is_doctrine_emit_command(
            "/abs/doctrine boot --emit --verbose"
        ));
        assert!(!is_doctrine_emit_command(
            "/abs/doctrine prompt resolve --role worker"
        ));
        assert!(!is_doctrine_emit_command("sh -c 'doctrine boot --emit'"));
        assert!(!is_doctrine_emit_command("/abs/doctrine memory sync"));
        assert!(!is_doctrine_emit_command(
            "/abs/doctrine worktree marker --stamp-subagent"
        ));
        assert!(!is_doctrine_emit_command("/abs/other-tool boot --emit"));
        assert!(!is_doctrine_emit_command(
            "/abs/other-tool prompt resolve --role orchestrator"
        ));
    }

    #[test]
    fn claude_hook_unchanged_after_dry_refactor() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let exec = root.join("doctrine");
        std::fs::create_dir_all(root.join(".doctrine")).unwrap();
        std::fs::create_dir_all(root.join(".claude")).unwrap();
        std::fs::write(&exec, b"fake").unwrap();
        let out = install_claude_hook(root, &HookSpec::boot(&exec), false).unwrap();
        assert!(matches!(out, RefreshOutcome::Wired(_)));
        let raw = std::fs::read_to_string(root.join(SETTINGS_REL)).unwrap();
        assert!(raw.contains(&format!("{} boot", exec.display())));
    }
}
