//! Agent-doc session memory indexing and retrieval.
//!
//! This is the first agent-doc consumer of the shared `tsift-memory` crate:
//! the CLI writes tracked session work into `.tsift/memory.db` and performs a
//! deterministic lexical ranking pass over current + persisted events. The
//! ranking is intentionally local and cheap; heavier codebase indexing stays
//! in the tsift CLI.

use agent_doc_document::queue_projection::strip_in_progress_marker;
use agent_doc_memory::{
    CompletionCandidate, MemorySearchResult, QueueStrikeMatch, SemanticCompletionMatch,
    bump_component_count, count_insert_results, dedupe_events, display_path,
    parse_done_archive_items, queue_prompt_target_id, rank_events, response_summary_events,
    semantic_completion_matches as match_semantic_completions,
    semantic_queue_strike_matches as match_semantic_queue_strikes, tracked_work_events,
};
#[cfg(test)]
use agent_doc_memory::{
    QUEUE_STRIKE_THRESHOLD, QueueStrikeMatchKind, format_semantic_completion_warning,
};
use anyhow::{Context, Result, bail};
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[cfg(test)]
use tsift_memory::MemoryEventKind;
use tsift_memory::{
    MEMORY_CONTRACT_VERSION, MemoryEvent, MemoryStore, default_memory_db_path, read_memory_events,
};

use agent_doc_element::element;
use agent_doc_element_backlog::backlog::{self, PendingState};

const MAX_EVENT_READ_LIMIT: usize = 20_000;

#[derive(Debug, Clone, Serialize)]
pub struct MemoryIndexReport {
    pub contract_version: String,
    pub session_path: String,
    pub memory_db_path: String,
    pub planned_events: usize,
    pub inserted_events: usize,
    pub already_present_events: usize,
    pub component_counts: BTreeMap<String, usize>,
    pub warnings: Vec<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct MemorySearchReport {
    pub contract_version: String,
    pub session_path: String,
    pub memory_db_path: String,
    pub query: String,
    pub indexed_current_events: usize,
    pub searched_events: usize,
    pub results: Vec<MemorySearchResult>,
    pub warnings: Vec<String>,
}

#[derive(Debug, Clone)]
struct SessionEvents {
    events: Vec<MemoryEvent>,
    component_counts: BTreeMap<String, usize>,
    warnings: Vec<String>,
}

pub fn run_index(file: &Path, db: Option<&Path>, json: bool) -> Result<()> {
    let db_path = resolve_memory_db_path(file, db)?;
    let report = index_session(file, &db_path)?;
    if json {
        println!("{}", serde_json::to_string_pretty(&report)?);
    } else {
        println!(
            "Indexed {} event(s) into {} ({} inserted, {} already present)",
            report.planned_events,
            report.memory_db_path,
            report.inserted_events,
            report.already_present_events
        );
        for (component, count) in &report.component_counts {
            println!("- {component}: {count}");
        }
        for warning in &report.warnings {
            eprintln!("[memory] warning: {warning}");
        }
    }
    Ok(())
}

pub fn run_search(
    file: &Path,
    query: &str,
    db: Option<&Path>,
    limit: usize,
    json: bool,
    rebuild: bool,
) -> Result<()> {
    let db_path = resolve_memory_db_path(file, db)?;
    let report = search_session_memory(file, &db_path, query, limit, rebuild)?;
    if json {
        println!("{}", serde_json::to_string_pretty(&report)?);
    } else {
        println!(
            "Memory search for {:?} in {}",
            report.query, report.memory_db_path
        );
        if report.results.is_empty() {
            println!("No matches.");
        }
        for result in &report.results {
            let item = result
                .item_id
                .as_deref()
                .map(|id| format!(" #{id}"))
                .unwrap_or_default();
            let component = result
                .component
                .as_deref()
                .map(|c| format!(" [{c}]"))
                .unwrap_or_default();
            println!(
                "- {:.3}{}{} {}",
                result.score, component, item, result.source_ref
            );
            println!("  {}", result.text.replace('\n', " "));
        }
        for warning in &report.warnings {
            eprintln!("[memory] warning: {warning}");
        }
    }
    Ok(())
}

pub fn index_session(file: &Path, db_path: &Path) -> Result<MemoryIndexReport> {
    let session = collect_session_events(file)?;
    let mut store = MemoryStore::open_or_create(db_path)?;
    let results = store.insert_events(&session.events)?;
    let (inserted_events, already_present_events) = count_insert_results(&results);
    Ok(MemoryIndexReport {
        contract_version: MEMORY_CONTRACT_VERSION.to_string(),
        session_path: display_path(file),
        memory_db_path: display_path(db_path),
        planned_events: session.events.len(),
        inserted_events,
        already_present_events,
        component_counts: session.component_counts,
        warnings: session.warnings,
    })
}

pub fn search_session_memory(
    file: &Path,
    db_path: &Path,
    query: &str,
    limit: usize,
    rebuild: bool,
) -> Result<MemorySearchReport> {
    if query.trim().is_empty() {
        bail!("memory search query must not be empty");
    }
    if rebuild {
        let _ = index_session(file, db_path)?;
    }

    let session = collect_session_events(file)?;
    let mut events = read_memory_events(db_path, MAX_EVENT_READ_LIMIT)?;
    events.extend(session.events.clone());
    let events = dedupe_events(events);
    let mut results = rank_events(query, &events);
    results.truncate(limit.max(1));

    Ok(MemorySearchReport {
        contract_version: MEMORY_CONTRACT_VERSION.to_string(),
        session_path: display_path(file),
        memory_db_path: display_path(db_path),
        query: query.to_string(),
        indexed_current_events: session.events.len(),
        searched_events: events.len(),
        results,
        warnings: session.warnings,
    })
}

pub fn semantic_completion_matches(
    file: &Path,
    db: Option<&Path>,
    limit: usize,
) -> Result<Vec<SemanticCompletionMatch>> {
    let candidates = collect_completion_candidates(file)?;
    if candidates.is_empty() {
        return Ok(Vec::new());
    }

    let db_path = resolve_memory_db_path(file, db)?;
    let session = collect_session_events(file)?;
    let mut events = read_memory_events(&db_path, MAX_EVENT_READ_LIMIT)?;
    events.extend(session.events.clone());
    let events = dedupe_events(events);
    Ok(match_semantic_completions(&candidates, &events, limit))
}

/// Deterministically score every LIVE free-text `agent:queue` head against BOTH
/// the completed `agent:done` archive (case **a**: already complete) AND the
/// active `agent:backlog` items (case **b**: tracked by a backlog item), and
/// return the best match per head that clears `threshold` (`#qftbklgstrike`).
///
/// This is the auto-strike sibling of [`semantic_completion_matches`] (which only
/// scores against done and only emits a *warning*). Both reuse the same
/// [`rank_events`] lexical scorer, so the behavior stays deterministic (no LLM).
/// Id-backed heads are skipped here (they have their own done-strike path); only
/// free-text heads (no `#id`) are candidates.
pub fn semantic_queue_strike_matches(
    file: &Path,
    db: Option<&Path>,
    threshold: f64,
    limit: usize,
) -> Result<Vec<QueueStrikeMatch>> {
    let candidates = collect_completion_candidates(file)?;
    if candidates.is_empty() {
        return Ok(Vec::new());
    }

    let db_path = resolve_memory_db_path(file, db)?;
    let session = collect_session_events(file)?;
    let mut events = read_memory_events(&db_path, MAX_EVENT_READ_LIMIT)?;
    events.extend(session.events.clone());
    let events = dedupe_events(events);
    Ok(match_semantic_queue_strikes(
        &candidates,
        &events,
        threshold,
        limit,
    ))
}

fn collect_completion_candidates(file: &Path) -> Result<Vec<CompletionCandidate>> {
    let content = std::fs::read_to_string(file)
        .with_context(|| format!("failed to read session document {}", file.display()))?;
    let canonical = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
    let session_ref = display_path(&canonical);
    let components = element::parse(&content).context("failed to parse session components")?;
    let mut candidates = Vec::new();

    for comp in &components {
        let body = comp.content(&content);
        if element::is_backlog_component(&comp.name) || element::is_review_component(&comp.name) {
            let (_, items, _) = backlog::parse_items(body);
            for item in items {
                if item.state == PendingState::Done || item.text.trim().is_empty() {
                    continue;
                }
                let item_id = (!item.id.is_empty()).then_some(item.id.clone());
                let id_ref = item_id.clone().unwrap_or_else(|| {
                    format!(
                        "anon:{}",
                        agent_doc_fs::document_state_hash_from_str(item.text.trim())
                    )
                });
                let mut text = item.text.trim().to_string();
                if !item.continuation.trim().is_empty() {
                    text.push('\n');
                    text.push_str(item.continuation.trim());
                }
                candidates.push(CompletionCandidate {
                    source: comp.name.clone(),
                    source_ref: format!("{session_ref}#{}:{id_ref}", comp.name),
                    item_id,
                    text,
                    queue_index: None,
                    exclude_from_queue_strike: false,
                });
            }
        } else if comp.name == "queue" {
            let Ok(entries) = agent_doc_queue::document_queue::parse(body) else {
                continue;
            };
            for (index, entry) in entries.iter().enumerate() {
                let agent_doc_queue::document_queue::QueueEntry::Prompt(prompt) = entry else {
                    continue;
                };
                if queue_prompt_target_id(&prompt.text).is_some() || prompt.text.trim().is_empty() {
                    continue;
                }
                // #qftstuck: the cosmetic `🚧` in-progress marker is not part of
                // the head's prose identity. Strip it so the deterministic strike
                // scorer (and the downstream `gate_norm` identity match in
                // `run_queue_maintenance`) sees the same clean prose whether or not
                // the head is currently the in-progress head — otherwise an
                // in-progress head can never be matched/struck and the marker
                // lingers on a now-redundant head forever.
                let text = strip_in_progress_marker(prompt.text.trim());
                candidates.push(CompletionCandidate {
                    source: "queue".to_string(),
                    source_ref: format!("{session_ref}#queue:{index}"),
                    item_id: None,
                    exclude_from_queue_strike:
                        agent_doc_queue::queue_continuation::is_recurring_imperative_head(&text),
                    text,
                    queue_index: Some(index),
                });
            }
        }
    }

    Ok(candidates)
}

fn collect_session_events(file: &Path) -> Result<SessionEvents> {
    let content = std::fs::read_to_string(file)
        .with_context(|| format!("failed to read session document {}", file.display()))?;
    let canonical = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
    let doc_hash = agent_doc_fs::document_state_hash(&canonical).unwrap_or_else(|_| {
        agent_doc_fs::document_state_hash_from_str(&canonical.to_string_lossy())
    });
    let session_ref = display_path(&canonical);
    let components = element::parse(&content).context("failed to parse session components")?;
    let mut events = Vec::new();
    let mut component_counts = BTreeMap::new();
    let mut warnings = Vec::new();

    for comp in &components {
        let body = comp.content(&content);
        if element::is_backlog_component(&comp.name)
            || element::is_review_component(&comp.name)
            || element::is_icebox_component(&comp.name)
        {
            let state_override = None;
            let (_, items, _) = backlog::parse_items(body);
            let new_events =
                tracked_work_events(&session_ref, &doc_hash, &comp.name, items, state_override);
            bump_component_count(&mut component_counts, &comp.name, new_events.len());
            events.extend(new_events);
        } else if element::is_backlog_done_component(&comp.name) {
            let (_, items, _) = backlog::parse_items(body);
            let new_events = tracked_work_events(
                &session_ref,
                &doc_hash,
                &comp.name,
                items,
                Some(PendingState::Done),
            );
            bump_component_count(&mut component_counts, &comp.name, new_events.len());
            events.extend(new_events);
            if let Some(archive) = comp.attrs.get("archive") {
                match read_done_archive(file, archive) {
                    Ok(Some((archive_path, archive_content))) => {
                        let source = format!("{}", archive_path.display());
                        let archive_items = parse_done_archive_items(&archive_content);
                        let archive_events = if archive_items.is_empty() {
                            let (_, items, _) = backlog::parse_items(&archive_content);
                            tracked_work_events(
                                &source,
                                &doc_hash,
                                &comp.name,
                                items,
                                Some(PendingState::Done),
                            )
                        } else {
                            tracked_work_events(
                                &source,
                                &doc_hash,
                                &comp.name,
                                archive_items,
                                Some(PendingState::Done),
                            )
                        };
                        bump_component_count(
                            &mut component_counts,
                            &format!("{}:archive", comp.name),
                            archive_events.len(),
                        );
                        events.extend(archive_events);
                    }
                    Ok(None) => warnings.push(format!("done archive not found: {archive}")),
                    Err(err) => warnings.push(err.to_string()),
                }
            }
        } else if comp.name == "exchange" {
            let new_events = response_summary_events(&session_ref, &doc_hash, body);
            bump_component_count(&mut component_counts, &comp.name, new_events.len());
            events.extend(new_events);
        }
    }

    Ok(SessionEvents {
        events,
        component_counts,
        warnings,
    })
}

fn read_done_archive(file: &Path, archive: &str) -> Result<Option<(PathBuf, String)>> {
    if archive.trim().is_empty() {
        bail!("agent:done archive= must not be empty");
    }
    if !archive.ends_with(".done.md") {
        bail!("agent:done archive={archive} must point to a .done.md file");
    }
    let relative = Path::new(archive);
    if relative.is_absolute()
        || relative.components().any(|component| {
            matches!(
                component,
                std::path::Component::ParentDir
                    | std::path::Component::RootDir
                    | std::path::Component::Prefix(_)
            )
        })
    {
        bail!("agent:done archive={archive} must be repo-relative");
    }
    let root = agent_doc_fs::find_project_root_canonical(file)
        .with_context(|| format!("failed to find project root for {}", file.display()))?;
    let target = root.join(relative);
    if let Ok(canonical_target) = target.canonicalize()
        && !canonical_target.starts_with(&root)
    {
        bail!("agent:done archive={archive} resolves outside the project root");
    }
    match std::fs::read_to_string(&target) {
        Ok(content) => Ok(Some((target, content))),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(err) => Err(err).with_context(|| format!("failed to read {}", target.display())),
    }
}

fn resolve_memory_db_path(file: &Path, db: Option<&Path>) -> Result<PathBuf> {
    if let Some(db) = db {
        return Ok(db.to_path_buf());
    }
    let root = agent_doc_fs::find_project_root_canonical(file)
        .or_else(|| std::env::current_dir().ok())
        .with_context(|| format!("failed to resolve memory DB root for {}", file.display()))?;
    Ok(default_memory_db_path(&root))
}

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

    fn write_doc(root: &Path, body: &str) -> PathBuf {
        std::fs::create_dir_all(root.join(".agent-doc")).unwrap();
        let doc_dir = root.join("tasks");
        std::fs::create_dir_all(&doc_dir).unwrap();
        let doc = doc_dir.join("session.md");
        std::fs::write(&doc, body).unwrap();
        doc
    }

    #[test]
    fn collect_session_events_reads_backlog_review_done_archive_and_exchange() {
        let tmp = tempdir().unwrap();
        std::fs::write(
            tmp.path().join("tasks.done.md"),
            "- 2026-06-07 [#oldfix] historical cache fix\n",
        )
        .unwrap();
        let doc = write_doc(
            tmp.path(),
            r#"---
agent_doc_session: test
---

## Exchange
<!-- agent:exchange -->
### Re: cache fix

Shipped cache repair.
<!-- /agent:exchange -->

## Backlog
<!-- agent:backlog -->
- [ ] [#cachefix] Repair cache duplication
<!-- /agent:backlog -->

## Review
<!-- agent:review -->
- [/] [#shipcheck] Await release verification
<!-- /agent:review -->

<!-- agent:done archive=tasks.done.md -->
<!-- /agent:done -->
"#,
        );

        let session = collect_session_events(&doc).unwrap();
        assert!(
            session
                .events
                .iter()
                .any(|event| event.metadata.get("item_id") == Some(&"cachefix".to_string()))
        );
        assert!(
            session
                .events
                .iter()
                .any(|event| event.metadata.get("item_id") == Some(&"shipcheck".to_string()))
        );
        assert!(
            session
                .events
                .iter()
                .any(|event| event.metadata.get("item_id") == Some(&"oldfix".to_string()))
        );
        assert!(
            session
                .events
                .iter()
                .any(|event| event.kind == MemoryEventKind::ResponseSummary)
        );
    }

    #[test]
    fn search_ranks_current_tracked_work_by_query_terms() {
        let tmp = tempdir().unwrap();
        let doc = write_doc(
            tmp.path(),
            r#"
<!-- agent:backlog -->
- [ ] [#memrag] Add semantic retrieval over backlog history
- [ ] [#routes] Fix tmux route timeout
<!-- /agent:backlog -->
"#,
        );
        let db = tmp.path().join(".tsift/memory.db");
        let report = search_session_memory(&doc, &db, "semantic backlog retrieval", 5, true)
            .expect("search should succeed");
        assert_eq!(
            report.results.first().and_then(|r| r.item_id.as_deref()),
            Some("memrag")
        );
    }

    #[test]
    fn semantic_completion_matches_done_archive_for_free_text_queue_prompt() {
        let tmp = tempdir().unwrap();
        std::fs::write(
            tmp.path().join("tasks.done.md"),
            "- 2026-06-07 [#cachefix] Repair cache duplication\n",
        )
        .unwrap();
        let doc = write_doc(
            tmp.path(),
            r#"
<!-- agent:queue auto -->
- Repair cache duplication
<!-- /agent:queue -->

<!-- agent:done archive=tasks.done.md -->
<!-- /agent:done -->
"#,
        );

        let db = tmp.path().join(".tsift/memory.db");
        let matches = semantic_completion_matches(&doc, Some(&db), 5).unwrap();
        let first = matches.first().expect("expected semantic match");
        assert_eq!(first.candidate_source, "queue");
        assert_eq!(first.candidate_id, None);
        assert_eq!(first.matched_done_id.as_deref(), Some("cachefix"));
        assert!(first.score >= 0.8, "{first:?}");
        assert!(
            format_semantic_completion_warning(first).contains("semantic completion candidate")
        );
    }

    #[test]
    fn queue_strike_matches_done_archive_above_threshold() {
        // #qftbklgstrike case (a): a free-text queue head that restates a
        // completed `agent:done` archive item matches with kind=Done above the
        // conservative strike threshold.
        let tmp = tempdir().unwrap();
        std::fs::write(
            tmp.path().join("tasks.done.md"),
            "- 2026-06-07 [#cachefix] Repair cache duplication on save\n",
        )
        .unwrap();
        let doc = write_doc(
            tmp.path(),
            r#"
<!-- agent:queue auto -->
- Repair cache duplication on save
<!-- /agent:queue -->

<!-- agent:done archive=tasks.done.md -->
<!-- /agent:done -->
"#,
        );
        let db = tmp.path().join(".tsift/memory.db");
        let matches =
            semantic_queue_strike_matches(&doc, Some(&db), QUEUE_STRIKE_THRESHOLD, 5).unwrap();
        let first = matches.first().expect("expected a done strike match");
        assert_eq!(first.matched_kind, QueueStrikeMatchKind::Done);
        assert_eq!(first.matched_id.as_deref(), Some("cachefix"));
        assert!(
            first.score >= QUEUE_STRIKE_THRESHOLD,
            "score {} must clear threshold {QUEUE_STRIKE_THRESHOLD}: {first:?}",
            first.score
        );
    }

    #[test]
    fn queue_strike_matches_active_backlog_above_threshold() {
        // #qftbklgstrike case (b): a free-text queue head that restates an active
        // `agent:backlog` item matches with kind=Backlog above threshold.
        let tmp = tempdir().unwrap();
        let doc = write_doc(
            tmp.path(),
            r#"
<!-- agent:queue auto -->
- Opencode responses are reverse ordering numeric lists
<!-- /agent:queue -->

<!-- agent:backlog -->
- [ ] [#revlist] Opencode responses are reverse ordering numeric lists
<!-- /agent:backlog -->
"#,
        );
        let db = tmp.path().join(".tsift/memory.db");
        let matches =
            semantic_queue_strike_matches(&doc, Some(&db), QUEUE_STRIKE_THRESHOLD, 5).unwrap();
        let first = matches.first().expect("expected a backlog strike match");
        assert_eq!(first.matched_kind, QueueStrikeMatchKind::Backlog);
        assert_eq!(first.matched_id.as_deref(), Some("revlist"));
        assert!(
            first.score >= QUEUE_STRIKE_THRESHOLD,
            "score {} must clear threshold: {first:?}",
            first.score
        );
    }

    #[test]
    fn queue_strike_does_not_match_unrelated_operator_prompt() {
        // #qftbklgstrike false-strike safety: an unrelated operator prompt that
        // is NOT a restatement of any done/backlog item never reaches the
        // conservative threshold, so it is never returned for a strike.
        let tmp = tempdir().unwrap();
        std::fs::write(
            tmp.path().join("tasks.done.md"),
            "- 2026-06-07 [#cachefix] Repair cache duplication on save\n",
        )
        .unwrap();
        let doc = write_doc(
            tmp.path(),
            r#"
<!-- agent:queue auto -->
- Please add a dark mode toggle to the settings panel
<!-- /agent:queue -->

<!-- agent:backlog -->
- [ ] [#revlist] Opencode responses are reverse ordering numeric lists
<!-- /agent:backlog -->

<!-- agent:done archive=tasks.done.md -->
<!-- /agent:done -->
"#,
        );
        let db = tmp.path().join(".tsift/memory.db");
        let matches =
            semantic_queue_strike_matches(&doc, Some(&db), QUEUE_STRIKE_THRESHOLD, 5).unwrap();
        assert!(
            matches.is_empty(),
            "unrelated operator prompt must NOT be struck: {matches:?}"
        );
    }

    #[test]
    fn queue_strike_skips_id_backed_heads() {
        // #qftbklgstrike: id-backed heads have their own done-strike path and must
        // never be returned by the free-text strike scorer.
        let tmp = tempdir().unwrap();
        std::fs::write(
            tmp.path().join("tasks.done.md"),
            "- 2026-06-07 [#cachefix] Repair cache duplication on save\n",
        )
        .unwrap();
        let doc = write_doc(
            tmp.path(),
            r#"
<!-- agent:queue auto -->
- do [#cachefix] Repair cache duplication on save
<!-- /agent:queue -->

<!-- agent:done archive=tasks.done.md -->
<!-- /agent:done -->
"#,
        );
        let db = tmp.path().join(".tsift/memory.db");
        let matches =
            semantic_queue_strike_matches(&doc, Some(&db), QUEUE_STRIKE_THRESHOLD, 5).unwrap();
        assert!(
            matches.is_empty(),
            "id-backed head must be skipped by the free-text strike scorer: {matches:?}"
        );
    }

    #[test]
    fn queue_strike_exempts_recurring_imperative_deploy_head() {
        // #qimpstrike: a recurring imperative command head (`deploy`) is an
        // executable directive, NOT a restatement of tracked work — it must not be
        // auto-struck even when a lexically-close done item containing the word
        // "deploy" exists above the strike threshold.
        let tmp = tempdir().unwrap();
        std::fs::write(
            tmp.path().join("tasks.done.md"),
            "- 2026-06-07 [#do-id-closeout-open-backlog] commit + push + deploy the worker\n",
        )
        .unwrap();
        let doc = write_doc(
            tmp.path(),
            r#"
<!-- agent:queue auto -->
- deploy
<!-- /agent:queue -->

<!-- agent:done archive=tasks.done.md -->
<!-- /agent:done -->
"#,
        );
        let db = tmp.path().join(".tsift/memory.db");
        let matches =
            semantic_queue_strike_matches(&doc, Some(&db), QUEUE_STRIKE_THRESHOLD, 5).unwrap();
        assert!(
            matches.is_empty(),
            "recurring-imperative `deploy` head must NOT be struck as done: {matches:?}"
        );
    }

    #[test]
    fn queue_strike_exempts_recurring_command_preset_token_head() {
        // #qimpstrike: a recurring-command preset token (`#spec-test-commit-push`)
        // is an executable directive and must not be struck by a lexically-close
        // done item.
        let tmp = tempdir().unwrap();
        std::fs::write(
            tmp.path().join("tasks.done.md"),
            "- 2026-06-07 [#shiplast] spec test commit push the release\n",
        )
        .unwrap();
        let doc = write_doc(
            tmp.path(),
            r#"
<!-- agent:queue auto -->
- #spec-test-commit-push
<!-- /agent:queue -->

<!-- agent:done archive=tasks.done.md -->
<!-- /agent:done -->
"#,
        );
        let db = tmp.path().join(".tsift/memory.db");
        let matches =
            semantic_queue_strike_matches(&doc, Some(&db), QUEUE_STRIKE_THRESHOLD, 5).unwrap();
        assert!(
            matches.is_empty(),
            "recurring-command preset head must NOT be struck as done: {matches:?}"
        );
    }

    #[test]
    fn queue_strike_still_strikes_legitimate_restatement_with_deploy_word() {
        // #qimpstrike non-regression: a multi-word free-text head that genuinely
        // restates a specific completed tracked task STILL auto-strikes, even when
        // it happens to contain a recurring-command verb. The exemption is for the
        // recurring-imperative subclass (short, verb-led), not for any prose that
        // mentions `deploy`/`commit`/`push`.
        let tmp = tempdir().unwrap();
        std::fs::write(
            tmp.path().join("tasks.done.md"),
            "- 2026-06-07 [#deployretry] fix the deploy script so it retries on a transient 500\n",
        )
        .unwrap();
        let doc = write_doc(
            tmp.path(),
            r#"
<!-- agent:queue auto -->
- fix the deploy script so it retries on a transient 500
<!-- /agent:queue -->

<!-- agent:done archive=tasks.done.md -->
<!-- /agent:done -->
"#,
        );
        let db = tmp.path().join(".tsift/memory.db");
        let matches =
            semantic_queue_strike_matches(&doc, Some(&db), QUEUE_STRIKE_THRESHOLD, 5).unwrap();
        let first = matches
            .first()
            .expect("legitimate multi-word restatement must still be struck");
        assert_eq!(first.matched_kind, QueueStrikeMatchKind::Done);
        assert_eq!(first.matched_id.as_deref(), Some("deployretry"));
    }

    #[test]
    fn index_session_writes_tsift_memory_events() {
        let tmp = tempdir().unwrap();
        let doc = write_doc(
            tmp.path(),
            r#"
<!-- agent:backlog -->
- [ ] [#dedupe] Detect duplicate backlog item
<!-- /agent:backlog -->
"#,
        );
        let db = tmp.path().join(".tsift/memory.db");
        let report = index_session(&doc, &db).unwrap();
        assert_eq!(report.inserted_events, 1);
        let events = read_memory_events(&db, 10).unwrap();
        assert_eq!(events.len(), 1);
        assert_eq!(
            events[0].metadata.get("agent_doc_surface"),
            Some(&"tracked_work".to_string())
        );
    }
}
