//! Pure closeout response and done-signal classification.
//!
//! Session-check owns file/cycle-state IO. This module owns the string-level
//! turn policy that decides whether response text completes a tracked-work id
//! and whether prompt text carries explicit done/resolved signals.

use agent_doc_prompt_lines::text_line_looks_like_prompt_target;

/// Tight list of "blocked / still needs future action" phrases that, combined
/// with a directed id gated this cycle, indicate a closeout reported more agent
/// execution is still needed. Kept narrow so ordinary review prose does not
/// trip closeout guards.
pub const BLOCKED_FUTURE_ACTION_PHRASES: &[&str] = &[
    "remains blocked",
    "still blocked",
    "is blocked",
    "are blocked",
    "blocked on",
    "blocked by",
    "blocked:",
    "blocked until",
    "next step to complete",
    "next steps to complete",
    "steps to complete",
    "cannot complete until",
    "can't complete until",
    "still needs to",
    "still need to",
    "must remove",
    "must delete",
    "must expire",
    "needs to be removed",
    "no live cutover",
    "waiting on approval",
    "awaiting approval",
    "needs approval before",
    "requires approval before",
    "deliberately delete",
    "get approval",
];

/// Explicit "no follow-up needed" justifications that satisfy a blocked-shape
/// closeout for a genuinely review-only gate.
pub const NO_FOLLOWUP_JUSTIFICATION_PHRASES: &[&str] = &[
    "no additional backlog follow-up",
    "no additional follow-up is needed",
    "no follow-up backlog",
    "no further backlog",
    "no actionable backlog follow-up",
    "no remaining backlog work",
];

pub fn text_has_blocked_future_action_signal(lower: &str) -> bool {
    BLOCKED_FUTURE_ACTION_PHRASES
        .iter()
        .any(|phrase| lower.contains(phrase))
}

pub fn text_has_no_followup_justification(lower: &str) -> bool {
    NO_FOLLOWUP_JUSTIFICATION_PHRASES
        .iter()
        .any(|phrase| lower.contains(phrase))
}

/// True when a user-added line is a next-cycle instruction that must be carried
/// forward rather than folded into the current turn's commit.
pub fn line_is_carry_forward_signal(line: &str) -> bool {
    let trimmed = line.trim();
    if trimmed.is_empty() {
        return false;
    }
    let candidate = carry_forward_signal_candidate(trimmed).unwrap_or(trimmed);
    if text_line_looks_like_prompt_target(trimmed) || text_line_looks_like_prompt_target(candidate)
    {
        return true;
    }
    let lower = candidate.to_ascii_lowercase();
    trimmed.starts_with('❯')
        || candidate.ends_with('?')
        || candidate.starts_with('#')
        || candidate.contains(" #")
        || lower.starts_with("[#")
        || lower.starts_with("do #")
        || lower.starts_with("do [#")
        || lower.starts_with("dispatch")
        || lower.starts_with("fix #")
        || lower.contains("spec-test")
        || lower.contains("spec test")
}

fn carry_forward_signal_candidate(line: &str) -> Option<&str> {
    let mut candidate = line.trim();
    if candidate.is_empty() {
        return None;
    }

    candidate = candidate.trim_start_matches('❯').trim_start();

    if let Some(rest) = strip_markdown_list_marker(candidate) {
        candidate = rest.trim_start();
    }
    if let Some(rest) = strip_markdown_checkbox_marker(candidate) {
        candidate = rest.trim_start();
    }
    if let Some(rest) = candidate.strip_prefix(":pushpin:") {
        candidate = rest.trim_start();
    }
    if let Some(rest) = strip_markdown_checkbox_marker(candidate) {
        candidate = rest.trim_start();
    }

    (!candidate.is_empty()).then_some(candidate)
}

fn strip_markdown_list_marker(line: &str) -> Option<&str> {
    line.strip_prefix("- ")
        .or_else(|| line.strip_prefix("* "))
        .or_else(|| line.strip_prefix("+ "))
}

fn strip_markdown_checkbox_marker(line: &str) -> Option<&str> {
    let rest = line.strip_prefix('[')?;
    let rest = rest.strip_prefix(|c: char| c == ' ' || c == 'x' || c == 'X')?;
    rest.strip_prefix("] ")
}

/// True when a blocked / still-needed-work phrase co-occurs with `#id` inside
/// the same paragraph of the response. Paragraph scoping keeps incidental
/// blocked phrases about unrelated work from tying the signal to the directed id.
pub fn blocked_signal_tied_to_id(text: &str, id: &str) -> bool {
    let needle = format!("#{}", id.to_ascii_lowercase());
    text.split("\n\n").any(|paragraph| {
        let lower = paragraph.to_ascii_lowercase();
        lower.contains(&needle) && text_has_blocked_future_action_signal(&lower)
    })
}

/// True when a kept-open item body enumerates multiple gated/remaining phases:
/// the word "phase" appears, at least two short parenthesized phase markers
/// (`(1)`, `(2a)`, `(2b)`, `(3)`, ...) are present, and a gating/remaining
/// signal frames them as deferred work.
pub fn body_enumerates_multiple_gated_phases(body: &str) -> bool {
    let lower = body.to_ascii_lowercase();
    if !lower.contains("phase") {
        return false;
    }
    let gating = [
        "gated",
        "remaining",
        "live-verify",
        "live verify",
        "awaiting",
        "still needs",
        "not yet",
    ];
    if !gating.iter().any(|signal| lower.contains(signal)) {
        return false;
    }
    count_phase_markers(body) >= 2
}

/// Count distinct short parenthesized phase markers like `(1)`, `(2a)`, `(2b)`,
/// `(3)`. Requires 1-2 digits optionally followed by 1-2 ASCII lowercase letters
/// so dates and commit hashes (`(2026-05-31)`, `(submodule 407b0825)`) are not
/// mistaken for phase markers.
pub fn count_phase_markers(body: &str) -> usize {
    static MARKER: std::sync::LazyLock<regex::Regex> =
        std::sync::LazyLock::new(|| regex::Regex::new(r"\((\d{1,2}[a-z]{0,2})\)").unwrap());
    let mut seen = std::collections::HashSet::new();
    for cap in MARKER.captures_iter(body) {
        seen.insert(cap[1].to_string());
    }
    seen.len()
}

/// True when the body already references at least two discrete child ids other
/// than its own (and other than the ubiquitous `#agent-doc-bug` preset tag).
pub fn body_already_split_into_child_ids(body: &str, own_id: &str) -> bool {
    static ID_REF: std::sync::LazyLock<regex::Regex> =
        std::sync::LazyLock::new(|| regex::Regex::new(r"#([a-z0-9][a-z0-9-]*)").unwrap());
    let mut others = std::collections::HashSet::new();
    for cap in ID_REF.captures_iter(body) {
        let id = agent_doc_element_backlog::backlog::normalize_pending_id(&cap[1]);
        if !id.is_empty() && id != own_id && id != "agent-doc-bug" {
            others.insert(id);
        }
    }
    others.len() >= 2
}

/// Substep-completion phrases that evidence partial progress in a queue audit.
pub const QUEUE_AUDIT_SUBSTEP_COMPLETE_PHRASES: &[&str] = &[
    "is complete",
    "was complete",
    "are complete",
    "were complete",
    "is done",
    "was done",
    "was clean",
    "is clean",
    "is current",
    "are current",
    "passed",
    "verified clean",
    "already complete",
];

/// True when a queue-audit response collapses partial completion: it is about the
/// queue, makes a blanket none-complete claim, shows >=2 distinct substep
/// completions, and never frames anything as "partial."
pub fn queue_audit_collapses_partial_completion(lower: &str) -> bool {
    if !lower.contains("queue") {
        return false;
    }
    // Already broke it down — not a collapse.
    if lower.contains("partial") {
        return false;
    }
    if !queue_audit_has_none_complete_claim(lower) {
        return false;
    }
    let substep_completions = QUEUE_AUDIT_SUBSTEP_COMPLETE_PHRASES
        .iter()
        .filter(|phrase| lower.contains(*phrase))
        .count();
    substep_completions >= 2
}

/// A blanket "none / not ... complete" claim about the queue items.
pub fn queue_audit_has_none_complete_claim(lower: &str) -> bool {
    static NONE_COMPLETE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
        // "none of the queue items is/are (fully) complete", "no items are
        // complete", "none are fully complete", etc. — a none/no quantifier
        // within a short span before a complete/completed token.
        regex::Regex::new(r"\b(none|no)\b[^.\n]{0,60}?\bcomplet(e|ed)\b").unwrap()
    });
    NONE_COMPLETE.is_match(lower)
}

pub const QUEUE_AUDIT_GUARD_SUPPRESS_MARKER: &str = "<!-- no-queue-audit-guard -->";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct QueueAuditPartialCompletionEvidence<'a> {
    pub cycle_open: bool,
    pub capture_committed: bool,
    pub response_body: &'a str,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueueAuditPartialCompletionDecision {
    Pass,
    Warn,
}

pub fn queue_audit_partial_completion_decision(
    evidence: QueueAuditPartialCompletionEvidence<'_>,
) -> QueueAuditPartialCompletionDecision {
    if evidence.cycle_open || !evidence.capture_committed {
        return QueueAuditPartialCompletionDecision::Pass;
    }
    if evidence
        .response_body
        .contains(QUEUE_AUDIT_GUARD_SUPPRESS_MARKER)
    {
        return QueueAuditPartialCompletionDecision::Pass;
    }

    let text = response_text_for_guards(evidence.response_body);
    let lower = text.to_ascii_lowercase();
    if queue_audit_collapses_partial_completion(&lower) {
        QueueAuditPartialCompletionDecision::Warn
    } else {
        QueueAuditPartialCompletionDecision::Pass
    }
}

/// Tight list of "deferred live work" phrases that, combined with a shipped
/// signal, indicate a `do [#id]` turn shipped a repo phase but left live
/// deploy / sync / verification / approval work for a later phase
/// (`#do-id-partial-closeout-state`). Kept narrow to avoid false positives on
/// ordinary closeout prose.
pub const PARTIAL_CLOSEOUT_REMAINING_PHRASES: &[&str] = &[
    "not deployed",
    "not yet deployed",
    "deploy remains",
    "deployment remains",
    "deploy/",
    "live verification",
    "live verify",
    "live-verify",
    "external validation remains",
    "awaiting approval",
    "awaiting user",
    "user approval",
    "sync remains",
    "feed sync",
    "merchant center",
    "live ads",
    "remains: deploy",
];

pub fn text_has_shipped_signal(lower: &str) -> bool {
    (lower.contains("committed")
        || lower.contains("commit + push")
        || lower.contains("commit and push"))
        && lower.contains("push")
}

pub fn text_has_partial_remaining_signal(lower: &str) -> bool {
    PARTIAL_CLOSEOUT_REMAINING_PHRASES
        .iter()
        .any(|phrase| lower.contains(phrase))
}

pub fn response_text_for_guards(response: &str) -> String {
    let Ok((patches, unmatched)) = agent_doc_template::parse_patches(response) else {
        return response.to_string();
    };

    let preferred: Vec<String> = patches
        .iter()
        .filter(|patch| matches!(patch.name.as_str(), "exchange" | "findings"))
        .map(|patch| patch.content.trim().to_string())
        .filter(|text| !text.is_empty())
        .collect();
    if !preferred.is_empty() {
        return preferred.join("\n\n");
    }

    if !unmatched.trim().is_empty() {
        return unmatched.trim().to_string();
    }

    let fallback: Vec<String> = patches
        .iter()
        .filter(|patch| {
            !agent_doc_element::element::is_backlog_component(&patch.name)
                && !agent_doc_element::element::is_review_component(&patch.name)
        })
        .map(|patch| patch.content.trim().to_string())
        .filter(|text| !text.is_empty())
        .collect();
    if !fallback.is_empty() {
        return fallback.join("\n\n");
    }

    response.to_string()
}

pub const PARTIAL_CLOSEOUT_GUARD_SUPPRESS_MARKER: &str = "<!-- no-partial-closeout-guard -->";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PartialCloseoutStateEvidence<'a> {
    pub cycle_open: bool,
    pub capture_committed: bool,
    pub response_body: &'a str,
    pub directed_ids: &'a [String],
    pub pending_done_ids: &'a [String],
    pub reaped_pending_ids: &'a [String],
    pub open_backlog_ids: &'a [String],
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PartialCloseoutStateDecision {
    Pass,
    Warn { candidate_ids: Vec<String> },
}

pub fn partial_closeout_state_decision(
    evidence: PartialCloseoutStateEvidence<'_>,
) -> PartialCloseoutStateDecision {
    if evidence.directed_ids.is_empty() || evidence.cycle_open || !evidence.capture_committed {
        return PartialCloseoutStateDecision::Pass;
    }
    if evidence
        .response_body
        .contains(PARTIAL_CLOSEOUT_GUARD_SUPPRESS_MARKER)
    {
        return PartialCloseoutStateDecision::Pass;
    }

    let text = response_text_for_guards(evidence.response_body);
    let lower = text.to_ascii_lowercase();
    if !(text_has_shipped_signal(&lower) && text_has_partial_remaining_signal(&lower)) {
        return PartialCloseoutStateDecision::Pass;
    }

    let resolved: std::collections::HashSet<String> = normalized_id_set(
        evidence
            .pending_done_ids
            .iter()
            .map(String::as_str)
            .chain(evidence.reaped_pending_ids.iter().map(String::as_str)),
    );
    let open_backlog = normalized_id_set(evidence.open_backlog_ids.iter().map(String::as_str));

    let mut candidate_ids = Vec::new();
    for id in evidence
        .directed_ids
        .iter()
        .map(|id| agent_doc_element_backlog::backlog::normalize_pending_id(id))
        .filter(|id| !id.is_empty())
    {
        if resolved.contains(&id) || !open_backlog.contains(&id) {
            continue;
        }
        if !candidate_ids.iter().any(|existing| existing == &id) {
            candidate_ids.push(id);
        }
    }

    if candidate_ids.is_empty() {
        PartialCloseoutStateDecision::Pass
    } else {
        PartialCloseoutStateDecision::Warn { candidate_ids }
    }
}

pub const BLOCKED_CLOSEOUT_FOLLOWUP_GUARD_SUPPRESS_MARKER: &str =
    "<!-- no-blocked-followup-guard -->";
pub const PENDING_DONE_GUARD_SUPPRESS_MARKER: &str = "<!-- no-pending-done-guard -->";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TrackedWorkCompletionEvidence<'a> {
    pub response_body: &'a str,
    pub recorded_done_ids: &'a [String],
    pub kept_open_ids: &'a [String],
    pub open_tracked_work_ids: &'a [String],
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrackedWorkCompletionDecision {
    Pass,
    MissingDone { missing_ids: Vec<String> },
}

pub fn pending_done_suppressed(response_body: &str) -> bool {
    response_body.contains(PENDING_DONE_GUARD_SUPPRESS_MARKER)
}

/// Done-signal ids implied by prompt text, restricted to ids that are still open
/// in tracked work. `auto_done` lets a plain "done" resolve the single open
/// review item when the document has exactly one such id.
pub fn inline_done_signal_ids(
    prompt_texts: &[String],
    open_tracked_work_ids: &[String],
    single_open_review_item_id: Option<&str>,
    auto_done: bool,
) -> Vec<String> {
    if prompt_texts.is_empty() || open_tracked_work_ids.is_empty() {
        return Vec::new();
    }

    let open_ids = normalized_id_set(open_tracked_work_ids.iter().map(String::as_str));
    if open_ids.is_empty() {
        return Vec::new();
    }
    let single_review_id = single_open_review_item_id
        .filter(|_| auto_done)
        .map(agent_doc_element_backlog::backlog::normalize_pending_id)
        .filter(|id| !id.is_empty());

    let mut ids = Vec::new();
    for prompt in prompt_texts {
        for id in explicit_done_signal_ids(prompt) {
            let id = agent_doc_element_backlog::backlog::normalize_pending_id(&id);
            if open_ids.contains(&id) && !ids.iter().any(|existing| existing == &id) {
                ids.push(id);
            }
        }

        if auto_done
            && plain_done_signal(prompt)
            && let Some(id) = single_review_id.as_deref()
            && open_ids.contains(id)
            && !ids.iter().any(|existing| existing == id)
        {
            ids.push(id.to_string());
        }
    }

    ids
}

pub fn tracked_work_completion_decision(
    evidence: TrackedWorkCompletionEvidence<'_>,
) -> TrackedWorkCompletionDecision {
    if pending_done_suppressed(evidence.response_body) {
        return TrackedWorkCompletionDecision::Pass;
    }

    let response_text = response_text_for_guards(evidence.response_body);
    let missing_ids = tracked_work_completion_missing_done_ids(
        &response_text,
        evidence.recorded_done_ids,
        evidence.kept_open_ids,
        evidence.open_tracked_work_ids,
    );
    if missing_ids.is_empty() {
        TrackedWorkCompletionDecision::Pass
    } else {
        TrackedWorkCompletionDecision::MissingDone { missing_ids }
    }
}

pub fn tracked_work_completion_missing_done_ids(
    response_text: &str,
    recorded_done_ids: &[String],
    kept_open_ids: &[String],
    open_tracked_work_ids: &[String],
) -> Vec<String> {
    if response_text.trim().is_empty() || open_tracked_work_ids.is_empty() {
        return Vec::new();
    }

    let recorded_done = normalized_id_set(recorded_done_ids.iter().map(String::as_str));
    let kept_open = normalized_id_set(kept_open_ids.iter().map(String::as_str));

    open_tracked_work_ids
        .iter()
        .map(|id| agent_doc_element_backlog::backlog::normalize_pending_id(id))
        .filter(|id| !id.is_empty())
        .filter(|id| !kept_open.contains(id))
        .filter(|id| response_clearly_completes_pending_id(response_text, id))
        .filter(|id| !recorded_done.contains(id))
        .collect()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExpectDoneOrGateEvidence<'a> {
    pub cycle_open: bool,
    pub capture_committed: bool,
    pub response_body: &'a str,
    pub directed_ids: &'a [String],
    pub pending_done_ids: &'a [String],
    pub pending_kept_open_ids: &'a [String],
    pub reaped_pending_ids: &'a [String],
    pub open_backlog_ids: &'a [String],
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExpectDoneOrGateDecision {
    Pass,
    Warn { unresolved_ids: Vec<String> },
}

pub fn expect_done_or_gate_decision(
    evidence: ExpectDoneOrGateEvidence<'_>,
) -> ExpectDoneOrGateDecision {
    if evidence.directed_ids.is_empty()
        || evidence.cycle_open
        || !evidence.capture_committed
        || pending_done_suppressed(evidence.response_body)
    {
        return ExpectDoneOrGateDecision::Pass;
    }

    let resolved = normalized_id_set(
        evidence
            .pending_done_ids
            .iter()
            .map(String::as_str)
            .chain(evidence.pending_kept_open_ids.iter().map(String::as_str))
            .chain(evidence.reaped_pending_ids.iter().map(String::as_str)),
    );
    let open_backlog = normalized_id_set(evidence.open_backlog_ids.iter().map(String::as_str));

    let mut unresolved_ids = Vec::new();
    for id in evidence
        .directed_ids
        .iter()
        .map(|id| agent_doc_element_backlog::backlog::normalize_pending_id(id))
        .filter(|id| !id.is_empty())
    {
        if resolved.contains(&id) || !open_backlog.contains(&id) {
            continue;
        }
        if !unresolved_ids.iter().any(|existing| existing == &id) {
            unresolved_ids.push(id);
        }
    }

    if unresolved_ids.is_empty() {
        ExpectDoneOrGateDecision::Pass
    } else {
        ExpectDoneOrGateDecision::Warn { unresolved_ids }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BlockedCloseoutFollowupEvidence<'a> {
    pub cycle_open: bool,
    pub capture_committed: bool,
    pub pending_added_this_cycle: bool,
    pub response_body: &'a str,
    pub directed_ids: &'a [String],
    pub pending_kept_open_ids: &'a [String],
    pub pending_done_ids: &'a [String],
    pub pending_gated_ids: &'a [String],
    pub still_gated_ids: &'a [String],
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlockedCloseoutFollowupDecision {
    Pass,
    Warn { unresolved_ids: Vec<String> },
}

pub fn blocked_closeout_followup_decision(
    evidence: BlockedCloseoutFollowupEvidence<'_>,
) -> BlockedCloseoutFollowupDecision {
    if evidence.directed_ids.is_empty()
        || evidence.pending_gated_ids.is_empty()
        || evidence.cycle_open
        || evidence.pending_added_this_cycle
        || !evidence.capture_committed
    {
        return BlockedCloseoutFollowupDecision::Pass;
    }
    if evidence
        .response_body
        .contains(BLOCKED_CLOSEOUT_FOLLOWUP_GUARD_SUPPRESS_MARKER)
        || evidence
            .response_body
            .contains(PENDING_DONE_GUARD_SUPPRESS_MARKER)
    {
        return BlockedCloseoutFollowupDecision::Pass;
    }

    let text = response_text_for_guards(evidence.response_body);
    let lower = text.to_ascii_lowercase();
    if !text_has_blocked_future_action_signal(&lower) || text_has_no_followup_justification(&lower)
    {
        return BlockedCloseoutFollowupDecision::Pass;
    }

    let kept_open = normalized_id_set(evidence.pending_kept_open_ids.iter().map(String::as_str));
    let done = normalized_id_set(evidence.pending_done_ids.iter().map(String::as_str));
    let gated = normalized_id_set(evidence.pending_gated_ids.iter().map(String::as_str));
    let still_gated = normalized_id_set(evidence.still_gated_ids.iter().map(String::as_str));

    let mut unresolved_ids = Vec::new();
    for id in evidence
        .directed_ids
        .iter()
        .map(|id| agent_doc_element_backlog::backlog::normalize_pending_id(id))
        .filter(|id| !id.is_empty())
    {
        if kept_open.contains(&id) || done.contains(&id) {
            continue;
        }
        if !gated.contains(&id) || !still_gated.contains(&id) {
            continue;
        }
        if !blocked_signal_tied_to_id(&text, &id) {
            continue;
        }
        if !unresolved_ids.iter().any(|existing| existing == &id) {
            unresolved_ids.push(id);
        }
    }

    if unresolved_ids.is_empty() {
        BlockedCloseoutFollowupDecision::Pass
    } else {
        BlockedCloseoutFollowupDecision::Warn { unresolved_ids }
    }
}

pub const GATED_PHASE_SPLIT_GUARD_SUPPRESS_MARKER: &str = "<!-- no-gated-phase-split-guard -->";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GatedPhaseSplitItemEvidence {
    pub id: String,
    pub done: bool,
    pub body: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GatedPhaseSplitEvidence<'a> {
    pub cycle_open: bool,
    pub capture_committed: bool,
    pub response_body: &'a str,
    pub directed_ids: &'a [String],
    pub pending_kept_open_ids: &'a [String],
    pub tracked_items: &'a [GatedPhaseSplitItemEvidence],
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GatedPhaseSplitDecision {
    Pass,
    Warn { flagged_ids: Vec<String> },
}

pub fn gated_phase_split_decision(
    evidence: GatedPhaseSplitEvidence<'_>,
) -> GatedPhaseSplitDecision {
    if evidence.directed_ids.is_empty() || evidence.cycle_open || !evidence.capture_committed {
        return GatedPhaseSplitDecision::Pass;
    }
    if evidence
        .response_body
        .contains(GATED_PHASE_SPLIT_GUARD_SUPPRESS_MARKER)
    {
        return GatedPhaseSplitDecision::Pass;
    }

    let kept_open = normalized_id_set(evidence.pending_kept_open_ids.iter().map(String::as_str));
    if kept_open.is_empty() {
        return GatedPhaseSplitDecision::Pass;
    }
    let directed = normalized_id_set(evidence.directed_ids.iter().map(String::as_str));

    let mut flagged_ids = Vec::new();
    for item in evidence.tracked_items {
        if item.done {
            continue;
        }
        let id = agent_doc_element_backlog::backlog::normalize_pending_id(&item.id);
        if id.is_empty() || !kept_open.contains(&id) || !directed.contains(&id) {
            continue;
        }
        if body_enumerates_multiple_gated_phases(&item.body)
            && !body_already_split_into_child_ids(&item.body, &id)
            && !flagged_ids.iter().any(|existing| existing == &id)
        {
            flagged_ids.push(id);
        }
    }

    if flagged_ids.is_empty() {
        GatedPhaseSplitDecision::Pass
    } else {
        GatedPhaseSplitDecision::Warn { flagged_ids }
    }
}

fn normalized_id_set<'a>(
    ids: impl IntoIterator<Item = &'a str>,
) -> std::collections::HashSet<String> {
    ids.into_iter()
        .map(agent_doc_element_backlog::backlog::normalize_pending_id)
        .filter(|id| !id.is_empty())
        .collect()
}

pub fn normalized_prompt_for_match(line: &str) -> String {
    line.trim().trim_start_matches('❯').trim().to_string()
}

/// True when `doc`'s `agent:exchange` component contains a line matching the
/// given prompt after prompt-prefix and whitespace normalization.
pub fn exchange_contains_prompt_line(doc: &str, prompt: &str) -> bool {
    let needle = normalized_prompt_for_match(prompt);
    if needle.is_empty() {
        return true;
    }
    let Ok(components) = agent_doc_element::element::parse(doc) else {
        return false;
    };
    let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
        return false;
    };
    exchange
        .content(doc)
        .lines()
        .any(|line| normalized_prompt_for_match(line) == needle)
}

/// Dropped queue prompts that are still absent from visible/HEAD queue and
/// exchange content, and whose directive ids were not resolved this cycle.
pub fn still_missing_dropped_queue_prompts(
    visible: &str,
    head: &str,
    dropped_prompts: &[String],
    resolved_ids: &std::collections::HashSet<String>,
) -> Vec<String> {
    let visible_queue_ids = agent_doc_queue::document_queue::queue_ids_including_struck(visible);
    let head_queue_ids = agent_doc_queue::document_queue::queue_ids_including_struck(head);
    dropped_prompts
        .iter()
        .filter(|prompt| {
            if agent_doc_queue::document_queue::queue_contains_prompt_line(visible, prompt)
                || agent_doc_queue::document_queue::queue_contains_prompt_line(head, prompt)
                || exchange_contains_prompt_line(visible, prompt)
                || exchange_contains_prompt_line(head, prompt)
            {
                return false;
            }

            let dropped_ids = agent_doc_queue::queue_directive::do_directive_target_ids(
                std::slice::from_ref(prompt),
            );
            if !dropped_ids.is_empty()
                && dropped_ids
                    .iter()
                    .all(|id| visible_queue_ids.contains(id) || head_queue_ids.contains(id))
            {
                return false;
            }

            let consumed = dropped_ids.iter().any(|id| resolved_ids.contains(id));
            !consumed
        })
        .cloned()
        .collect()
}

/// Dropped exchange prompts that are still absent from the committed exchange.
pub fn still_missing_dropped_exchange_prompts(
    head: &str,
    dropped_prompts: &[String],
) -> Vec<String> {
    dropped_prompts
        .iter()
        .filter(|prompt| !exchange_contains_prompt_line(head, prompt))
        .cloned()
        .collect()
}

pub fn is_exchange_response_heading(trimmed: &str) -> bool {
    trimmed == "## Assistant"
        || trimmed.starts_with("### Re:")
        || trimmed.starts_with("#### Re:")
        || trimmed.starts_with("##### Re:")
        || trimmed.starts_with("###### Re:")
}

pub fn is_direct_response_patchback_heading(trimmed: &str) -> bool {
    trimmed.starts_with("### Re:") || trimmed == "## Assistant"
}

pub fn has_new_response_heading_marker(snapshot_doc: &str, current_doc: &str) -> bool {
    let snapshot_counts = response_heading_marker_counts(snapshot_doc);
    let current_counts = response_heading_marker_counts(current_doc);
    current_counts
        .into_iter()
        .any(|(marker, count)| count > snapshot_counts.get(&marker).copied().unwrap_or(0))
}

fn response_heading_marker_counts(doc: &str) -> std::collections::BTreeMap<String, usize> {
    let mut counts = std::collections::BTreeMap::new();
    for line in doc.lines() {
        let trimmed = line.trim();
        if is_direct_response_patchback_heading(trimmed) {
            *counts.entry(trimmed.to_string()).or_insert(0) += 1;
        }
    }
    counts
}

/// Known binary-authored recovery diagnostics use `### Re:` headings so diff
/// classification treats them as recovery artifacts, not user prompt targets.
pub fn is_binary_authored_recovery_diagnostic_heading(trimmed: &str) -> bool {
    (trimmed.starts_with("### Re:")
        || trimmed.starts_with("#### Re:")
        || trimmed.starts_with("##### Re:"))
        && trimmed.contains("interrupted-cycle recovery")
}

/// True when a `### Re:` heading answers a queue/backlog continuation rather
/// than a free-text exchange prompt.
pub fn is_queue_continuation_response_heading(trimmed: &str) -> bool {
    let Some(rest) = trimmed
        .strip_prefix("### Re:")
        .or_else(|| trimmed.strip_prefix("#### Re:"))
        .or_else(|| trimmed.strip_prefix("##### Re:"))
        .or_else(|| trimmed.strip_prefix("###### Re:"))
    else {
        return false;
    };
    let topic = rest.trim_start();
    (topic.starts_with("do [#") || topic.starts_with("re [#")) && topic.contains(']')
}

/// Collect assistant response prose from an exchange component body.
///
/// Heading, boundary, comment, and blockquote prompt-echo lines are excluded so
/// callers can compare queue prompts against answer prose rather than echoed
/// prompt text.
pub fn assistant_response_text(exchange_body: &str) -> String {
    let mut in_response = false;
    let mut out = String::new();
    for line in exchange_body.lines() {
        let trimmed = line.trim();
        if is_exchange_response_heading(trimmed) {
            in_response = true;
            continue;
        }
        if trimmed.starts_with("<!-- agent:boundary") {
            continue;
        }
        if in_response
            && !trimmed.is_empty()
            && !trimmed.starts_with("<!--")
            && !trimmed.starts_with('>')
        {
            out.push_str(trimmed);
            out.push('\n');
        }
    }
    out
}

/// Where a directive id's response heading materialized.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ResponseSource {
    Exchange,
    Archive,
}

impl ResponseSource {
    pub fn as_str(self) -> &'static str {
        match self {
            ResponseSource::Exchange => "exchange",
            ResponseSource::Archive => "archive",
        }
    }
}

/// Resolve where `id`'s `### Re:` response heading materialized, if anywhere.
///
/// Pure over already-resolved `content` (the live committed exchange) and
/// `archives` (HEAD compact-archive bodies). The caller owns all file/archive
/// IO and passes the string bodies here.
pub fn directive_response_source(
    content: &str,
    archives: &[String],
    id: &str,
) -> Option<ResponseSource> {
    ResponseIndex::build(content, archives).source_for(id)
}

/// True when a `### Re:` response node in `content` references `#id`.
///
/// `#reheadingidoptional`: this used to require the id in the heading LINE, on
/// the premise that a `do #id` response always renders as `### Re: ... #id`.
/// That premise is false — `SKILL.md` asks for `### Re: <topic> — <model>`, and
/// a topic named after the operator's question legitimately carries no id. The
/// heading-only match then reported a response plainly present in the document
/// as silently lost, alarming on a healthy closeout.
///
/// Scope comes from the exchange AST, not from scanning lines: `components`
/// bounds the `exchange` component (ignoring markers inside code fences) and
/// `parse_exchange_nodes` splits it into prompt and response turns. Only
/// `Response` nodes are searched, so `agent:queue` / `agent:backlog` text and
/// user prompt turns can never be read as a response body — the same
/// anti-false-match intent the heading-only rule had, without the hand-rolled
/// section tracking that made it wrong.
pub fn content_has_re_heading_for_id(content: &str, id: &str) -> bool {
    ResponseIndex::build(content, &[]).source_for(id).is_some()
}

/// The regions of `content` that may contain response turns.
///
/// Normally that is the `exchange` component(s). A HEAD compact archive is a
/// bare response run with no component wrapper, so when no `exchange` component
/// exists we fall back to the content *outside* every other component — never
/// the raw input, or a document carrying `agent:queue` / `agent:backlog` but no
/// exchange would feed those items straight into the response scan.
fn exchange_bodies(content: &str) -> Vec<String> {
    let components = agent_doc_markdown_ast::overlay::components(content);
    let bodies: Vec<String> = components
        .iter()
        .filter(|component| component.name == "exchange")
        .filter_map(|component| content.get(component.start_byte..component.end_byte))
        .map(str::to_string)
        .collect();
    if !bodies.is_empty() {
        return bodies;
    }
    if components.is_empty() {
        return vec![content.to_string()];
    }
    let mut outside = Vec::new();
    let mut cursor = 0usize;
    for component in &components {
        if component.start_byte > cursor
            && let Some(region) = content.get(cursor..component.start_byte)
        {
            outside.push(region.to_string());
        }
        cursor = cursor.max(component.end_byte);
    }
    if let Some(tail) = content.get(cursor..) {
        outside.push(tail.to_string());
    }
    outside
}

/// True when `line` mentions `needle` (`#<id>`, lowercased) as a whole id.
fn line_references_id(line: &str, needle: &str) -> bool {
    let lower = line.to_ascii_lowercase();
    let mut from = 0;
    while let Some(offset) = lower[from..].find(needle) {
        let pos = from + offset;
        // Reject a longer-id prefix collision (`#ab` must not match `#abc`).
        let after = &lower[pos + needle.len()..];
        if !after
            .chars()
            .next()
            .is_some_and(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
        {
            return true;
        }
        from = pos + needle.len();
    }
    false
}

/// Pure inputs for per-id queue directive response-loss detection.
///
/// All ids are normalized (no leading `#`, lowercased) before they reach this
/// policy. The caller resolves `content` and `archives` up front so this logic
/// stays deterministic and IO-free.
#[derive(Clone, Debug)]
pub struct ReapedResponseLossInput<'a> {
    /// `do #id` directive target ids active this cycle.
    pub directive_ids: &'a [String],
    /// Pending ids reaped into `agent:done` this cycle.
    pub reaped_ids: &'a [String],
    /// Live committed exchange content.
    pub content: &'a str,
    /// HEAD-referenced compact-archive bodies (each searched like `content`).
    pub archives: &'a [String],
}

/// Reaped `do #id` directive ids whose `### Re: ... #id` response did not land.
///
/// Returns the directive ids whose response heading did not materialize in the
/// live exchange `content` or in any HEAD compact `archives` entry. Order follows
/// `directive_ids`; duplicates are collapsed.
pub fn reaped_directive_ids_without_response(input: &ReapedResponseLossInput<'_>) -> Vec<String> {
    let index = ResponseIndex::build(input.content, input.archives);
    let reaped: std::collections::HashSet<&str> =
        input.reaped_ids.iter().map(String::as_str).collect();
    let mut lost: Vec<String> = Vec::new();
    for id in input.directive_ids {
        if id.is_empty() || !reaped.contains(id.as_str()) {
            continue;
        }
        if index.source_for(id).is_some() {
            continue;
        }
        if !lost.iter().any(|existing| existing == id) {
            lost.push(id.clone());
        }
    }
    lost
}

/// Response turns from the live exchange and the HEAD compact archives, parsed
/// **once** so a multi-id cycle does not re-run the markdown AST per id.
///
/// `#respidxparse`: the per-id entry points each re-parsed `content` *and* every
/// archive, so an N-id cycle with M archives paid N x (1 + M) tree-sitter parses
/// of the same bytes. Build this once and query it per id instead.
///
/// This is still a parse from a `&str`, and — contrary to what this comment
/// used to claim — there is currently nowhere else to get the nodes from.
/// `OverlayCrdtDoc` is a single `markdown: String` field, not a node tree, and
/// has no live instances; nothing in the workspace holds a persistent
/// `Vec<ExchangeNode>`. Reading "from the realtime model" would therefore
/// relocate the parse, not remove it.
///
/// Removing it needs the `SeqCrdt` exchange-node backing AGENTS.md names as a
/// strategic *direction* — i.e. a model that actually holds the nodes and is
/// updated incrementally. Until that exists this single per-invocation parse is
/// the floor, and `#respidxparse` above already took the large win (N x (1 + M)
/// parses down to one).
pub struct ResponseIndex {
    exchange: Vec<String>,
    archive: Vec<String>,
}

impl ResponseIndex {
    /// Parse the live content and each archive into their response-turn bodies.
    pub fn build(content: &str, archives: &[String]) -> Self {
        Self {
            exchange: response_bodies(content),
            archive: archives
                .iter()
                .flat_map(|archive| response_bodies(archive))
                .collect(),
        }
    }

    /// Where `id`'s response materialized, if anywhere. Live exchange wins.
    pub fn source_for(&self, id: &str) -> Option<ResponseSource> {
        if id.is_empty() {
            return None;
        }
        let needle = format!("#{}", id.to_ascii_lowercase());
        if self
            .exchange
            .iter()
            .any(|body| line_references_id(body, &needle))
        {
            return Some(ResponseSource::Exchange);
        }
        if self
            .archive
            .iter()
            .any(|body| line_references_id(body, &needle))
        {
            return Some(ResponseSource::Archive);
        }
        None
    }
}

/// The rendered text of every `### Re:` response turn in `content`.
fn response_bodies(content: &str) -> Vec<String> {
    exchange_bodies(content)
        .iter()
        .flat_map(|body| agent_doc_markdown_ast::exchange_tree::parse_exchange_nodes(body))
        .filter(|node| {
            matches!(
                node.kind,
                agent_doc_markdown_ast::exchange_tree::ExchangeNodeKind::Response { .. }
            )
        })
        .map(|node| node.render())
        .collect()
}

/// True when response text clearly completes `id`.
///
/// Completion is signalled by a response HEADING whose topic RESOLVES to exactly
/// this id, never by a bare prose citation of `#id` in the body
/// (#pending-done-guard-false-positive). A heading match plus a completion
/// marker still distinguishes a real completion from a halt/refusal response
/// that merely names the head (#queue-strike-on-halt).
pub fn response_clearly_completes_pending_id(response_text: &str, id: &str) -> bool {
    if !response_heading_resolves_to_pending_id(response_text, id) {
        return false;
    }
    contains_completion_marker(&response_text.to_ascii_lowercase())
}

/// True when some `### Re:` response heading's topic resolves to `#id`.
///
/// A batch `do [#a] [#b] ...` directive heading resolves to every bracketed id;
/// a titled `#id descriptive text` heading resolves only to its LEADING id. A
/// heading that merely contains `#id` later in descriptive prose, and any `#id`
/// cited in the response body, never resolves to it.
pub fn response_heading_resolves_to_pending_id(response_text: &str, id: &str) -> bool {
    let id_lower = id.to_ascii_lowercase();
    for raw in response_text.lines() {
        let line = raw.trim().to_ascii_lowercase();
        let Some(after) = line.strip_prefix('#') else {
            continue;
        };
        let heading = after.trim_start_matches('#').trim_start();
        let Some(topic) = heading.strip_prefix("re:") else {
            continue;
        };
        let topic = topic.split(" — ").next().unwrap_or(topic).trim();
        if let Some(do_list) = topic.strip_prefix("do ") {
            let bracket_ids = extract_bracket_ids(do_list);
            if !bracket_ids.is_empty() {
                if bracket_ids.iter().any(|b| b == &id_lower) {
                    return true;
                }
                continue;
            }
            if leading_hash_id(do_list).as_deref() == Some(id_lower.as_str()) {
                return true;
            }
        } else if leading_hash_id(topic).as_deref() == Some(id_lower.as_str()) {
            return true;
        }
    }
    false
}

fn leading_hash_id(text: &str) -> Option<String> {
    let t = text.strip_prefix('[').unwrap_or(text);
    let rest = t.strip_prefix('#')?;
    let id: String = rest
        .chars()
        .take_while(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
        .collect();
    (!id.is_empty()).then_some(id)
}

fn extract_bracket_ids(text: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut rest = text;
    while let Some(pos) = rest.find("[#") {
        let after = &rest[pos + 2..];
        let id: String = after
            .chars()
            .take_while(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
            .collect();
        let consumed = id.len();
        if !id.is_empty() {
            out.push(id);
        }
        rest = &after[consumed..];
    }
    out
}

fn contains_completion_marker(text: &str) -> bool {
    [
        "implemented",
        "fixed",
        "done.",
        "done ",
        "completed",
        "updated",
        "verification:",
        "verified",
        "pushed",
        "commit:",
        "outcome:",
        "what changed:",
        "landed",
        "shipped",
    ]
    .iter()
    .any(|marker| text.contains(marker))
}

pub fn explicit_done_signal_ids(text: &str) -> Vec<String> {
    let normalized = normalize_done_signal_text(text);
    if normalized.is_empty() {
        return Vec::new();
    }

    let lower = normalized.to_ascii_lowercase();
    let is_done_signal = lower.contains(" done")
        || lower.ends_with(" done")
        || lower.starts_with("done ")
        || lower.contains(" complete")
        || lower.ends_with(" complete")
        || lower.starts_with("complete ")
        || lower.contains(" completed")
        || lower.ends_with(" completed")
        || lower.starts_with("completed ")
        || lower.contains(" resolved")
        || lower.ends_with(" resolved")
        || lower.starts_with("resolved ");
    if !is_done_signal {
        return Vec::new();
    }

    agent_doc_element_backlog::backlog::extract_pending_hash_ids(&normalized)
}

pub fn plain_done_signal(text: &str) -> bool {
    let normalized = normalize_done_signal_text(text);
    let lower = normalized.to_ascii_lowercase();
    matches!(
        lower.as_str(),
        "done"
            | "done."
            | "complete"
            | "complete."
            | "completed"
            | "completed."
            | "resolved"
            | "resolved."
    )
}

fn normalize_done_signal_text(text: &str) -> String {
    text.trim()
        .trim_start_matches('❯')
        .trim()
        .trim_start_matches("- ")
        .trim()
        .to_string()
}

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

    fn loss_input<'a>(
        directive_ids: &'a [String],
        reaped_ids: &'a [String],
        content: &'a str,
        archives: &'a [String],
    ) -> ReapedResponseLossInput<'a> {
        ReapedResponseLossInput {
            directive_ids,
            reaped_ids,
            content,
            archives,
        }
    }

    #[test]
    fn reaped_directive_response_loss_flags_reap_only_loss() {
        let directive = vec!["lostresp".to_string()];
        let reaped = vec!["lostresp".to_string()];
        let content = "### Re: prior — gpt-5\n\nAnswered something else.\n";
        let archives: Vec<String> = Vec::new();
        assert_eq!(
            reaped_directive_ids_without_response(&loss_input(
                &directive, &reaped, content, &archives
            )),
            vec!["lostresp".to_string()],
        );
    }

    #[test]
    fn reaped_directive_response_loss_flags_captured_but_id_lost() {
        let directive = vec!["kept".to_string(), "lost".to_string()];
        let reaped = vec!["kept".to_string(), "lost".to_string()];
        let content = "### Re: do #kept — opus-4-8\n\nShipped the kept fix.\n";
        let archives: Vec<String> = Vec::new();
        assert_eq!(
            reaped_directive_ids_without_response(&loss_input(
                &directive, &reaped, content, &archives
            )),
            vec!["lost".to_string()],
        );
    }

    #[test]
    fn reaped_directive_response_loss_accepts_archive_materialization() {
        let directive = vec!["archived".to_string()];
        let reaped = vec!["archived".to_string()];
        let content = "### Re: prior — gpt-5\n\nUnrelated live response.\n";
        let archives = vec!["### Re: do #archived — opus-4-8\n\nShipped earlier.\n".to_string()];
        assert!(
            reaped_directive_ids_without_response(&loss_input(
                &directive, &reaped, content, &archives
            ))
            .is_empty(),
            "a reaped id materialized in a HEAD compact archive is not a loss"
        );
    }

    /// `#reheadingidoptional`: `SKILL.md` asks for `### Re: <topic> — <model>`,
    /// so a header named after the operator's question carries no id. The
    /// response is present; reporting it lost is a false positive.
    #[test]
    fn response_section_body_reference_counts_when_the_heading_is_topic_titled() {
        let exchange = "\
### Re: how should snippets be archived? — opus-4-8

Archived them under the new layout. Closes #rappsnippetarchive.
";
        assert!(
            content_has_re_heading_for_id(exchange, "rappsnippetarchive"),
            "a topic-titled heading whose body cites the id is not a lost response"
        );
        assert_eq!(
            directive_response_source(exchange, &[], "rappsnippetarchive"),
            Some(ResponseSource::Exchange)
        );
    }

    /// The loosened match must not start reading queue/backlog text or a user
    /// prompt turn as a response body, or a genuinely lost response stops being
    /// detected. Scope comes from the exchange AST, so this pins both bounds.
    #[test]
    fn component_and_prompt_turns_are_not_response_bodies() {
        let after_component = "\
<!-- agent:exchange -->
### Re: unrelated topic — opus-4-8

Nothing to do with the reaped id.
<!-- /agent:exchange -->

<!-- agent:queue -->
do [#lostid]
<!-- /agent:queue -->
";
        assert!(
            !content_has_re_heading_for_id(after_component, "lostid"),
            "a queue head in its own component is not a response"
        );

        // A user prompt turn following the response is a Prompt node, not part
        // of the response, so an id the operator merely asked about is not a
        // materialized response.
        let trailing_prompt = "\
<!-- agent:exchange -->
### Re: unrelated topic — opus-4-8

Nothing to do with the reaped id.

❯ do [#lostid]
<!-- /agent:exchange -->
";
        assert!(
            !content_has_re_heading_for_id(trailing_prompt, "lostid"),
            "a trailing prompt turn is not a response body"
        );

        // A bare `#tag` inside the body must not truncate the search before a
        // later id reference in the same response.
        let tagged = "\
<!-- agent:exchange -->
### Re: unrelated topic — opus-4-8

#sometag context line

Closes #lostid.
<!-- /agent:exchange -->
";
        assert!(content_has_re_heading_for_id(tagged, "lostid"));
    }

    #[test]
    fn directive_response_source_resolves_found_and_source() {
        let archives = vec!["### Re: do #archived — opus-4-8\n\nShipped earlier.\n".to_string()];

        let exchange = "### Re: do #live — opus-4-8\n\nShipped the live fix.\n";
        assert_eq!(
            directive_response_source(exchange, &archives, "live"),
            Some(ResponseSource::Exchange)
        );

        let unrelated = "### Re: prior — gpt-5\n\nUnrelated live response.\n";
        assert_eq!(
            directive_response_source(unrelated, &archives, "archived"),
            Some(ResponseSource::Archive)
        );
        assert!(directive_response_source(unrelated, &archives, "lost").is_none());
    }

    #[test]
    fn blocked_followup_policy_ties_future_action_to_directed_id() {
        assert!(text_has_blocked_future_action_signal(
            "merchant center is blocked on approval"
        ));
        assert!(text_has_no_followup_justification(
            "no additional backlog follow-up is needed because rollout is review-only"
        ));
        assert!(blocked_signal_tied_to_id(
            "### Re: do #abc\n\n#abc remains blocked on approval.",
            "abc"
        ));
        assert!(!blocked_signal_tied_to_id(
            "### Re: do #abc\n\n#other remains blocked on approval.",
            "abc"
        ));
    }

    #[test]
    fn tracked_work_completion_decision_reports_missing_done_ids() {
        let response = "### Re: do [#alpha] [#beta]\n\nImplemented #alpha and #beta; tests passed.";
        let recorded_done_ids = vec!["beta".to_string()];
        let kept_open_ids = vec!["gamma".to_string()];
        let open_tracked_work_ids = vec![
            "alpha".to_string(),
            "beta".to_string(),
            "gamma".to_string(),
            "delta".to_string(),
        ];

        assert_eq!(
            tracked_work_completion_decision(TrackedWorkCompletionEvidence {
                response_body: response,
                recorded_done_ids: &recorded_done_ids,
                kept_open_ids: &kept_open_ids,
                open_tracked_work_ids: &open_tracked_work_ids,
            }),
            TrackedWorkCompletionDecision::MissingDone {
                missing_ids: vec!["alpha".to_string()]
            }
        );
    }

    #[test]
    fn tracked_work_completion_decision_passes_for_suppressed_or_non_completion() {
        let response = "### Re: do [#alpha]\n\nImplemented #alpha; tests passed.";
        let suppressed = format!("{response}\n{PENDING_DONE_GUARD_SUPPRESS_MARKER}");
        let open_tracked_work_ids = vec!["alpha".to_string()];
        let empty = Vec::new();

        assert_eq!(
            tracked_work_completion_decision(TrackedWorkCompletionEvidence {
                response_body: &suppressed,
                recorded_done_ids: &empty,
                kept_open_ids: &empty,
                open_tracked_work_ids: &open_tracked_work_ids,
            }),
            TrackedWorkCompletionDecision::Pass
        );
        assert_eq!(
            tracked_work_completion_decision(TrackedWorkCompletionEvidence {
                response_body: "Mentioned #alpha in prose without a response heading.",
                recorded_done_ids: &empty,
                kept_open_ids: &empty,
                open_tracked_work_ids: &open_tracked_work_ids,
            }),
            TrackedWorkCompletionDecision::Pass
        );
    }

    #[test]
    fn expect_done_or_gate_decision_warns_for_unresolved_open_directed_ids() {
        let response = "### Re: do #nstep2 — gpt-5\n\nShipped the repo/API/deploy work.";
        let directed_ids = vec![
            "nstep2".to_string(),
            "#nstep2".to_string(),
            "done1".to_string(),
            "kept1".to_string(),
            "reaped1".to_string(),
            "removed1".to_string(),
        ];
        let pending_done_ids = vec!["done1".to_string()];
        let pending_kept_open_ids = vec!["kept1".to_string()];
        let reaped_pending_ids = vec!["reaped1".to_string()];
        let open_backlog_ids = vec![
            "nstep2".to_string(),
            "done1".to_string(),
            "kept1".to_string(),
            "reaped1".to_string(),
        ];

        assert_eq!(
            expect_done_or_gate_decision(ExpectDoneOrGateEvidence {
                cycle_open: false,
                capture_committed: true,
                response_body: response,
                directed_ids: &directed_ids,
                pending_done_ids: &pending_done_ids,
                pending_kept_open_ids: &pending_kept_open_ids,
                reaped_pending_ids: &reaped_pending_ids,
                open_backlog_ids: &open_backlog_ids,
            }),
            ExpectDoneOrGateDecision::Warn {
                unresolved_ids: vec!["nstep2".to_string()]
            }
        );
    }

    #[test]
    fn expect_done_or_gate_decision_passes_without_required_state_or_signal() {
        let response = "### Re: do #nstep2 — gpt-5\n\nCompleted.";
        let suppressed = format!("{response}\n{PENDING_DONE_GUARD_SUPPRESS_MARKER}");
        let directed_ids = vec!["nstep2".to_string()];
        let open_backlog_ids = vec!["nstep2".to_string()];
        let empty = Vec::new();
        let base = ExpectDoneOrGateEvidence {
            cycle_open: false,
            capture_committed: true,
            response_body: response,
            directed_ids: &directed_ids,
            pending_done_ids: &empty,
            pending_kept_open_ids: &empty,
            reaped_pending_ids: &empty,
            open_backlog_ids: &open_backlog_ids,
        };

        assert_eq!(
            expect_done_or_gate_decision(ExpectDoneOrGateEvidence {
                cycle_open: true,
                ..base
            }),
            ExpectDoneOrGateDecision::Pass
        );
        assert_eq!(
            expect_done_or_gate_decision(ExpectDoneOrGateEvidence {
                capture_committed: false,
                ..base
            }),
            ExpectDoneOrGateDecision::Pass
        );
        assert_eq!(
            expect_done_or_gate_decision(ExpectDoneOrGateEvidence {
                response_body: &suppressed,
                ..base
            }),
            ExpectDoneOrGateDecision::Pass
        );
        assert_eq!(
            expect_done_or_gate_decision(ExpectDoneOrGateEvidence {
                directed_ids: &empty,
                ..base
            }),
            ExpectDoneOrGateDecision::Pass
        );
        assert_eq!(
            expect_done_or_gate_decision(ExpectDoneOrGateEvidence {
                pending_done_ids: &directed_ids,
                ..base
            }),
            ExpectDoneOrGateDecision::Pass
        );
        assert_eq!(
            expect_done_or_gate_decision(ExpectDoneOrGateEvidence {
                open_backlog_ids: &empty,
                ..base
            }),
            ExpectDoneOrGateDecision::Pass
        );
    }

    #[test]
    fn blocked_closeout_followup_decision_warns_for_gated_blocked_directed_id() {
        let response = "### Re: do #374n\n\n#374n remains blocked until the legacy rows expire.";
        let directed_ids = vec![
            "374n".to_string(),
            "#374n".to_string(),
            "done1".to_string(),
            "kept1".to_string(),
            "ungated1".to_string(),
        ];
        let pending_kept_open_ids = vec!["kept1".to_string()];
        let pending_done_ids = vec!["done1".to_string()];
        let pending_gated_ids = vec![
            "374n".to_string(),
            "kept1".to_string(),
            "done1".to_string(),
            "ungated1".to_string(),
        ];
        let still_gated_ids = vec!["374n".to_string(), "done1".to_string()];

        assert_eq!(
            blocked_closeout_followup_decision(BlockedCloseoutFollowupEvidence {
                cycle_open: false,
                capture_committed: true,
                pending_added_this_cycle: false,
                response_body: response,
                directed_ids: &directed_ids,
                pending_kept_open_ids: &pending_kept_open_ids,
                pending_done_ids: &pending_done_ids,
                pending_gated_ids: &pending_gated_ids,
                still_gated_ids: &still_gated_ids,
            }),
            BlockedCloseoutFollowupDecision::Warn {
                unresolved_ids: vec!["374n".to_string()]
            }
        );
    }

    #[test]
    fn blocked_closeout_followup_decision_passes_without_required_state_or_signal() {
        let response = "### Re: do #374n\n\n#374n remains blocked until approval.";
        let suppressed = format!("{response}\n{BLOCKED_CLOSEOUT_FOLLOWUP_GUARD_SUPPRESS_MARKER}");
        let pending_done_suppressed = format!("{response}\n{PENDING_DONE_GUARD_SUPPRESS_MARKER}");
        let directed_ids = vec!["374n".to_string()];
        let pending_gated_ids = vec!["374n".to_string()];
        let still_gated_ids = vec!["374n".to_string()];
        let empty = Vec::new();
        let base = BlockedCloseoutFollowupEvidence {
            cycle_open: false,
            capture_committed: true,
            pending_added_this_cycle: false,
            response_body: response,
            directed_ids: &directed_ids,
            pending_kept_open_ids: &empty,
            pending_done_ids: &empty,
            pending_gated_ids: &pending_gated_ids,
            still_gated_ids: &still_gated_ids,
        };

        assert_eq!(
            blocked_closeout_followup_decision(BlockedCloseoutFollowupEvidence {
                cycle_open: true,
                ..base
            }),
            BlockedCloseoutFollowupDecision::Pass
        );
        assert_eq!(
            blocked_closeout_followup_decision(BlockedCloseoutFollowupEvidence {
                capture_committed: false,
                ..base
            }),
            BlockedCloseoutFollowupDecision::Pass
        );
        assert_eq!(
            blocked_closeout_followup_decision(BlockedCloseoutFollowupEvidence {
                pending_added_this_cycle: true,
                ..base
            }),
            BlockedCloseoutFollowupDecision::Pass
        );
        assert_eq!(
            blocked_closeout_followup_decision(BlockedCloseoutFollowupEvidence {
                response_body: "### Re: do #374n\n\nImplemented #374n and ready for review.",
                ..base
            }),
            BlockedCloseoutFollowupDecision::Pass
        );
        assert_eq!(
            blocked_closeout_followup_decision(BlockedCloseoutFollowupEvidence {
                response_body: "### Re: do #374n\n\nBlocked on an unrelated task; no mention in paragraph.",
                ..base
            }),
            BlockedCloseoutFollowupDecision::Pass
        );
        assert_eq!(
            blocked_closeout_followup_decision(BlockedCloseoutFollowupEvidence {
                response_body: "### Re: do #374n\n\n#374n remains blocked, but no additional backlog follow-up is needed because review owns it.",
                ..base
            }),
            BlockedCloseoutFollowupDecision::Pass
        );
        assert_eq!(
            blocked_closeout_followup_decision(BlockedCloseoutFollowupEvidence {
                response_body: &suppressed,
                ..base
            }),
            BlockedCloseoutFollowupDecision::Pass
        );
        assert_eq!(
            blocked_closeout_followup_decision(BlockedCloseoutFollowupEvidence {
                response_body: &pending_done_suppressed,
                ..base
            }),
            BlockedCloseoutFollowupDecision::Pass
        );
    }

    #[test]
    fn gated_phase_split_policy_detects_multi_phase_unsplit_body() {
        let body =
            "Remaining gated phases: phase (2b) live-verify the pane, phase (3) ship the rollout.";
        assert!(body_enumerates_multiple_gated_phases(body));
        assert_eq!(count_phase_markers(body), 2);
        assert!(!body_already_split_into_child_ids(body, "parentfix"));
    }

    #[test]
    fn gated_phase_split_policy_accepts_child_id_breakout() {
        let body = "Remaining gated phases tracked as children: phase (2b) -> #childb, phase (3) -> #childc. Plan: tasks/x.md";
        assert!(body_enumerates_multiple_gated_phases(body));
        assert!(body_already_split_into_child_ids(body, "parentfix"));
    }

    #[test]
    fn gated_phase_split_decision_warns_for_unsplit_kept_open_directed_item() {
        let response = "### Re: do #parentfix\n\nKept #parentfix open for the remaining phases.";
        let directed_ids = vec!["parentfix".to_string(), "#parentfix".to_string()];
        let pending_kept_open_ids = vec!["parentfix".to_string()];
        let tracked_items = vec![
            GatedPhaseSplitItemEvidence {
                id: "parentfix".to_string(),
                done: false,
                body: "Remaining gated phases: phase (2b) live-verify the pane, phase (3) ship the rollout.".to_string(),
            },
            GatedPhaseSplitItemEvidence {
                id: "parentfix".to_string(),
                done: false,
                body: "Remaining gated phases: phase (2b) live-verify the pane, phase (3) ship the rollout.".to_string(),
            },
            GatedPhaseSplitItemEvidence {
                id: "donechild".to_string(),
                done: true,
                body: "Remaining gated phases: phase (2b) live-verify, phase (3) ship.".to_string(),
            },
        ];

        assert_eq!(
            gated_phase_split_decision(GatedPhaseSplitEvidence {
                cycle_open: false,
                capture_committed: true,
                response_body: response,
                directed_ids: &directed_ids,
                pending_kept_open_ids: &pending_kept_open_ids,
                tracked_items: &tracked_items,
            }),
            GatedPhaseSplitDecision::Warn {
                flagged_ids: vec!["parentfix".to_string()]
            }
        );
    }

    #[test]
    fn gated_phase_split_decision_passes_without_required_state_or_unsplit_body() {
        let response = "### Re: do #parentfix\n\nKept #parentfix open for the remaining phases.";
        let suppressed = format!("{response}\n{GATED_PHASE_SPLIT_GUARD_SUPPRESS_MARKER}");
        let directed_ids = vec!["parentfix".to_string()];
        let pending_kept_open_ids = vec!["parentfix".to_string()];
        let tracked_items = vec![GatedPhaseSplitItemEvidence {
            id: "parentfix".to_string(),
            done: false,
            body: "Remaining gated phases: phase (2b) live-verify the pane, phase (3) ship the rollout.".to_string(),
        }];
        let split_items = vec![GatedPhaseSplitItemEvidence {
            id: "parentfix".to_string(),
            done: false,
            body: "Remaining gated phases tracked as children: phase (2b) -> #childb, phase (3) -> #childc.".to_string(),
        }];
        let base = GatedPhaseSplitEvidence {
            cycle_open: false,
            capture_committed: true,
            response_body: response,
            directed_ids: &directed_ids,
            pending_kept_open_ids: &pending_kept_open_ids,
            tracked_items: &tracked_items,
        };

        assert_eq!(
            gated_phase_split_decision(GatedPhaseSplitEvidence {
                cycle_open: true,
                ..base
            }),
            GatedPhaseSplitDecision::Pass
        );
        assert_eq!(
            gated_phase_split_decision(GatedPhaseSplitEvidence {
                capture_committed: false,
                ..base
            }),
            GatedPhaseSplitDecision::Pass
        );
        assert_eq!(
            gated_phase_split_decision(GatedPhaseSplitEvidence {
                response_body: &suppressed,
                ..base
            }),
            GatedPhaseSplitDecision::Pass
        );
        assert_eq!(
            gated_phase_split_decision(GatedPhaseSplitEvidence {
                pending_kept_open_ids: &[],
                ..base
            }),
            GatedPhaseSplitDecision::Pass
        );
        assert_eq!(
            gated_phase_split_decision(GatedPhaseSplitEvidence {
                tracked_items: &split_items,
                ..base
            }),
            GatedPhaseSplitDecision::Pass
        );
    }

    #[test]
    fn queue_audit_policy_flags_none_complete_collapse_with_substep_progress() {
        let lower = "none of the six queue items are complete. same-day qa is complete and the url validate-only check was clean, but each row still has at least one remaining action.";
        assert!(queue_audit_collapses_partial_completion(lower));
        assert!(queue_audit_has_none_complete_claim(lower));
    }

    #[test]
    fn queue_audit_policy_ignores_already_partial_or_unrelated_reports() {
        assert!(!queue_audit_collapses_partial_completion(
            "none of the queue items are fully complete, but several are partially complete: qa is complete and validate-only was clean."
        ));
        assert!(!queue_audit_collapses_partial_completion(
            "none of the migration steps are complete. schema dump is complete and backup was clean."
        ));
        assert!(!queue_audit_collapses_partial_completion(
            "none of the queue items are complete yet; every row is still blocked on input."
        ));
    }

    #[test]
    fn queue_audit_partial_completion_decision_warns_on_clear_collapse() {
        let response = "### Re: which queue items are complete?\n\nNone of the six queue items are complete. Same-day QA is complete and the URL validate-only check was clean, but each row still has at least one remaining action.";

        assert_eq!(
            queue_audit_partial_completion_decision(QueueAuditPartialCompletionEvidence {
                cycle_open: false,
                capture_committed: true,
                response_body: response,
            }),
            QueueAuditPartialCompletionDecision::Warn
        );
    }

    #[test]
    fn queue_audit_partial_completion_decision_passes_without_required_state_or_signal() {
        let response = "### Re: which queue items are complete?\n\nNone of the six queue items are complete. Same-day QA is complete and the URL validate-only check was clean.";
        let suppressed = format!("{response}\n{QUEUE_AUDIT_GUARD_SUPPRESS_MARKER}");

        assert_eq!(
            queue_audit_partial_completion_decision(QueueAuditPartialCompletionEvidence {
                cycle_open: true,
                capture_committed: true,
                response_body: response,
            }),
            QueueAuditPartialCompletionDecision::Pass
        );
        assert_eq!(
            queue_audit_partial_completion_decision(QueueAuditPartialCompletionEvidence {
                cycle_open: false,
                capture_committed: false,
                response_body: response,
            }),
            QueueAuditPartialCompletionDecision::Pass
        );
        assert_eq!(
            queue_audit_partial_completion_decision(QueueAuditPartialCompletionEvidence {
                cycle_open: false,
                capture_committed: true,
                response_body: "None of the migration steps are complete. The backup was clean.",
            }),
            QueueAuditPartialCompletionDecision::Pass
        );
        assert_eq!(
            queue_audit_partial_completion_decision(QueueAuditPartialCompletionEvidence {
                cycle_open: false,
                capture_committed: true,
                response_body: &suppressed,
            }),
            QueueAuditPartialCompletionDecision::Pass
        );
    }

    #[test]
    fn partial_closeout_policy_detects_shipped_with_remaining_live_work() {
        let lower = "committed and pushed the repo plus tests. live deploy and live verification remain; not deployed yet.";
        assert!(text_has_shipped_signal(lower));
        assert!(text_has_partial_remaining_signal(lower));

        assert!(text_has_shipped_signal("commit + push completed"));
        assert!(text_has_shipped_signal("commit and push completed"));
        assert!(!text_has_shipped_signal("committed locally only"));
        assert!(!text_has_partial_remaining_signal(
            "completed the full task with no external validation left"
        ));
    }

    #[test]
    fn partial_closeout_state_decision_warns_only_for_open_unresolved_directed_ids() {
        let response = "### Re: do #nstep2\n\nCommitted and pushed. Live verification remains; not deployed yet.";
        let directed_ids = vec![
            "nstep2".to_string(),
            "#nstep2".to_string(),
            "done1".to_string(),
            "reaped1".to_string(),
            "missing1".to_string(),
        ];
        let pending_done_ids = vec!["done1".to_string()];
        let reaped_pending_ids = vec!["reaped1".to_string()];
        let open_backlog_ids = vec![
            "nstep2".to_string(),
            "done1".to_string(),
            "reaped1".to_string(),
        ];

        assert_eq!(
            partial_closeout_state_decision(PartialCloseoutStateEvidence {
                cycle_open: false,
                capture_committed: true,
                response_body: response,
                directed_ids: &directed_ids,
                pending_done_ids: &pending_done_ids,
                reaped_pending_ids: &reaped_pending_ids,
                open_backlog_ids: &open_backlog_ids,
            }),
            PartialCloseoutStateDecision::Warn {
                candidate_ids: vec!["nstep2".to_string()]
            }
        );
    }

    #[test]
    fn partial_closeout_state_decision_passes_without_required_state_or_signal() {
        let response = "### Re: do #nstep2\n\nCommitted and pushed. Live verification remains; not deployed yet.";
        let suppressed = format!("{response}\n{PARTIAL_CLOSEOUT_GUARD_SUPPRESS_MARKER}");
        let directed_ids = vec!["nstep2".to_string()];
        let open_backlog_ids = vec!["nstep2".to_string()];
        let empty = Vec::new();
        let base = PartialCloseoutStateEvidence {
            cycle_open: false,
            capture_committed: true,
            response_body: response,
            directed_ids: &directed_ids,
            pending_done_ids: &empty,
            reaped_pending_ids: &empty,
            open_backlog_ids: &open_backlog_ids,
        };

        assert_eq!(
            partial_closeout_state_decision(PartialCloseoutStateEvidence {
                cycle_open: true,
                ..base
            }),
            PartialCloseoutStateDecision::Pass
        );
        assert_eq!(
            partial_closeout_state_decision(PartialCloseoutStateEvidence {
                capture_committed: false,
                ..base
            }),
            PartialCloseoutStateDecision::Pass
        );
        assert_eq!(
            partial_closeout_state_decision(PartialCloseoutStateEvidence {
                response_body: "Committed locally only. Nothing remains.",
                ..base
            }),
            PartialCloseoutStateDecision::Pass
        );
        assert_eq!(
            partial_closeout_state_decision(PartialCloseoutStateEvidence {
                response_body: &suppressed,
                ..base
            }),
            PartialCloseoutStateDecision::Pass
        );
    }

    #[test]
    fn response_text_for_guards_prefers_exchange_and_findings_patches() {
        let response = concat!(
            "<!-- replace:backlog -->\n- [ ] [#later] Follow-up\n<!-- /replace:backlog -->\n\n",
            "<!-- patch:exchange -->\n  Exchange closeout body.  \n<!-- /patch:exchange -->\n\n",
            "<!-- patch:findings -->\nFinding body.\n<!-- /patch:findings -->\n",
        );

        assert_eq!(
            response_text_for_guards(response),
            "Exchange closeout body.\n\nFinding body."
        );
    }

    #[test]
    fn response_text_for_guards_falls_back_to_unmatched_or_non_tracking_patch() {
        let unmatched = "Plain final answer.\n\n<!-- replace:backlog -->\n- [ ] [#later] Follow-up\n<!-- /replace:backlog -->\n";
        assert_eq!(response_text_for_guards(unmatched), "Plain final answer.");

        let status_only = concat!(
            "<!-- replace:backlog -->\n- [ ] [#later] Follow-up\n<!-- /replace:backlog -->\n\n",
            "<!-- patch:status -->\n  Shipped status text.  \n<!-- /patch:status -->\n",
        );
        assert_eq!(
            response_text_for_guards(status_only),
            "Shipped status text."
        );
    }

    #[test]
    fn exchange_prompt_matching_normalizes_prompt_prefixes() {
        let doc = concat!(
            "<!-- agent:exchange -->\n",
            "  ❯   Keep this prompt\n",
            "<!-- /agent:exchange -->\n"
        );

        assert_eq!(
            normalized_prompt_for_match(" ❯  Keep this prompt "),
            "Keep this prompt"
        );
        assert!(exchange_contains_prompt_line(doc, "Keep this prompt"));
        assert!(exchange_contains_prompt_line(doc, "❯ Keep this prompt"));
        assert!(!exchange_contains_prompt_line(doc, "Different prompt"));
    }

    #[test]
    fn still_missing_dropped_queue_prompts_keeps_preserved_or_consumed_prompts_clear() {
        let visible = concat!(
            "<!-- agent:exchange -->\n",
            "answered in exchange\n",
            "<!-- /agent:exchange -->\n",
            "<!-- agent:queue auto -->\n",
            "- ~~do [#struck]~~\n",
            "- preserved in queue\n",
            "<!-- /agent:queue -->\n",
        );
        let head = concat!(
            "<!-- agent:exchange -->\n",
            "<!-- /agent:exchange -->\n",
            "<!-- agent:queue auto -->\n",
            "<!-- /agent:queue -->\n",
        );
        let resolved = std::collections::HashSet::from(["done".to_string()]);
        let dropped = vec![
            "preserved in queue".to_string(),
            "answered in exchange".to_string(),
            "[#struck]".to_string(),
            "do [#done]".to_string(),
            "lost free text".to_string(),
        ];

        assert_eq!(
            still_missing_dropped_queue_prompts(visible, head, &dropped, &resolved),
            vec!["lost free text".to_string()]
        );
    }

    #[test]
    fn still_missing_dropped_queue_prompts_accepts_head_queue_id_preservation() {
        let visible = concat!(
            "<!-- agent:exchange -->\n",
            "<!-- /agent:exchange -->\n",
            "<!-- agent:queue auto -->\n",
            "<!-- /agent:queue -->\n",
        );
        let head = concat!(
            "<!-- agent:exchange -->\n",
            "<!-- /agent:exchange -->\n",
            "<!-- agent:queue auto -->\n",
            "- ~~do [#qpauseux]~~\n",
            "<!-- /agent:queue -->\n",
        );
        let dropped = vec!["[#qpauseux]".to_string()];
        let resolved = std::collections::HashSet::new();

        assert!(still_missing_dropped_queue_prompts(visible, head, &dropped, &resolved).is_empty());
    }

    #[test]
    fn still_missing_dropped_exchange_prompts_reports_only_absent_head_prompts() {
        let head = concat!(
            "<!-- agent:exchange -->\n",
            "  ❯ kept prompt\n",
            "<!-- /agent:exchange -->\n"
        );
        let dropped = vec!["kept prompt".to_string(), "lost prompt".to_string()];

        assert_eq!(
            still_missing_dropped_exchange_prompts(head, &dropped),
            vec!["lost prompt".to_string()]
        );
    }

    #[test]
    fn assistant_response_text_skips_headings_boundaries_comments_and_quotes() {
        let exchange = concat!(
            "Old prompt\n",
            "### Re: do [#abc]\n",
            "<!-- agent:boundary:abc -->\n",
            "<!-- internal comment -->\n",
            "> **Queue prompt:** do [#abc]\n",
            "Implemented the answer.\n",
            "\n",
            "#### Re: follow-up\n",
            "Verified the follow-up.\n"
        );

        assert_eq!(
            assistant_response_text(exchange),
            "Implemented the answer.\nVerified the follow-up.\n"
        );
    }

    #[test]
    fn response_heading_classifiers_distinguish_queue_continuations() {
        assert!(is_exchange_response_heading("## Assistant"));
        assert!(is_exchange_response_heading("### Re: user prompt"));
        assert!(is_exchange_response_heading("###### Re: deep prompt"));
        assert!(is_queue_continuation_response_heading(
            "### Re: do [#abc] finish task"
        ));
        assert!(is_queue_continuation_response_heading(
            "#### Re: re [#abc] follow-up"
        ));
        assert!(!is_queue_continuation_response_heading(
            "### Re: user free-text prompt"
        ));
        assert!(!is_queue_continuation_response_heading("## Assistant"));
    }

    #[test]
    fn line_is_carry_forward_signal_detects_markdown_queue_entries() {
        assert!(line_is_carry_forward_signal("- do [#advance-review]"));
        assert!(line_is_carry_forward_signal(
            "- :pushpin: do [#advance-review]"
        ));
        assert!(line_is_carry_forward_signal(
            "- :pushpin: [#advance-review] Review the next item"
        ));
        assert!(line_is_carry_forward_signal(
            "- [ ] [#advance-review] Review the next item"
        ));
        assert!(!line_is_carry_forward_signal("- ordinary note"));
    }

    #[test]
    fn direct_response_patchback_heading_policy_counts_new_markers() {
        let before = "## Exchange\n\n### Re: old topic\n\nOld answer.\n";
        let after =
            "## Exchange\n\n### Re: old topic\n\nOld answer.\n\n### Re: new topic\n\nNew answer.\n";

        assert!(is_direct_response_patchback_heading("## Assistant"));
        assert!(is_direct_response_patchback_heading("### Re: new topic"));
        assert!(!is_direct_response_patchback_heading(
            "#### Re: nested heading"
        ));
        assert!(has_new_response_heading_marker(before, after));
        assert!(!has_new_response_heading_marker(after, before));
    }

    #[test]
    fn binary_recovery_diagnostic_headings_are_exempted_by_shape() {
        assert!(is_binary_authored_recovery_diagnostic_heading(
            "### Re: interrupted-cycle recovery for file.md"
        ));
        assert!(is_binary_authored_recovery_diagnostic_heading(
            "##### Re: interrupted-cycle recovery for file.md"
        ));
        assert!(!is_binary_authored_recovery_diagnostic_heading(
            "### Re: ordinary user answer"
        ));
        assert!(!is_binary_authored_recovery_diagnostic_heading(
            "## Assistant interrupted-cycle recovery"
        ));
    }

    #[test]
    fn reaped_directive_response_loss_ignores_unreaped_directive() {
        let directive = vec!["pending".to_string()];
        let reaped: Vec<String> = Vec::new();
        let content = "### Re: prior — gpt-5\n\nAnswered.\n";
        let archives: Vec<String> = Vec::new();
        assert!(
            reaped_directive_ids_without_response(&loss_input(
                &directive, &reaped, content, &archives
            ))
            .is_empty(),
            "an unreaped directive id is not a loss"
        );
    }

    /// `#reheadingidoptional`: this used to pin the multi-directive
    /// single-heading FALSE POSITIVE — `#b`, answered in the body of a
    /// `### Re: do #a` response, was reported lost because only the heading was
    /// scanned. Section-scoped matching resolves it, so neither id is a loss.
    #[test]
    fn reaped_directive_response_loss_accepts_multi_directive_single_heading() {
        let directive = vec!["a".to_string(), "b".to_string()];
        let reaped = vec!["a".to_string(), "b".to_string()];
        let single_heading = "### Re: do #a — opus-4-8\n\nFixed #a; also addressed #b inline.\n";
        let archives: Vec<String> = Vec::new();
        assert!(
            reaped_directive_ids_without_response(&loss_input(
                &directive,
                &reaped,
                single_heading,
                &archives
            ))
            .is_empty(),
            "both ids are answered under the one heading; neither is lost"
        );

        let grouped_heading = "### Re: do #a, #b — opus-4-8\n\nFixed both.\n";
        assert!(
            reaped_directive_ids_without_response(&loss_input(
                &directive,
                &reaped,
                grouped_heading,
                &archives
            ))
            .is_empty(),
            "a grouped heading naming both ids is not a loss"
        );
    }

    #[test]
    fn response_completion_requires_matching_heading_and_completion_marker() {
        let response = "### Re: do [#alpha] — gpt-5\n\nImplemented the fix.\n\nRelated to #beta.";
        assert!(response_clearly_completes_pending_id(response, "alpha"));
        assert!(!response_clearly_completes_pending_id(response, "beta"));
        assert!(!response_clearly_completes_pending_id(
            "### Re: do [#alpha]\n\nBlocked on CI.",
            "alpha"
        ));
    }

    #[test]
    fn response_heading_resolution_uses_heading_topic_not_body_mentions() {
        assert!(response_heading_resolves_to_pending_id(
            "### Re: do [#a] [#b]\n\nDone.",
            "b"
        ));
        assert!(response_heading_resolves_to_pending_id(
            "### Re: #lead descriptive text\n\nDone.",
            "lead"
        ));
        assert!(!response_heading_resolves_to_pending_id(
            "### Re: unrelated #body\n\nDone #body.",
            "body"
        ));
    }

    #[test]
    fn done_signals_extract_explicit_ids_and_plain_single_review_signals() {
        assert_eq!(explicit_done_signal_ids("❯ #abc done"), vec!["abc"]);
        assert_eq!(
            explicit_done_signal_ids("- complete #abc and #def"),
            vec!["abc", "def"]
        );
        assert!(plain_done_signal("❯ done."));
        assert!(!plain_done_signal("done #abc"));
        assert!(explicit_done_signal_ids("look at #abc").is_empty());
    }

    #[test]
    fn inline_done_signal_ids_filters_to_open_tracked_work() {
        let prompts = vec![
            "❯ #Alpha done".to_string(),
            "- complete #missing and #Beta".to_string(),
            "done".to_string(),
        ];
        let open = vec![
            "alpha".to_string(),
            "beta".to_string(),
            "review".to_string(),
        ];

        assert_eq!(
            inline_done_signal_ids(&prompts, &open, Some("review"), true),
            vec![
                "alpha".to_string(),
                "beta".to_string(),
                "review".to_string()
            ]
        );
    }

    #[test]
    fn inline_done_signal_ids_requires_auto_done_for_plain_done() {
        let prompts = vec!["done".to_string()];
        let open = vec!["review".to_string()];

        assert!(inline_done_signal_ids(&prompts, &open, Some("review"), false).is_empty());
        assert!(inline_done_signal_ids(&prompts, &open, None, true).is_empty());
    }
}
