Skip to main content

agent_doc_core/
diff.rs

1//! # Module: diff
2//!
3//! ## Spec
4//! - `strip_comments(content)` removes `[//]: # (...)` link-reference comments and
5//!   `<!-- ... -->` HTML comments from document content, while preserving agent
6//!   range markers (`<!-- agent:* -->`). Comment patterns inside fenced code blocks
7//!   and inline backtick spans are not treated as comment syntax.
8//! - `compute(doc)` reads the on-disk snapshot for `doc`, waits for stable content
9//!   via `wait_for_stable_content`, strips comments from both sides, and returns a
10//!   unified diff (5-line context, header `snapshot`→`document`) or `None` when
11//!   there are no changes. Stale-snapshot recovery fires before emitting a diff:
12//!   if the delta contains only completed assistant/user exchanges with an empty
13//!   trailing user block, the snapshot is synced to the document and `None` is returned.
14//! - `wait_for_stable_content(doc, previous)` polls the document file until the
15//!   last inserted line looks complete (not mid-word/mid-URL) or up to 12 × 500 ms
16//!   rechecks (~6 s). Three consecutive identical reads constitute "stable".
17//! - `is_stale_snapshot(snapshot, document)` returns `true` when the document is a
18//!   superset of the snapshot, the extra content contains at least one `## Assistant`
19//!   block, and the trailing `## User` block is empty.
20//! - `run(file, wait)` is the CLI entry point for the `diff` subcommand. When `wait`
21//!   is `true` it runs truncation detection first, then calls `compute` and prints
22//!   the result to stdout.
23//! - `annotate_diff(diff_text)` transforms a unified diff into an annotated format
24//!   with content-source markers: `[agent]`, `[user+]`, `[user-]`, `[user~]`.
25//!   Returns `None` if the diff is empty. Pure function, no I/O.
26//! - `classify_diff(diff_text)` classifies a unified diff into a `DiffType` with a
27//!   human-readable reason string. Types: `Approval` (single word: go/yes/do/ok/continue),
28//!   `SimpleQuestion` (single added line ending with `?`), `BoundaryArtifact` (only
29//!   `(HEAD)` or boundary UUID changes), `Annotation` (edits within agent content),
30//!   `StructuralChange` (only deletions/reorgs), `MultiTopic` (multiple unrelated
31//!   added blocks), `ContentAddition` (general new content).
32//!
33//! ## Agentic Contracts
34//! - Comment stripping is idempotent: calling `strip_comments` twice yields the same
35//!   result as calling it once.
36//! - Agent markers are always preserved by `strip_comments`; the skill can rely on
37//!   their presence in the stripped output.
38//! - `compute` never writes to the document file; it may write to the snapshot file
39//!   only during stale-snapshot recovery. A copy-on-read guard compares the snapshot
40//!   file's mtime at read time against its mtime before recovery write — if an
41//!   external process modified the snapshot mid-diff, recovery is skipped.
42//! - `compute` returns `None` (no diff) if and only if there are no meaningful
43//!   content changes after comment stripping.
44//! - `wait_for_stable_content` always terminates: the `MAX_RECHECKS` bound guarantees
45//!   it returns within ~6 s regardless of file activity.
46//! - `looks_truncated` never returns `true` for empty strings, markdown headings,
47//!   slash commands, or fenced code fences. Single characters (including alphanumeric)
48//!   are always treated as truncated — the stability check confirms completion.
49//! - Callers of `compute` can assume: `Some(diff)` → there is user-visible content
50//!   to respond to; `None` → skip the response cycle.
51//! - `classify_diff` is pure and deterministic: same diff text always yields the same
52//!   `DiffType` and reason. It operates only on the diff string, never reads files.
53//!
54//! ## Evals
55//! - `strip_html_comment`: `"before\n<!-- a comment -->\nafter\n"` → `"before\nafter\n"`
56//! - `strip_multiline_html_comment`: multiline `<!-- ... -->` on its own lines → stripped with surrounding newlines preserved
57//! - `strip_link_ref_comment`: `"[//]: # (note)\n"` on its own line → removed entirely
58//! - `preserve_agent_markers`: `<!-- agent:status -->` and `<!-- /agent:status -->` → unchanged
59//! - `strip_inline_comment`: inline `<!-- note -->` in middle of line → comment removed, surrounding text retained
60//! - `strip_preserves_comment_syntax_in_fenced_code_block`: `<!-- not a comment -->` inside triple-backtick fence → unchanged
61//! - `strip_preserves_comment_syntax_in_inline_backticks`: `` `<!--` `` in inline code → not treated as comment start
62//! - `strip_backtick_comment_before_agent_marker`: `` `<!--` `` text followed by `<!-- /agent:exchange -->` → agent marker not consumed
63//! - `stale_snapshot_detects_completed_exchange`: snapshot + completed assistant/user cycle with empty trailing user block → `is_stale_snapshot` returns `true`
64//! - `stale_snapshot_false_when_user_has_new_content`: trailing `## User` block has text → `is_stale_snapshot` returns `false`
65//! - `stale_snapshot_ignores_comments_in_detection`: scratch comments between exchanges → still detected as stale
66//! - `copy_on_read_guard_skips_recovery_when_snapshot_modified`: mtime comparison logic — same mtime allows recovery, different mtime blocks it, both-None allows it
67//! - `compute_stale_snapshot_recovery_proceeds_when_unmodified`: stale snapshot (base + completed exchange) → recovery fires, returns None, snapshot synced
68//! - `compute_stale_recovery_updates_snapshot_to_current_document`: after recovery, snapshot content matches document
69//! - `compute_returns_diff_when_user_adds_content`: user adds new content → returns diff containing the addition
70//! - `compute_returns_none_when_no_changes`: identical snapshot and document → returns None
71//! - `diff_detects_user_edits_after_stream_write`: snapshot saved post-stream, user adds line → `compute` returns `Some(diff)` containing new text
72//! - `diff_no_change_when_document_matches_snapshot`: document identical to snapshot → `compute` returns `None`
73//! - `truncated_mid_sentence`: line ending mid-word → `looks_truncated` returns `true`
74//! - `not_truncated_complete_sentence`: line ending with `.` → `looks_truncated` returns `false`
75//! - `not_truncated_single_word_command`: bare word like `"release"` → `looks_truncated` returns `false`
76//! - `truncated_single_chars`: single characters like `"A"`, `"S"`, `"1"` → `looks_truncated` returns `true` (stability check required)
77//! - `wait_for_stable_content_returns_immediately_when_complete`: already-complete content → returns in < 500 ms
78//! - `annotate_diff_additions`: added lines get `[user+]` markers
79//! - `annotate_diff_removals`: removed lines get `[user-]` markers
80//! - `annotate_diff_modifications`: paired add/remove with similar content get `[user~]` markers
81//! - `annotate_diff_context`: context lines get `[agent]` markers
82//! - `annotate_diff_empty`: empty diff returns `None`
83//! - `classify_approval`: single added word "go" → `DiffType::Approval`
84//! - `classify_approval_case_insensitive`: "Yes" → `DiffType::Approval`
85//! - `classify_simple_question`: single added line "what is X?" → `DiffType::SimpleQuestion`
86//! - `classify_boundary_artifact`: only `(HEAD)` marker change → `DiffType::BoundaryArtifact`
87//! - `classify_boundary_uuid`: only boundary UUID change → `DiffType::BoundaryArtifact`
88//! - `classify_structural_change`: only deletions → `DiffType::StructuralChange`
89//! - `classify_multi_topic`: multiple separated added blocks → `DiffType::MultiTopic`
90//! - `classify_content_addition`: general new content → `DiffType::ContentAddition`
91//! - `classify_annotation`: colon-appended edit to agent line → `DiffType::Annotation`
92//! - `parse_slash_commands(diff)`: extracts slash commands from added lines in a unified diff
93//!   with guards against: code fences (``` / ~~~), blockquotes (`>`), HTML comments, and
94//!   non-added lines. Returns a vec of command strings (e.g. `["/clear", "/agent-doc foo.md"]`).
95//! - `detect_prompt_preset_requests(diff)`: extracts ordered `preset <name>` / `presets <a>, <b>`
96//!   directives from user-added diff lines, ignoring code fences and blockquotes.
97//! - `extract_prompt_preset_requests_from_text(text)`: text-mode companion used by orchestration
98//!   task extraction for batch-level preset directives outside unified diffs.
99//! - `parse_slash_commands_simple`: single added `/clear` line → `["/clear"]`
100//! - `parse_slash_commands_ignores_fenced`: `/cmd` inside a ``` block → empty
101//! - `parse_slash_commands_ignores_blockquote`: `> /cmd` → empty
102//! - `parse_slash_commands_ignores_context_lines`: `/cmd` on context line (` `) → empty
103//! - `parse_slash_commands_ignores_removed_lines`: `/cmd` on removed line (`-`) → empty
104//! - `parse_slash_commands_with_args`: `/agent-doc foo.md` → `["/agent-doc foo.md"]`
105//! - `parse_slash_commands_requires_letter_after_slash`: `/ `, `//comment` → empty
106//! - `detect_prompt_preset_requests_from_diff`: added `preset #1` and `presets release-check, #2`
107//!   lines → ordered unique preset names returned
108//! - `extract_prompt_preset_requests_from_text_ignores_fences_and_blockquotes`
109//! - `extract_imperative_directives(diff)`: finds added user directive lines like `do #id`,
110//!   `run tests`, `build + install`, `commit + push`, or pending-item prose like
111//!   `[#id] Fix the cross-repo ...`, skipping code fences and blockquotes
112//! - `diff_contains_imperative_directive(diff)`: true when the diff contains either an explicit
113//!   imperative directive line or a one-word approval like `go`
114//! - `classify_prompt_bearing_changes(diff)`: extracts ordered user-authored changes that need
115//!   prompt-aware handling. Each item is typed as `prompt_target`, `content_edit`,
116//!   `recovery_artifact`, or `boundary_artifact`.
117//! - `extract_required_response_blocks_multiple_prompts`: changed exchange tail with two prompt
118//!   starts → both blocks returned oldest-first
119//! - `extract_required_response_blocks_preserves_code_fence_context`: prompt block followed by an
120//!   added fenced code block → returned block keeps the fence content intact
121//! - `format_prompt_bearing_changes_mentions_turn_completeness`: rendered section includes the
122//!   "do not stop at the newest question" contract plus edit/artifact handling guidance
123//! - `extract_required_response_blocks(diff)`: extracts ordered user request blocks from added
124//!   diff lines (for example `❯` prompts, questions, or imperative directives) so prompt builders
125//!   can restate the full changed exchange tail instead of anchoring only on the newest question
126//! - `format_required_response_targets(diff)`: compatibility wrapper that returns only the
127//!   `prompt_target` blocks from `classify_prompt_bearing_changes`
128//! - `format_prompt_bearing_changes(diff)`: renders the typed change list into a prompt-ready
129//!   section with explicit turn-completeness and edit/artifact instructions
130//! - `text_line_looks_like_prompt_target("❯ **Verification:** passed")` → `false`;
131//!   known assistant response labels are recognized after optional prompt glyph,
132//!   list marker, and markdown emphasis normalization.
133
134use serde::{Deserialize, Serialize};
135use similar::{ChangeTag, TextDiff};
136
137use crate::component;
138
139const IMPERATIVE_LEADING_VERBS: &[&str] = &[
140    "add",
141    "audit",
142    "benchmark",
143    "build",
144    "check",
145    "clean",
146    "close",
147    "commit",
148    "document",
149    "fix",
150    "harden",
151    "implement",
152    "install",
153    "investigate",
154    "note",
155    "preserve",
156    "push",
157    "record",
158    "refactor",
159    "remove",
160    "repair",
161    "rerun",
162    "revisit",
163    "run",
164    "test",
165    "trace",
166    "update",
167    "verify",
168    "write",
169];
170
171/// Classification of a user diff for skill routing.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
173#[serde(rename_all = "snake_case")]
174pub enum DiffType {
175    /// Single word matching approval list (go, yes, do, ok, continue, approve, approved).
176    Approval,
177    /// Single added line ending with `?`.
178    SimpleQuestion,
179    /// Only `(HEAD)` marker or boundary UUID changes.
180    BoundaryArtifact,
181    /// Edits within agent content (colon-appended, inline modifications).
182    Annotation,
183    /// Only deletions or reordered hunks, no additions.
184    StructuralChange,
185    /// Multiple unrelated added blocks separated by context lines.
186    MultiTopic,
187    /// General new content (default when no specific pattern matches).
188    ContentAddition,
189}
190
191/// Result of classifying a unified diff.
192#[derive(Debug, Clone, Serialize)]
193pub struct DiffClassification {
194    pub diff_type: DiffType,
195    pub diff_type_reason: String,
196}
197
198/// Canonical classification for user-authored prompt-bearing changes in a diff.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum PromptBearingChangeKind {
202    PromptTarget,
203    ContentEdit,
204    RecoveryArtifact,
205    BoundaryArtifact,
206}
207
208/// Ordered user-authored change that the harness should surface to the agent.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct PromptBearingChange {
211    pub kind: PromptBearingChangeKind,
212    pub text: String,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
216struct PromptPrefixLine {
217    raw: String,
218    prefixed: bool,
219}
220
221/// Strip comments from document content for diff comparison.
222///
223/// Delegates to `component::strip_comments` — the shared implementation
224/// available to both the binary and external crates.
225pub fn strip_comments(content: &str) -> String {
226    // #22a8 (Phase 5b write-side): also drop the managed `agent_doc_pipeline:`
227    // frontmatter block so a pipeline-only mirror write (emitted on every
228    // hot-path phase transition) reads as no change and never surfaces as a user
229    // edit. Both sides of every diff pass through this, so a pipeline-only delta
230    // cancels to `no_changes`. Shared with the write-side splice so the strip and
231    // the write agree byte-for-byte on the block boundary.
232    crate::frontmatter::strip_pipeline_block_lines(&component::strip_comments(content))
233}
234
235/// Annotate a unified diff with content-source markers.
236///
237/// Transforms a standard unified diff into a human-readable annotated format
238/// that shows the source of each line:
239/// - `[agent]` — context lines (unchanged content from agent)
240/// - `[user+]` — lines added by the user
241/// - `[user-]` — lines removed by the user
242/// - `[user~]` — lines modified by the user (paired add/remove with >60% common prefix)
243///
244/// Returns the annotated diff as a string, or `None` if the diff is empty.
245pub fn annotate_diff(diff_text: &str) -> Option<String> {
246    let mut result = Vec::new();
247    let mut pending_removes: Vec<String> = Vec::new();
248
249    for line in diff_text.lines() {
250        // Skip unified diff headers
251        if line.starts_with("--- ") || line.starts_with("+++ ") {
252            continue;
253        }
254        if line.starts_with("@@ ") {
255            // Flush pending removes before a new hunk
256            flush_removes(&mut pending_removes, &mut result);
257            continue;
258        }
259
260        if let Some(content) = line.strip_prefix('+') {
261            // Check if this pairs with a pending remove (modification)
262            if let Some(removed) = pending_removes.pop() {
263                let common = common_prefix_len(content, &removed);
264                let min_len = content.len().min(removed.len());
265                if min_len >= 3 && common > min_len * 6 / 10 {
266                    // Modified line — show the new version
267                    result.push(format!("[user~] {}", content));
268                } else {
269                    // Not similar enough — emit remove then add
270                    result.push(format!("[user-] {}", removed));
271                    result.push(format!("[user+] {}", content));
272                }
273            } else {
274                result.push(format!("[user+] {}", content));
275            }
276        } else if let Some(content) = line.strip_prefix('-') {
277            // Buffer removes to check for paired modifications
278            pending_removes.push(content.to_string());
279        } else if let Some(content) = line.strip_prefix(' ') {
280            flush_removes(&mut pending_removes, &mut result);
281            result.push(format!("[agent] {}", content));
282        } else if !line.is_empty() {
283            flush_removes(&mut pending_removes, &mut result);
284            result.push(format!("[agent] {}", line));
285        }
286    }
287
288    flush_removes(&mut pending_removes, &mut result);
289
290    if result.is_empty() {
291        None
292    } else {
293        Some(result.join("\n"))
294    }
295}
296
297/// Extract user additions that appear within agent content (inline annotations).
298///
299/// An inline annotation is a `[user+]` or `[user~]` line that has at least one
300/// substantive `[agent]` line after it — meaning the user inserted or modified text
301/// inside an agent response block rather than appending at the end.
302///
303/// Structural markers (component tags `<!-- ... -->`, section headers `# ...`) are
304/// excluded from the "agent lines after" check to avoid false positives where
305/// end-of-exchange user input is followed only by closing markers.
306pub fn extract_inline_annotations(annotated_diff: &str) -> Vec<String> {
307    classify_prompt_bearing_changes_from_annotated_internal(annotated_diff, false)
308        .into_iter()
309        .filter_map(|change| match change.kind {
310            PromptBearingChangeKind::PromptTarget | PromptBearingChangeKind::ContentEdit => {
311                Some(change.text)
312            }
313            PromptBearingChangeKind::RecoveryArtifact
314            | PromptBearingChangeKind::BoundaryArtifact => None,
315        })
316        .collect()
317}
318
319/// Returns true if the annotated line is a substantive agent line (not a structural marker).
320/// Component tags (`<!-- ... -->`), section headers (`# ...`), and blank lines are
321/// structural — they should not count as "agent response content" for inline annotation detection.
322fn is_substantive_agent_line(line: &str) -> bool {
323    let Some(content) = line.strip_prefix("[agent] ") else {
324        return false;
325    };
326    let t = content.trim_start();
327    !t.is_empty() && !t.starts_with("<!--") && !t.starts_with('#')
328}
329
330/// Returns true when a `[user~]` line is just the boundary marker `(HEAD)` being appended
331/// to a response heading — a reposition artifact from `agent-doc commit`, not a user edit.
332fn is_head_boundary_artifact(content: &str) -> bool {
333    content.ends_with(" (HEAD)") && {
334        let base = &content[..content.len() - " (HEAD)".len()];
335        base.trim_start_matches('#').starts_with(' ')
336            || base.contains("### Re:")
337            || base.contains("## ")
338    }
339}
340
341fn is_boundary_artifact_line(content: &str) -> bool {
342    let trimmed = content.trim();
343    trimmed.starts_with("<!-- agent:boundary:")
344        || is_head_boundary_artifact(trimmed)
345        || trimmed == "(HEAD)"
346}
347
348fn is_recovery_artifact_line(content: &str) -> bool {
349    let trimmed = content.trim_start();
350    trimmed.starts_with("### Re:")
351        || trimmed.starts_with("#### Re:")
352        || trimmed.starts_with("##### Re:")
353        || trimmed == "## Assistant"
354        || trimmed == "## User"
355        || trimmed.starts_with("<!-- patch:")
356        || trimmed.starts_with("<!-- /patch:")
357        || line_is_binary_authored_ipc_proof_diagnostic(trimmed)
358        || line_is_binary_authored_compact_summary(trimmed)
359}
360
361/// `#ipcproofnostall`: recognize a binary-authored interrupted-cycle IPC-proof
362/// recovery diagnostic by its OWN unambiguous tokens, independent of whether its
363/// `### Re:` heading is still adjacent.
364///
365/// `preflight::format_ipc_dogfood_note` appends a `### Re: ... (interrupted-cycle
366/// recovery)` heading plus a fenced block that ends with the structured
367/// `ipc_proof_insufficient file=... invariant=... recovery=...` event line. When
368/// the block is intact the heading already classifies it as a `RecoveryArtifact`.
369/// But a post-commit worktree corruption (`#postcommit-ipc-worktree-corruption`)
370/// can reorder/separate the structured line out of the fenced block; on its own
371/// it would otherwise fall through to a `PromptTarget` at the exchange tail and
372/// falsely INTERRUPT the next `session-check` / `finalize`, stalling the queue.
373///
374/// Match is intentionally narrow: it keys off the binary-authored event token
375/// shape (`ipc_proof_insufficient` + structured `invariant=`/`recovery=` fields)
376/// or the note's literal self-description line. A genuine user prompt that merely
377/// mentions "ipc" / "drift" in prose does NOT match.
378pub fn line_is_binary_authored_ipc_proof_diagnostic(line: &str) -> bool {
379    let trimmed = line.trim();
380    // The structured fail-closed event line emitted by
381    // `write::materialize` / recorded in ops.log and folded into the dogfood
382    // note. Require the leading event token AND both structured fields so an
383    // arbitrary user sentence containing the phrase cannot match.
384    if trimmed.starts_with("ipc_proof_insufficient")
385        && trimmed.contains("invariant=")
386        && trimmed.contains("recovery=")
387    {
388        return true;
389    }
390    // The note's literal binary-authored self-description line.
391    if trimmed
392        == "This is binary-authored diagnostic content, not a user prompt, so it does not require a separate response cycle."
393    {
394        return true;
395    }
396    false
397}
398
399/// `#provauth3`: recognize a binary-authored `### Session Summary` compaction
400/// line by its OWN unambiguous shape, independent of provenance inference.
401///
402/// `compact.rs` rewrites `agent:exchange` into a Session Summary block authored
403/// entirely by the binary (`### Session Summary`, the `*Compacted. Content
404/// archived to ...*` pointer, a `Compacted content:` section, and
405/// `- Archived N response topic(s): ...` / `- Prior summary/context: ...` /
406/// `- Trailing prompt/context: ...` items). Relative to the pre-compact snapshot
407/// every one of those lines is an *inserted* line in the exchange user region, so
408/// the content-inference prompt-prefix normalizer (`normalize_user_prompts_in_exchange`)
409/// stamps them with `❯` and the prompt classifier then treats them as an
410/// unresolved user prompt — falsely INTERRUPTing `session-check` and stalling the
411/// queue. Origin is *known* here (the binary authored the compaction), so this is
412/// the provenance check that replaces the content guess: a compaction summary
413/// line is never an operator prompt and never gets a `❯` prefix.
414///
415/// Match is intentionally narrow — it keys off the exact binary-authored summary
416/// shapes, tolerating a leading `❯ ` that an earlier mis-classification already
417/// applied, so a genuine user line that merely mentions "compacted" in prose does
418/// NOT match.
419pub fn line_is_binary_authored_compact_summary(line: &str) -> bool {
420    let trimmed = line.trim();
421    // Tolerate a `❯ ` (or bare `❯`) prefix a prior repair pass already applied,
422    // so the recognizer matches both a freshly-built summary (no prefix) and an
423    // already-corrupted committed one.
424    let trimmed = trimmed
425        .strip_prefix("❯ ")
426        .or_else(|| trimmed.strip_prefix('❯'))
427        .map(str::trim_start)
428        .unwrap_or(trimmed);
429    trimmed == "### Session Summary"
430        || trimmed == "Compacted content:"
431        || trimmed.starts_with("*Compacted. Content archived to `")
432        || (trimmed.starts_with("- Archived ") && trimmed.contains("response topic(s):"))
433        || trimmed.starts_with("- Prior summary/context:")
434        || trimmed.starts_with("- Trailing prompt/context:")
435}
436
437fn is_exchange_close_marker_line(content: &str) -> bool {
438    content.trim() == "<!-- /agent:exchange -->"
439}
440
441fn parse_slash_command_candidate(line: &str) -> Option<String> {
442    let trimmed = line.trim();
443    let rest = trimmed.strip_prefix('/')?;
444    let token_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
445    let token = &rest[..token_end];
446    if token.is_empty() {
447        return None;
448    }
449    let mut chars = token.chars();
450    let first = chars.next()?;
451    let command_like = first.is_ascii_lowercase()
452        && chars
453            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, ':' | '_' | '-'));
454    command_like.then(|| trimmed.to_string())
455}
456
457fn line_looks_like_slash_command(line: &str) -> bool {
458    parse_slash_command_candidate(line).is_some()
459}
460
461fn line_looks_like_prompt_target(line: &str) -> bool {
462    let trimmed = line.trim();
463    let normalized_imperative = normalize_imperative_candidate(trimmed)
464        .is_some_and(|normalized| looks_like_imperative_directive(&normalized));
465    let slash_command = line_looks_like_slash_command(trimmed);
466    let prompt_prefixed = trimmed.starts_with('❯') && !line_looks_like_markdown_list_item(trimmed);
467    !trimmed.is_empty()
468        && !trimmed.starts_with("<!--")
469        && !trimmed.starts_with("```")
470        && !trimmed.starts_with("~~~")
471        && !trimmed.starts_with("### Re:")
472        && !line_has_known_response_label_after_prompt(trimmed)
473        && (slash_command || prompt_prefixed || trimmed.ends_with('?') || normalized_imperative)
474}
475
476pub fn text_line_looks_like_prompt_target(line: &str) -> bool {
477    line_looks_like_prompt_target(line)
478}
479
480fn block_looks_like_prompt_target(block: &str) -> bool {
481    block.lines().any(line_looks_like_prompt_target)
482}
483
484fn is_exchange_response_heading(trimmed: &str) -> bool {
485    trimmed == "## Assistant"
486        || trimmed.starts_with("### Re:")
487        || trimmed.starts_with("#### Re:")
488        || trimmed.starts_with("##### Re:")
489        || trimmed.starts_with("###### Re:")
490}
491
492fn normalized_prompt_preview_line(line: &str) -> Option<String> {
493    let trimmed = line.trim();
494    if trimmed.is_empty() || !text_line_looks_like_prompt_target(trimmed) {
495        return None;
496    }
497    Some(trimmed.trim_start_matches('❯').trim().to_string())
498}
499
500pub fn line_looks_like_fresh_prompt_after_response(trimmed: &str) -> bool {
501    if line_looks_like_plain_response_after_prompt(trimmed) {
502        return false;
503    }
504
505    let unprefixed = trimmed.trim_start_matches('❯').trim();
506    let lower = unprefixed.to_ascii_lowercase();
507    trimmed.starts_with('❯')
508        || unprefixed.ends_with('?')
509        || line_looks_like_slash_command(unprefixed)
510        || lower == "go"
511        || lower == "continue"
512        || lower.starts_with("do #")
513        || lower.starts_with("do [#")
514        || lower.starts_with("fix #")
515        || lower.starts_with("run ")
516        || lower.starts_with("rerun ")
517        || lower.starts_with("build ")
518        || lower.starts_with("test ")
519        || lower.starts_with("commit ")
520        || lower.starts_with("push ")
521        || lower.starts_with("verify ")
522        || lower.starts_with("investigate ")
523}
524
525pub(crate) fn line_looks_like_soft_prompt_request(trimmed: &str) -> bool {
526    let lower = trimmed.to_ascii_lowercase();
527    lower.starts_with("please ")
528        || lower.contains(" please ")
529        || lower.starts_with("can you ")
530        || lower.starts_with("could you ")
531        || lower.starts_with("would you ")
532        || lower.starts_with("need you to ")
533}
534
535pub fn line_looks_like_prompt_prefix_repair_start(trimmed: &str, is_target: bool) -> bool {
536    let unprefixed = trimmed
537        .strip_prefix("❯ ")
538        .or_else(|| trimmed.strip_prefix('❯'))
539        .map(str::trim_start)
540        .unwrap_or(trimmed);
541
542    if unprefixed.is_empty() || line_looks_like_plain_response_after_prompt(unprefixed) {
543        return false;
544    }
545
546    is_target
547        || line_looks_like_fresh_prompt_after_response(unprefixed)
548        || line_looks_like_soft_prompt_request(unprefixed)
549}
550
551pub fn line_looks_like_targeted_prompt_prefix_repair_start(trimmed: &str, is_target: bool) -> bool {
552    if !is_target {
553        return false;
554    }
555
556    let unprefixed = trimmed
557        .strip_prefix("❯ ")
558        .or_else(|| trimmed.strip_prefix('❯'))
559        .map(str::trim_start)
560        .unwrap_or(trimmed);
561
562    if unprefixed.is_empty() || line_looks_like_plain_response_after_prompt(unprefixed) {
563        return false;
564    }
565
566    if (trimmed.starts_with('❯') && !line_looks_like_markdown_list_item(trimmed))
567        || line_looks_like_soft_prompt_request(unprefixed)
568    {
569        return true;
570    }
571
572    let lower = unprefixed.to_ascii_lowercase();
573    lower == "go"
574        || lower == "continue"
575        || line_looks_like_slash_command(unprefixed)
576        || lower.starts_with("do #")
577        || lower.starts_with("do [#")
578        || lower.starts_with("fix #")
579        || lower.starts_with("run ")
580        || lower.starts_with("rerun ")
581        || lower.starts_with("build ")
582        || lower.starts_with("test ")
583        || lower.starts_with("commit ")
584        || lower.starts_with("push ")
585        || lower.starts_with("verify ")
586        || lower.starts_with("investigate ")
587}
588
589pub fn line_looks_like_plain_response_after_prompt(trimmed: &str) -> bool {
590    if trimmed.is_empty() {
591        return false;
592    }
593
594    if line_has_known_response_label_after_prompt(trimmed) {
595        return true;
596    }
597
598    if normalized_prompt_preview_line(trimmed).is_some() {
599        return false;
600    }
601
602    if line_looks_like_markdown_list_item(trimmed)
603        || trimmed.starts_with("- ")
604        || trimmed.starts_with("* ")
605        || trimmed.starts_with("Plan:")
606        || trimmed.starts_with("Verification")
607        || trimmed.starts_with("What changed:")
608        || trimmed.starts_with("Follow-up:")
609        || trimmed.starts_with("Backlog:")
610        || trimmed.starts_with("`#")
611    {
612        return true;
613    }
614
615    let lower = trimmed.to_ascii_lowercase();
616    lower.starts_with("i updated ")
617        || lower.starts_with("i fixed ")
618        || lower.starts_with("i added ")
619        || lower.starts_with("i implemented ")
620        || lower.starts_with("i left ")
621        || lower.starts_with("i dry-ran ")
622        || lower.starts_with("recovered ")
623        || lower.starts_with("confirmed ")
624        || lower.starts_with("no code files changed")
625        || lower.starts_with("this closeout ")
626        || lower.starts_with("updated ")
627        || lower.starts_with("fixed ")
628        || lower.starts_with("added ")
629        || lower.starts_with("implemented ")
630}
631
632fn line_has_known_response_label_after_prompt(line: &str) -> bool {
633    let Some(normalized) = normalize_response_label_candidate(line) else {
634        return false;
635    };
636    matches!(
637        normalized.as_str(),
638        s if s.starts_with("Plan:")
639            || s.starts_with("Verification:")
640            || s.starts_with("What changed:")
641            || s.starts_with("Follow-up:")
642            || s.starts_with("Commit / push:")
643            || s.starts_with("Backlog:")
644    )
645}
646
647fn normalize_response_label_candidate(line: &str) -> Option<String> {
648    let mut trimmed = line.trim();
649    if trimmed.is_empty() {
650        return None;
651    }
652
653    if let Some(rest) = trimmed.strip_prefix('❯') {
654        trimmed = rest.trim_start();
655    }
656
657    if let Some(rest) = trimmed
658        .strip_prefix("- ")
659        .or_else(|| trimmed.strip_prefix("* "))
660        .or_else(|| trimmed.strip_prefix("+ "))
661    {
662        trimmed = rest.trim_start();
663    } else {
664        let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
665        if digits > 0 {
666            let rest = &trimmed[digits..];
667            if let Some(rest) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
668                trimmed = rest.trim_start();
669            }
670        }
671    }
672
673    let stripped = strip_markdown_emphasis_pair(trimmed);
674    let normalized = stripped.trim_start();
675    (!normalized.is_empty()).then(|| normalized.to_string())
676}
677
678fn strip_markdown_emphasis_pair(text: &str) -> String {
679    for marker in ["***", "___", "**", "__", "*", "_"] {
680        if let Some(rest) = text.strip_prefix(marker)
681            && let Some(end) = rest.find(marker)
682        {
683            let label = &rest[..end];
684            let tail = &rest[end + marker.len()..];
685            if !label.trim().is_empty()
686                && (label.trim_end().ends_with(':') || tail.trim_start().starts_with(':'))
687            {
688                return format!("{}{}", label, tail);
689            }
690        }
691    }
692    text.to_string()
693}
694
695pub fn prompt_change_is_already_answered(change_text: &str) -> bool {
696    fn fence_open(trimmed: &str) -> Option<(char, usize)> {
697        let fc = trimmed.chars().next()?;
698        if fc != '`' && fc != '~' {
699            return None;
700        }
701        let fl = trimmed.chars().take_while(|&c| c == fc).count();
702        if fl >= 3 { Some((fc, fl)) } else { None }
703    }
704
705    fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
706        let fc = trimmed.chars().next().unwrap_or('\0');
707        if fc != fence_char {
708            return false;
709        }
710        let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
711        fl >= fence_len && trimmed[fl..].trim().is_empty()
712    }
713
714    let mut in_fence = false;
715    let mut fence_char = '`';
716    let mut fence_len = 0usize;
717    let mut saw_prompt = false;
718    let mut saw_response = false;
719
720    for segment in change_text.split_inclusive('\n') {
721        let line = segment.trim_end_matches('\n');
722        let trimmed = line.trim();
723        if trimmed.is_empty() || trimmed.starts_with("<!--") {
724            continue;
725        }
726
727        if !in_fence {
728            if let Some((fc, fl)) = fence_open(trimmed) {
729                in_fence = true;
730                fence_char = fc;
731                fence_len = fl;
732                continue;
733            }
734        } else {
735            if fence_close(trimmed, fence_char, fence_len) {
736                in_fence = false;
737            }
738            continue;
739        }
740
741        if is_exchange_response_heading(trimmed) {
742            if saw_prompt {
743                saw_response = true;
744            }
745            continue;
746        }
747
748        if normalized_prompt_preview_line(trimmed).is_some() {
749            if saw_response && line_looks_like_fresh_prompt_after_response(trimmed) {
750                return false;
751            }
752            saw_prompt = true;
753            continue;
754        }
755
756        if !saw_prompt && line_looks_like_soft_prompt_request(trimmed) {
757            saw_prompt = true;
758            continue;
759        }
760
761        if saw_prompt && line_looks_like_plain_response_after_prompt(trimmed) {
762            saw_response = true;
763        }
764    }
765
766    saw_prompt && saw_response
767}
768
769pub fn prompt_change_is_answered_by_later_response(
770    changes: &[PromptBearingChange],
771    idx: usize,
772) -> bool {
773    if changes
774        .get(idx)
775        .is_none_or(|change| change.kind != PromptBearingChangeKind::PromptTarget)
776    {
777        return false;
778    }
779
780    for later in changes.iter().skip(idx + 1) {
781        match later.kind {
782            PromptBearingChangeKind::PromptTarget => return false,
783            PromptBearingChangeKind::RecoveryArtifact
784            | PromptBearingChangeKind::BoundaryArtifact => {
785                let heading = later
786                    .text
787                    .lines()
788                    .find(|line| !line.trim().is_empty())
789                    .unwrap_or(later.text.as_str())
790                    .trim();
791                if is_exchange_response_heading(heading) {
792                    return true;
793                }
794            }
795            PromptBearingChangeKind::ContentEdit => {}
796        }
797    }
798
799    false
800}
801
802fn suppress_answered_prompt_runs(changes: Vec<PromptBearingChange>) -> Vec<PromptBearingChange> {
803    let mut filtered = Vec::with_capacity(changes.len());
804    let mut skip_answered_response_run = false;
805
806    for (idx, change) in changes.iter().enumerate() {
807        match change.kind {
808            PromptBearingChangeKind::RecoveryArtifact
809            | PromptBearingChangeKind::BoundaryArtifact => {
810                filtered.push(change.clone());
811            }
812            PromptBearingChangeKind::PromptTarget => {
813                if skip_answered_response_run {
814                    let preview = change
815                        .text
816                        .lines()
817                        .find(|line| !line.trim().is_empty())
818                        .unwrap_or(change.text.as_str())
819                        .trim();
820                    if !line_looks_like_fresh_prompt_after_response(preview) {
821                        continue;
822                    }
823                }
824                if prompt_change_is_already_answered(&change.text)
825                    || prompt_change_is_answered_by_later_response(&changes, idx)
826                {
827                    skip_answered_response_run = true;
828                    continue;
829                }
830                filtered.push(change.clone());
831            }
832            PromptBearingChangeKind::ContentEdit => {
833                if skip_answered_response_run {
834                    continue;
835                }
836                filtered.push(change.clone());
837            }
838        }
839    }
840
841    filtered
842}
843
844fn classify_prompt_bearing_block(
845    block_text: &str,
846    has_substantive_agent_after: bool,
847    closes_exchange_tail: bool,
848) -> Option<PromptBearingChangeKind> {
849    let trimmed = block_text.trim();
850    if trimmed.is_empty() {
851        return None;
852    }
853    let non_blank: Vec<&str> = trimmed
854        .lines()
855        .map(str::trim)
856        .filter(|line| !line.is_empty())
857        .collect();
858    if non_blank.is_empty() {
859        return None;
860    }
861    if non_blank.iter().all(|line| is_boundary_artifact_line(line)) {
862        return Some(PromptBearingChangeKind::BoundaryArtifact);
863    }
864    if non_blank
865        .first()
866        .is_some_and(|line| is_recovery_artifact_line(line))
867    {
868        return Some(PromptBearingChangeKind::RecoveryArtifact);
869    }
870    // `#ipcproofnostall`: a binary-authored IPC-proof recovery diagnostic line
871    // anywhere in the block (e.g. after a post-commit worktree corruption
872    // separated it from its `### Re:` heading) keeps the block a RecoveryArtifact
873    // so it is never an unresolved user PromptTarget at the exchange tail. The
874    // marker is keyed off the structured event/self-description tokens, so a real
875    // user prompt that only mentions IPC/drift in prose still classifies normally.
876    if non_blank
877        .iter()
878        .any(|line| line_is_binary_authored_ipc_proof_diagnostic(line))
879    {
880        return Some(PromptBearingChangeKind::RecoveryArtifact);
881    }
882    // `#provauth3`: a block that is entirely binary-authored compaction summary
883    // content (Session Summary heading + archive pointer + archived-topic items)
884    // is never a user prompt. Compaction rewrites the whole exchange tail at once,
885    // so the block is self-contained; requiring EVERY non-blank line to match the
886    // narrow summary shapes keeps a real prompt that happens to sit next to a
887    // stray summary line from being hidden.
888    if non_blank
889        .iter()
890        .all(|line| line_is_binary_authored_compact_summary(line))
891    {
892        return Some(PromptBearingChangeKind::RecoveryArtifact);
893    }
894    if block_looks_like_prompt_target(trimmed) {
895        return Some(PromptBearingChangeKind::PromptTarget);
896    }
897    if closes_exchange_tail
898        && non_blank
899            .iter()
900            .all(|line| line_looks_like_plain_response_after_prompt(line))
901    {
902        return None;
903    }
904    if closes_exchange_tail {
905        return Some(PromptBearingChangeKind::PromptTarget);
906    }
907    if has_substantive_agent_after {
908        return Some(PromptBearingChangeKind::ContentEdit);
909    }
910    None
911}
912
913/// Return the prompt-bearing exchange lines that must carry a `❯ ` prefix.
914///
915/// This derives from the canonical `prompt_target` classifier rather than a
916/// separate line-shape heuristic, so write-path normalization and session-check
917/// can enforce the same invariant.
918pub fn prompt_prefix_normalization_targets(diff: &str) -> Vec<String> {
919    let mut seen = std::collections::HashSet::<String>::new();
920    let mut lines = Vec::new();
921    for change in classify_prompt_bearing_changes_raw(diff) {
922        if change.kind != PromptBearingChangeKind::PromptTarget {
923            continue;
924        }
925        for line in prompt_prefix_lines_from_block(&change.text) {
926            if line.prefixed {
927                continue;
928            }
929            if seen.insert(line.raw.clone()) {
930                lines.push(line.raw);
931            }
932        }
933    }
934    lines
935}
936
937/// Return the first prompt-bearing line that should have had a `❯ ` prefix but did not.
938pub fn first_bare_prompt_prefix_target(diff: &str) -> Option<String> {
939    for change in classify_prompt_bearing_changes_raw(diff) {
940        if change.kind != PromptBearingChangeKind::PromptTarget {
941            continue;
942        }
943        for line in prompt_prefix_lines_from_block(&change.text) {
944            if !line.prefixed {
945                return Some(line.raw);
946            }
947        }
948    }
949    None
950}
951
952fn classify_prompt_bearing_changes_from_annotated(
953    annotated_diff: &str,
954) -> Vec<PromptBearingChange> {
955    classify_prompt_bearing_changes_from_annotated_internal(annotated_diff, true)
956}
957
958fn classify_prompt_bearing_changes_from_annotated_internal(
959    annotated_diff: &str,
960    promote_exchange_tail_prompts: bool,
961) -> Vec<PromptBearingChange> {
962    fn fence_open(trimmed: &str) -> Option<(char, usize)> {
963        let fc = trimmed.chars().next()?;
964        if fc != '`' && fc != '~' {
965            return None;
966        }
967        let fl = trimmed.chars().take_while(|&c| c == fc).count();
968        if fl >= 3 { Some((fc, fl)) } else { None }
969    }
970
971    fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
972        let fc = trimmed.chars().next().unwrap_or('\0');
973        if fc != fence_char {
974            return false;
975        }
976        let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
977        fl >= fence_len && trimmed[fl..].trim().is_empty()
978    }
979
980    let lines: Vec<&str> = annotated_diff.lines().collect();
981    let mut changes = Vec::new();
982    let mut i = 0usize;
983
984    while i < lines.len() {
985        let Some(mut block) = lines[i]
986            .strip_prefix("[user+] ")
987            .or_else(|| lines[i].strip_prefix("[user~] "))
988            .map(|line| vec![line.to_string()])
989        else {
990            i += 1;
991            continue;
992        };
993
994        let mut in_fence = false;
995        let mut fence_char = '`';
996        let mut fence_len = 3usize;
997        let first_trimmed = block[0].trim();
998        if let Some((fc, fl)) = fence_open(first_trimmed) {
999            in_fence = true;
1000            fence_char = fc;
1001            fence_len = fl;
1002        }
1003
1004        i += 1;
1005        while i < lines.len() {
1006            if let Some(content) = lines[i]
1007                .strip_prefix("[user+] ")
1008                .or_else(|| lines[i].strip_prefix("[user~] "))
1009            {
1010                let trimmed = content.trim();
1011                let starts_new_block = !in_fence
1012                    && block.last().is_some_and(|line| line.trim().is_empty())
1013                    && !trimmed.is_empty()
1014                    && !trimmed.starts_with("```")
1015                    && !trimmed.starts_with("~~~");
1016                if starts_new_block {
1017                    break;
1018                }
1019                block.push(content.to_string());
1020                if !in_fence {
1021                    if let Some((fc, fl)) = fence_open(trimmed) {
1022                        in_fence = true;
1023                        fence_char = fc;
1024                        fence_len = fl;
1025                    }
1026                } else if fence_close(trimmed, fence_char, fence_len) {
1027                    in_fence = false;
1028                }
1029                i += 1;
1030            } else {
1031                break;
1032            }
1033        }
1034
1035        while block.first().is_some_and(|line| line.trim().is_empty()) {
1036            block.remove(0);
1037        }
1038        while block.last().is_some_and(|line| line.trim().is_empty()) {
1039            block.pop();
1040        }
1041        if block.is_empty() {
1042            continue;
1043        }
1044
1045        let has_substantive_agent_after = lines[i..]
1046            .iter()
1047            .any(|line| is_substantive_agent_line(line));
1048        let closes_exchange_tail = lines[i..]
1049            .iter()
1050            .find_map(|line| {
1051                let trimmed = line.trim();
1052                if trimmed.is_empty() {
1053                    return None;
1054                }
1055                line.strip_prefix("[agent] ")
1056            })
1057            .is_some_and(is_exchange_close_marker_line);
1058        let text = block.join("\n");
1059        let Some(kind) = classify_prompt_bearing_block(
1060            &text,
1061            has_substantive_agent_after,
1062            promote_exchange_tail_prompts && closes_exchange_tail,
1063        ) else {
1064            continue;
1065        };
1066        changes.push(PromptBearingChange { kind, text });
1067    }
1068
1069    changes
1070}
1071
1072fn classify_prompt_bearing_changes_raw(diff: &str) -> Vec<PromptBearingChange> {
1073    let mut changes = annotate_diff(diff)
1074        .map(|annotated| classify_prompt_bearing_changes_from_annotated(&annotated))
1075        .unwrap_or_default();
1076
1077    // Annotated classification is the ordered source of truth because it preserves
1078    // mixed prompt/edit/artifact encounter order across the changed tail. Keep the
1079    // older prompt-block extractor as a safety net for prompt-target-only consumers
1080    // and append only truly-missing prompt blocks.
1081    for text in extract_prompt_target_blocks(diff) {
1082        if changes
1083            .iter()
1084            .any(|existing| prompt_target_block_already_classified(existing, &text))
1085        {
1086            continue;
1087        }
1088        changes.push(PromptBearingChange {
1089            kind: PromptBearingChangeKind::PromptTarget,
1090            text,
1091        });
1092    }
1093
1094    changes
1095}
1096
1097fn prompt_target_block_already_classified(
1098    existing: &PromptBearingChange,
1099    prompt_text: &str,
1100) -> bool {
1101    if existing.text == prompt_text {
1102        return true;
1103    }
1104    match existing.kind {
1105        PromptBearingChangeKind::PromptTarget => {
1106            (existing.text.contains(prompt_text) || prompt_text.contains(&existing.text))
1107                && prompt_change_is_already_answered(&existing.text)
1108        }
1109        PromptBearingChangeKind::RecoveryArtifact
1110        | PromptBearingChangeKind::BoundaryArtifact
1111        | PromptBearingChangeKind::ContentEdit => existing.text.contains(prompt_text),
1112    }
1113}
1114
1115pub fn classify_prompt_bearing_changes(diff: &str) -> Vec<PromptBearingChange> {
1116    suppress_answered_prompt_runs(classify_prompt_bearing_changes_raw(diff))
1117}
1118
1119/// True when the change is entirely a managed-component state mutation:
1120/// queue activity toggle, queue body item (add/strike), backlog item line,
1121/// or done item line. These edits are routine session bookkeeping, not
1122/// real user prompts, so the Claude Code auto-loop guard treats them as
1123/// non-blocking. Plan: `#ccloopguard`.
1124pub fn change_is_managed_state_only(change: &PromptBearingChange) -> bool {
1125    text_is_managed_state_only(&change.text)
1126}
1127
1128fn text_is_managed_state_only(text: &str) -> bool {
1129    let trimmed = text.trim();
1130    if trimmed.is_empty() {
1131        return false;
1132    }
1133    let lines: Vec<&str> = trimmed.lines().collect();
1134    if lines.is_empty() {
1135        return false;
1136    }
1137    lines.iter().all(|line| line_is_managed_state_only(line))
1138}
1139
1140fn line_is_managed_state_only(line: &str) -> bool {
1141    let trimmed = line.trim();
1142    if trimmed.is_empty() {
1143        return true;
1144    }
1145    // Queue activity comment marker (with or without `auto`)
1146    if trimmed.starts_with("<!-- agent:queue")
1147        || trimmed.starts_with("<!-- /agent:queue")
1148        || trimmed.starts_with("<!-- agent:backlog")
1149        || trimmed.starts_with("<!-- /agent:backlog")
1150        || trimmed.starts_with("<!-- agent:done")
1151        || trimmed.starts_with("<!-- /agent:done")
1152        || trimmed.starts_with("<!-- agent:review")
1153        || trimmed.starts_with("<!-- /agent:review")
1154    {
1155        return true;
1156    }
1157    // Frontmatter queue activity toggle (`queue_active: true|false`)
1158    if trimmed.starts_with("queue_active:") {
1159        return true;
1160    }
1161    // #22a8: managed `agent_doc_pipeline:` mirror block (key + indented children
1162    // such as `run_id:` / `step:` / `turn_id:` / `queue_task_id:`). The diff
1163    // already strips this block, but classify it as managed state too so any
1164    // residual line never reads as a user prompt.
1165    if trimmed.starts_with("agent_doc_pipeline:")
1166        || trimmed.starts_with("run_id:")
1167        || trimmed.starts_with("step:")
1168        || trimmed.starts_with("turn_id:")
1169        || trimmed.starts_with("queue_task_id:")
1170    {
1171        return true;
1172    }
1173    // Queue body lines: `- do ...`, `- ~do ...~` (strikethrough)
1174    if trimmed.starts_with("- do ") || trimmed.starts_with("- ~do ") {
1175        return true;
1176    }
1177    // Backlog/review/done item lines: `- [ ] [#id] ...`, `- [/]`, `- [x]`, `- [-]`
1178    if trimmed.starts_with("- [ ]")
1179        || trimmed.starts_with("- [/]")
1180        || trimmed.starts_with("- [x]")
1181        || trimmed.starts_with("- [-]")
1182        || trimmed.starts_with("- [?]")
1183    {
1184        return true;
1185    }
1186    // Done archive items often start with a date prefix: `- YYYY-MM-DD [#id]`
1187    if trimmed.len() >= 12
1188        && trimmed.starts_with("- ")
1189        && trimmed.chars().nth(2).is_some_and(|c| c.is_ascii_digit())
1190    {
1191        return true;
1192    }
1193    // Queue preset lines (`preset #foo`).
1194    if trimmed.starts_with("preset #") || trimmed.starts_with("preset:") {
1195        return true;
1196    }
1197    false
1198}
1199
1200/// Return a copy of `diff` with user-added lines inside the current
1201/// `agent:queue` component removed.
1202///
1203/// Inactive queue bodies are document state, not fresh prompt input. Callers use
1204/// this before extracting prompt targets, slash commands, presets, and
1205/// imperative directives when queue activation has not resolved true.
1206pub fn suppress_inactive_queue_additions(diff: &str, current_content: &str) -> String {
1207    let ranges = queue_line_ranges(current_content);
1208    if ranges.is_empty() {
1209        return diff.to_string();
1210    }
1211
1212    let mut current_line: Option<usize> = None;
1213    let mut filtered = String::new();
1214    for line in diff.lines() {
1215        if line.starts_with("@@ ") {
1216            current_line = hunk_current_start_line(line);
1217            filtered.push_str(line);
1218            filtered.push('\n');
1219            continue;
1220        }
1221
1222        if line.starts_with("--- ") || line.starts_with("+++ ") {
1223            filtered.push_str(line);
1224            filtered.push('\n');
1225            continue;
1226        }
1227
1228        if line.starts_with('+') && !line.starts_with("+++") {
1229            let line_no = current_line.unwrap_or(0);
1230            if current_line_is_in_ranges(line_no, &ranges) {
1231                current_line = current_line.map(|n| n + 1);
1232                continue;
1233            }
1234            current_line = current_line.map(|n| n + 1);
1235            filtered.push_str(line);
1236            filtered.push('\n');
1237            continue;
1238        }
1239
1240        if line.starts_with(' ') {
1241            current_line = current_line.map(|n| n + 1);
1242        }
1243
1244        filtered.push_str(line);
1245        filtered.push('\n');
1246    }
1247
1248    filtered
1249}
1250
1251fn queue_line_ranges(content: &str) -> Vec<(usize, usize)> {
1252    let Ok(components) = component::parse(content) else {
1253        return Vec::new();
1254    };
1255    components
1256        .iter()
1257        .filter(|component| component.name == "queue")
1258        .map(|component| {
1259            (
1260                line_number_at_byte(content, component.open_start),
1261                line_number_at_byte(content, component.close_end.saturating_sub(1)),
1262            )
1263        })
1264        .collect()
1265}
1266
1267fn line_number_at_byte(content: &str, byte_offset: usize) -> usize {
1268    let end = byte_offset.min(content.len());
1269    content[..end].bytes().filter(|b| *b == b'\n').count() + 1
1270}
1271
1272fn hunk_current_start_line(header: &str) -> Option<usize> {
1273    let plus = header
1274        .split_whitespace()
1275        .find(|part| part.starts_with('+'))?;
1276    let number = plus
1277        .trim_start_matches('+')
1278        .split(',')
1279        .next()
1280        .unwrap_or_default();
1281    number.parse::<usize>().ok()
1282}
1283
1284fn current_line_is_in_ranges(line_no: usize, ranges: &[(usize, usize)]) -> bool {
1285    ranges
1286        .iter()
1287        .any(|(start, end)| line_no >= *start && line_no <= *end)
1288}
1289
1290fn prompt_prefix_lines_from_block(block: &str) -> Vec<PromptPrefixLine> {
1291    fn fence_open(trimmed: &str) -> Option<(char, usize)> {
1292        let fc = trimmed.chars().next()?;
1293        if fc != '`' && fc != '~' {
1294            return None;
1295        }
1296        let fl = trimmed.chars().take_while(|&c| c == fc).count();
1297        if fl >= 3 { Some((fc, fl)) } else { None }
1298    }
1299
1300    fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
1301        let fc = trimmed.chars().next().unwrap_or('\0');
1302        if fc != fence_char {
1303            return false;
1304        }
1305        let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
1306        fl >= fence_len && trimmed[fl..].trim().is_empty()
1307    }
1308
1309    let mut lines = Vec::new();
1310    let mut in_fence = false;
1311    let mut fence_char = '`';
1312    let mut fence_len = 3usize;
1313    let mut in_response_block = false;
1314
1315    for line in block.lines() {
1316        let trimmed = line.trim();
1317        if !in_fence {
1318            if let Some((fc, fl)) = fence_open(trimmed) {
1319                in_fence = true;
1320                fence_char = fc;
1321                fence_len = fl;
1322                continue;
1323            }
1324        } else if fence_close(trimmed, fence_char, fence_len) {
1325            in_fence = false;
1326            continue;
1327        }
1328
1329        if in_fence || trimmed.is_empty() || trimmed.starts_with("<!--") {
1330            continue;
1331        }
1332
1333        if line_looks_like_markdown_list_item(trimmed) {
1334            continue;
1335        }
1336
1337        if is_exchange_response_heading(trimmed) {
1338            in_response_block = true;
1339            continue;
1340        }
1341
1342        if in_response_block {
1343            if line_looks_like_targeted_prompt_prefix_repair_start(trimmed, true) {
1344                in_response_block = false;
1345            } else {
1346                continue;
1347            }
1348        }
1349
1350        lines.push(PromptPrefixLine {
1351            raw: line.to_string(),
1352            prefixed: trimmed.starts_with('❯'),
1353        });
1354    }
1355
1356    lines
1357}
1358
1359fn extract_prompt_target_blocks(diff: &str) -> Vec<String> {
1360    fn is_response_heading(line: &str) -> bool {
1361        line.starts_with("### Re:") || line.starts_with("#### Re:") || line.starts_with("## Re:")
1362    }
1363
1364    fn fence_open(trimmed: &str) -> Option<(char, usize)> {
1365        let fc = trimmed.chars().next()?;
1366        if fc != '`' && fc != '~' {
1367            return None;
1368        }
1369        let fl = trimmed.chars().take_while(|&c| c == fc).count();
1370        if fl >= 3 { Some((fc, fl)) } else { None }
1371    }
1372
1373    fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
1374        let fc = trimmed.chars().next().unwrap_or('\0');
1375        if fc != fence_char {
1376            return false;
1377        }
1378        let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
1379        fl >= fence_len && trimmed[fl..].trim().is_empty()
1380    }
1381
1382    fn trim_block_lines(lines: &mut Vec<String>) {
1383        while lines.first().is_some_and(|line| line.trim().is_empty()) {
1384            lines.remove(0);
1385        }
1386        while lines.last().is_some_and(|line| line.trim().is_empty()) {
1387            lines.pop();
1388        }
1389    }
1390
1391    fn block_start(line: &str) -> bool {
1392        let trimmed = line.trim();
1393        if trimmed.is_empty()
1394            || trimmed.starts_with("<!--")
1395            || trimmed.starts_with("```")
1396            || trimmed.starts_with("~~~")
1397            || is_response_heading(trimmed)
1398        {
1399            return false;
1400        }
1401        line_looks_like_prompt_target(trimmed)
1402    }
1403
1404    fn flush(blocks: &mut Vec<String>, current: &mut Vec<String>) {
1405        trim_block_lines(current);
1406        if !current.is_empty() {
1407            blocks.push(current.join("\n"));
1408            current.clear();
1409        }
1410    }
1411
1412    let mut blocks = Vec::new();
1413    let mut current: Vec<String> = Vec::new();
1414    let mut in_fence = false;
1415    let mut fence_char = '`';
1416    let mut fence_len = 3usize;
1417
1418    for line in diff.lines() {
1419        if line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("@@ ") {
1420            if !in_fence {
1421                flush(&mut blocks, &mut current);
1422            }
1423            continue;
1424        }
1425
1426        let Some(content) = line.strip_prefix('+') else {
1427            if !in_fence {
1428                flush(&mut blocks, &mut current);
1429            }
1430            continue;
1431        };
1432
1433        let trimmed = content.trim();
1434        let starts_new_block = !current.is_empty()
1435            && !in_fence
1436            && ((current.last().is_some_and(|line| line.trim().is_empty()) && !trimmed.is_empty())
1437                || trimmed.starts_with('❯'));
1438        if starts_new_block && current.iter().any(|line| !line.trim().is_empty()) {
1439            flush(&mut blocks, &mut current);
1440        }
1441
1442        if current.is_empty() && !block_start(content) {
1443            continue;
1444        }
1445
1446        current.push(content.to_string());
1447
1448        if !in_fence {
1449            if let Some((fc, fl)) = fence_open(trimmed) {
1450                in_fence = true;
1451                fence_char = fc;
1452                fence_len = fl;
1453            }
1454        } else if fence_close(trimmed, fence_char, fence_len) {
1455            in_fence = false;
1456        }
1457    }
1458
1459    flush(&mut blocks, &mut current);
1460    blocks
1461}
1462
1463/// Flush buffered remove lines as `[user-]`.
1464fn flush_removes(pending: &mut Vec<String>, result: &mut Vec<String>) {
1465    for removed in pending.drain(..) {
1466        result.push(format!("[user-] {}", removed));
1467    }
1468}
1469
1470/// Count characters of common prefix between two strings.
1471fn common_prefix_len(a: &str, b: &str) -> usize {
1472    a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
1473}
1474
1475/// Known Claude Code built-in slash command names (without arguments).
1476/// These affect Claude Code session state and cannot be invoked via the Skill tool.
1477const BUILTIN_COMMAND_NAMES: &[&str] = &[
1478    "/help",
1479    "/model",
1480    "/clear",
1481    "/compact",
1482    "/cost",
1483    "/login",
1484    "/logout",
1485    "/status",
1486    "/config",
1487    "/memory",
1488    "/review",
1489    "/bug",
1490    "/fast",
1491    "/slow",
1492    "/permissions",
1493    "/terminal-setup",
1494    "/doctor",
1495    "/init",
1496    "/pr-comments",
1497    "/vim",
1498    "/diff",
1499    "/undo",
1500    "/resume",
1501    "/listen",
1502    "/mcp",
1503    "/approved-tools",
1504    "/add-dir",
1505    "/release-notes",
1506    "/hooks",
1507    "/btw",
1508];
1509
1510/// Returns true if the command is a Claude Code built-in (not invocable via Skill tool).
1511pub fn is_builtin_command(cmd: &str) -> bool {
1512    let cmd_name = cmd.split_whitespace().next().unwrap_or("");
1513    BUILTIN_COMMAND_NAMES.contains(&cmd_name)
1514}
1515
1516/// Result of classifying slash commands from a diff.
1517pub struct ParsedSlashCommands {
1518    /// Skill commands (non-built-ins) — route to Skill tool.
1519    pub skill_commands: Vec<String>,
1520    /// Claude Code built-in commands — cannot invoke via Skill tool.
1521    pub builtin_commands: Vec<String>,
1522}
1523
1524/// Structured natural-language orchestration request detected from the diff.
1525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1526#[serde(rename_all = "snake_case")]
1527pub struct OrchestrationRequest {
1528    /// Resolved orchestration mode the skill should dispatch.
1529    pub mode: OrchestrationRequestMode,
1530    /// The triggering user-authored line or synthesized block summary.
1531    pub trigger_text: String,
1532    /// Number of task list items detected in the same added block.
1533    pub task_count: usize,
1534}
1535
1536/// Supported orchestration modes surfaced through preflight.
1537#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1538#[serde(rename_all = "snake_case")]
1539pub enum OrchestrationRequestMode {
1540    Sequential,
1541    Parallel,
1542    Dag,
1543}
1544
1545/// Extract ordered unique prompt preset references from user-added diff lines.
1546pub fn detect_prompt_preset_requests(diff: &str) -> Vec<String> {
1547    let mut requests = Vec::new();
1548    let mut in_fence = false;
1549    let mut fence_char = '`';
1550    let mut fence_len = 0usize;
1551
1552    for line in diff.lines() {
1553        if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
1554            continue;
1555        }
1556
1557        let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
1558            &line[1..]
1559        } else {
1560            line
1561        };
1562        let trimmed = content.trim_start();
1563
1564        if !in_fence {
1565            let fc = trimmed.chars().next().unwrap_or('\0');
1566            if fc == '`' || fc == '~' {
1567                let fl = trimmed.chars().take_while(|&c| c == fc).count();
1568                if fl >= 3 {
1569                    in_fence = true;
1570                    fence_char = fc;
1571                    fence_len = fl;
1572                    continue;
1573                }
1574            }
1575        } else {
1576            let fc = trimmed.chars().next().unwrap_or('\0');
1577            if fc == fence_char {
1578                let fl = trimmed.chars().take_while(|&c| c == fc).count();
1579                if fl >= fence_len && trimmed[fl..].trim().is_empty() {
1580                    in_fence = false;
1581                }
1582            }
1583            continue;
1584        }
1585
1586        if !line.starts_with('+') || line.starts_with("+++") {
1587            continue;
1588        }
1589        if content.starts_with('>') {
1590            continue;
1591        }
1592
1593        collect_prompt_preset_requests_from_line(content, &mut requests);
1594    }
1595
1596    requests
1597}
1598
1599/// Extract ordered unique prompt preset references from plain text.
1600pub fn extract_prompt_preset_requests_from_text(text: &str) -> Vec<String> {
1601    let mut requests = Vec::new();
1602    let mut in_fence = false;
1603    let mut fence_char = '`';
1604    let mut fence_len = 0usize;
1605
1606    for line in text.lines() {
1607        let trimmed = line.trim_start();
1608
1609        if !in_fence {
1610            let fc = trimmed.chars().next().unwrap_or('\0');
1611            if fc == '`' || fc == '~' {
1612                let fl = trimmed.chars().take_while(|&c| c == fc).count();
1613                if fl >= 3 {
1614                    in_fence = true;
1615                    fence_char = fc;
1616                    fence_len = fl;
1617                    continue;
1618                }
1619            }
1620        } else {
1621            let fc = trimmed.chars().next().unwrap_or('\0');
1622            if fc == fence_char {
1623                let fl = trimmed.chars().take_while(|&c| c == fc).count();
1624                if fl >= fence_len && trimmed[fl..].trim().is_empty() {
1625                    in_fence = false;
1626                }
1627            }
1628            continue;
1629        }
1630
1631        if trimmed.starts_with('>') {
1632            continue;
1633        }
1634
1635        collect_prompt_preset_requests_from_line(line, &mut requests);
1636    }
1637
1638    requests
1639}
1640
1641fn collect_prompt_preset_requests_from_line(line: &str, requests: &mut Vec<String>) {
1642    let Some(names) = parse_prompt_preset_directive(line) else {
1643        return;
1644    };
1645    for name in names {
1646        if !requests.iter().any(|existing| existing == &name) {
1647            requests.push(name);
1648        }
1649    }
1650}
1651
1652fn parse_prompt_preset_directive(line: &str) -> Option<Vec<String>> {
1653    let mut trimmed = line.trim();
1654    if trimmed.is_empty() {
1655        return None;
1656    }
1657
1658    if let Some(rest) = trimmed.strip_prefix('❯') {
1659        trimmed = rest.trim_start();
1660    }
1661
1662    if let Some(rest) = trimmed
1663        .strip_prefix("- ")
1664        .or_else(|| trimmed.strip_prefix("* "))
1665        .or_else(|| trimmed.strip_prefix("+ "))
1666    {
1667        trimmed = rest.trim_start();
1668    } else {
1669        let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
1670        if digits > 0 {
1671            let rest = &trimmed[digits..];
1672            if let Some(rest) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
1673                trimmed = rest.trim_start();
1674            }
1675        }
1676    }
1677
1678    let lower = trimmed.to_ascii_lowercase();
1679    let rest = if lower.starts_with("presets ") {
1680        &trimmed["presets ".len()..]
1681    } else if lower.starts_with("preset ") {
1682        &trimmed["preset ".len()..]
1683    } else {
1684        return None;
1685    };
1686
1687    let rest = rest.trim();
1688    if rest.is_empty() {
1689        return None;
1690    }
1691
1692    let mut names = Vec::new();
1693    for segment in rest.split(',') {
1694        let segment = segment.trim();
1695        if segment.is_empty() {
1696            continue;
1697        }
1698        for part in segment.split(" and ") {
1699            let name = part.trim().trim_end_matches(['.', ':', ';']).trim();
1700            if !name.is_empty() {
1701                names.push(name.to_string());
1702            }
1703        }
1704    }
1705
1706    (!names.is_empty()).then_some(names)
1707}
1708
1709/// Like `parse_slash_commands` but classifies results into skill vs built-in commands.
1710pub fn parse_slash_commands_classified(diff: &str) -> ParsedSlashCommands {
1711    let commands = parse_slash_commands(diff);
1712    let mut skill_commands = Vec::new();
1713    let mut builtin_commands = Vec::new();
1714    for cmd in commands {
1715        if is_builtin_command(&cmd) {
1716            builtin_commands.push(cmd);
1717        } else {
1718            skill_commands.push(cmd);
1719        }
1720    }
1721    ParsedSlashCommands {
1722        skill_commands,
1723        builtin_commands,
1724    }
1725}
1726
1727/// Return the slash commands when every substantive added diff line is a slash
1728/// command outside fences and blockquotes. Returns `None` for mixed prompt text,
1729/// fenced examples, blockquotes, or diffs with no slash commands.
1730pub fn parse_slash_command_only_added_diff(diff: &str) -> Option<Vec<String>> {
1731    let mut commands = Vec::new();
1732    let mut in_fence = false;
1733    let mut fence_char = '`';
1734    let mut fence_len = 0usize;
1735
1736    for line in diff.lines() {
1737        if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
1738            continue;
1739        }
1740
1741        let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
1742            &line[1..]
1743        } else {
1744            line
1745        };
1746        let trimmed = content.trim_start();
1747        let was_in_fence = in_fence;
1748        let mut fence_delimiter = false;
1749
1750        if !in_fence {
1751            let fc = trimmed.chars().next().unwrap_or('\0');
1752            if fc == '`' || fc == '~' {
1753                let fl = trimmed.chars().take_while(|&c| c == fc).count();
1754                if fl >= 3 {
1755                    in_fence = true;
1756                    fence_char = fc;
1757                    fence_len = fl;
1758                    fence_delimiter = true;
1759                }
1760            }
1761        } else {
1762            let fc = trimmed.chars().next().unwrap_or('\0');
1763            if fc == fence_char {
1764                let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
1765                if fl >= fence_len && trimmed[fl..].trim().is_empty() {
1766                    in_fence = false;
1767                    fence_delimiter = true;
1768                }
1769            }
1770        }
1771
1772        if !line.starts_with('+') || line.starts_with("+++") {
1773            continue;
1774        }
1775
1776        if content.trim().is_empty() {
1777            continue;
1778        }
1779        if was_in_fence || fence_delimiter || trimmed.starts_with('>') {
1780            return None;
1781        }
1782        let command = parse_slash_command_candidate(content)?;
1783        commands.push(command);
1784    }
1785
1786    (!commands.is_empty()).then_some(commands)
1787}
1788
1789/// Extract slash commands from user-added lines in a unified diff.
1790///
1791/// Guards against false positives:
1792/// - Lines inside code fences (``` or ~~~) are excluded.
1793/// - Blockquote lines (starting with `>`) are excluded.
1794/// - Only lines added by the user (`+` prefix, not `+++`) are inspected.
1795/// - The command must start with `/` followed immediately by an ASCII letter.
1796///
1797/// Returns the trimmed command strings (including any arguments after the command).
1798pub fn parse_slash_commands(diff: &str) -> Vec<String> {
1799    let mut commands = Vec::new();
1800    let mut in_fence = false;
1801    let mut fence_char = '`';
1802    let mut fence_len = 0usize;
1803
1804    for line in diff.lines() {
1805        // Skip unified diff meta-lines.
1806        if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
1807            continue;
1808        }
1809
1810        // Strip leading diff marker to get the actual content.
1811        let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
1812            &line[1..]
1813        } else {
1814            line
1815        };
1816
1817        // Track code-fence state across all lines (added, removed, and context).
1818        let trimmed = content.trim_start();
1819        if !in_fence {
1820            let fc = trimmed.chars().next().unwrap_or('\0');
1821            if fc == '`' || fc == '~' {
1822                let fl = trimmed.chars().take_while(|&c| c == fc).count();
1823                if fl >= 3 {
1824                    in_fence = true;
1825                    fence_char = fc;
1826                    fence_len = fl;
1827                    continue; // Fence delimiter itself is not a command.
1828                }
1829            }
1830        } else {
1831            // Check for matching closing fence.
1832            let fc = trimmed.chars().next().unwrap_or('\0');
1833            if fc == fence_char {
1834                let fl = trimmed.chars().take_while(|&c| c == fc).count();
1835                if fl >= fence_len && trimmed[fl..].trim().is_empty() {
1836                    in_fence = false;
1837                    continue; // Closing delimiter — not a command.
1838                }
1839            }
1840        }
1841
1842        // Only process user-added lines (not context, removed, or meta).
1843        if !line.starts_with('+') || line.starts_with("+++") {
1844            continue;
1845        }
1846
1847        // Skip lines inside code fences.
1848        if in_fence {
1849            continue;
1850        }
1851
1852        // Skip blockquotes.
1853        if content.trim_start().starts_with('>') {
1854            continue;
1855        }
1856
1857        // Must start with '/' followed by a command-like token.
1858        // Grammar: `/[a-z][a-z0-9:_-]*` with no additional `/` in the token.
1859        // This rejects absolute paths like `/home/brian/...` and `/tmp/foo`
1860        // that look like slash commands but are really filesystem paths.
1861        if let Some(command) = parse_slash_command_candidate(content) {
1862            commands.push(command);
1863        }
1864    }
1865
1866    commands
1867}
1868
1869/// Detect a natural-language orchestration request from user-added diff lines.
1870///
1871/// This is the binary-owned counterpart to the skill-level command-synonym
1872/// guidance: if the user adds a batch-oriented preamble plus a task list, the
1873/// caller can route through `agent-doc orchestrate --from-exchange` instead of
1874/// relying on the agent to remember that prose convention.
1875pub fn detect_orchestration_request(diff: &str) -> Option<OrchestrationRequest> {
1876    for block in collect_added_text_blocks(diff) {
1877        let task_count = block
1878            .iter()
1879            .filter(|line| parse_markdown_list_item(line).is_some())
1880            .count();
1881        if task_count < 2 {
1882            continue;
1883        }
1884
1885        let trigger_lines: Vec<&str> = block
1886            .iter()
1887            .map(|line| line.trim())
1888            .filter(|line| !line.is_empty() && parse_markdown_list_item(line).is_none())
1889            .collect();
1890        let trigger_text = if trigger_lines.is_empty() {
1891            block.join(" ")
1892        } else {
1893            trigger_lines.join(" ")
1894        };
1895        let Some(mode) = detect_orchestration_mode(&trigger_text) else {
1896            continue;
1897        };
1898
1899        return Some(OrchestrationRequest {
1900            mode,
1901            trigger_text,
1902            task_count,
1903        });
1904    }
1905
1906    None
1907}
1908
1909/// Detect whether the user wrote `do queue` or `run queue` in an added diff line.
1910///
1911/// Returns `true` when any user-added line (outside code fences/blockquotes)
1912/// starts with one of these trigger phrases. Does NOT match `do #queue` (pending
1913/// item reference) — only the bare component activation form.
1914pub fn detect_queue_trigger(diff: &str) -> bool {
1915    let mut in_fence = false;
1916    let mut fence_char = '`';
1917    let mut fence_len = 0usize;
1918
1919    for line in diff.lines() {
1920        if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
1921            continue;
1922        }
1923        if !line.starts_with('+') {
1924            continue;
1925        }
1926        let content = &line[1..];
1927        let trimmed = content.trim_start();
1928
1929        // Track code fences
1930        if !in_fence {
1931            let fc = trimmed.chars().next().unwrap_or('\0');
1932            if fc == '`' || fc == '~' {
1933                let fl = trimmed.chars().take_while(|&c| c == fc).count();
1934                if fl >= 3 {
1935                    in_fence = true;
1936                    fence_char = fc;
1937                    fence_len = fl;
1938                    continue;
1939                }
1940            }
1941        } else {
1942            let fc = trimmed.chars().next().unwrap_or('\0');
1943            if fc == fence_char {
1944                let fl = trimmed.chars().take_while(|&c| c == fc).count();
1945                if fl >= fence_len {
1946                    in_fence = false;
1947                }
1948            }
1949            continue;
1950        }
1951
1952        // Skip blockquotes
1953        if trimmed.starts_with('>') {
1954            continue;
1955        }
1956
1957        // Strip the `❯ ` prompt prefix if present
1958        let text = trimmed.strip_prefix("❯ ").unwrap_or(trimmed);
1959        let lower = text.to_lowercase();
1960
1961        if lower.starts_with("do queue") || lower.starts_with("run queue") {
1962            let after = if lower.starts_with("do queue") {
1963                &text[8..]
1964            } else {
1965                &text[9..]
1966            };
1967            // Must be end of line, or followed by non-alphanumeric (punctuation, space)
1968            if after.is_empty() || after.starts_with(|c: char| !c.is_alphanumeric() && c != '#') {
1969                return true;
1970            }
1971        }
1972    }
1973    false
1974}
1975
1976/// Build a unified diff directly from two content strings after comment stripping.
1977///
1978/// Returns `None` when there are no meaningful changes.
1979pub fn unified_diff_from_contents(previous: &str, current: &str) -> Option<String> {
1980    let previous_stripped = strip_comments(previous);
1981    let current_stripped = strip_comments(current);
1982    let diff = TextDiff::from_lines(&previous_stripped, &current_stripped);
1983    let has_changes = diff.iter_all_changes().any(|c| c.tag() != ChangeTag::Equal);
1984    if !has_changes {
1985        return None;
1986    }
1987    Some(
1988        diff.unified_diff()
1989            .context_radius(5)
1990            .header("snapshot", "document")
1991            .to_string(),
1992    )
1993}
1994
1995/// Build a minimal unified diff from an in-memory prompt body.
1996///
1997/// This is used for binary-owned prompt sources that do not directly mutate the
1998/// session document, such as harness prompt bodies and active queue items.
1999pub fn synthetic_added_lines_diff(body: &str, target: &str) -> String {
2000    let lines = body.lines().collect::<Vec<_>>();
2001    let count = lines.len().max(1);
2002    let mut diff = format!("--- snapshot\n+++ {target}\n@@ -0,0 +1,{count} @@\n");
2003    if lines.is_empty() {
2004        diff.push_str("+\n");
2005        return diff;
2006    }
2007    for line in lines {
2008        diff.push('+');
2009        diff.push_str(line);
2010        diff.push('\n');
2011    }
2012    diff
2013}
2014
2015/// Extract imperative user directives from added lines in a unified diff.
2016///
2017/// This is a conservative parser for the binary-enforced directive contract.
2018/// It only recognizes clear imperative shapes and approval words, while
2019/// skipping code fences and blockquotes to avoid false positives.
2020pub fn extract_imperative_directives(diff: &str) -> Vec<String> {
2021    let mut directives = Vec::new();
2022    let mut in_fence = false;
2023    let mut fence_char = '`';
2024    let mut fence_len = 0usize;
2025
2026    for line in diff.lines() {
2027        if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
2028            continue;
2029        }
2030
2031        let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
2032            &line[1..]
2033        } else {
2034            line
2035        };
2036        let trimmed = content.trim_start();
2037
2038        if !in_fence {
2039            let fc = trimmed.chars().next().unwrap_or('\0');
2040            if fc == '`' || fc == '~' {
2041                let fl = trimmed.chars().take_while(|&c| c == fc).count();
2042                if fl >= 3 {
2043                    in_fence = true;
2044                    fence_char = fc;
2045                    fence_len = fl;
2046                    continue;
2047                }
2048            }
2049        } else {
2050            let fc = trimmed.chars().next().unwrap_or('\0');
2051            if fc == fence_char {
2052                let fl = trimmed.chars().take_while(|&c| c == fc).count();
2053                if fl >= fence_len && trimmed[fl..].trim().is_empty() {
2054                    in_fence = false;
2055                    continue;
2056                }
2057            }
2058        }
2059
2060        if !line.starts_with('+') || line.starts_with("+++") || in_fence {
2061            continue;
2062        }
2063
2064        if content.starts_with('>') {
2065            continue;
2066        }
2067
2068        let Some(normalized) = normalize_imperative_candidate(content) else {
2069            continue;
2070        };
2071
2072        if looks_like_imperative_directive(&normalized) {
2073            directives.push(normalized);
2074        }
2075    }
2076
2077    directives
2078}
2079
2080/// Returns true when the diff contains an imperative work directive or a
2081/// one-word approval that authorizes the next step.
2082pub fn diff_contains_imperative_directive(diff: &str) -> bool {
2083    if !extract_imperative_directives(diff).is_empty() {
2084        return true;
2085    }
2086    matches!(classify_diff(diff).diff_type, DiffType::Approval)
2087}
2088
2089/// True when `trimmed` (a line already left-trimmed of whitespace) opens with a
2090/// markdown list bullet (`- `, `* `, `+ `, or an ordered `N. `). Used to exclude
2091/// queue/backlog/review task entries from same-turn exchange-directive detection
2092/// (`#qcompactfp`).
2093fn trimmed_is_markdown_list_item(trimmed: &str) -> bool {
2094    if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
2095        return true;
2096    }
2097    // Ordered-list `N. ` bullet.
2098    if let Some(dot) = trimmed.find(". ") {
2099        let head = &trimmed[..dot];
2100        if !head.is_empty() && head.chars().all(|c| c.is_ascii_digit()) {
2101            return true;
2102        }
2103    }
2104    false
2105}
2106
2107/// Detect whether the user explicitly requested exchange compaction in added
2108/// diff lines.
2109///
2110/// This only matches direct imperative forms that start with `compact exchange`
2111/// (or `compact the exchange`) after prompt/pending normalization, and only when
2112/// authored as exchange prose — a queue/backlog/review **list item** that merely
2113/// begins with those words is a task entry, not a same-turn directive, and is
2114/// skipped (`#qcompactfp`).
2115pub fn detect_exchange_compaction_request(diff: &str) -> bool {
2116    let mut in_fence = false;
2117    let mut fence_char = '`';
2118    let mut fence_len = 0usize;
2119
2120    for line in diff.lines() {
2121        if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
2122            continue;
2123        }
2124
2125        let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
2126            &line[1..]
2127        } else {
2128            line
2129        };
2130        let trimmed = content.trim_start();
2131
2132        if !in_fence {
2133            let fc = trimmed.chars().next().unwrap_or('\0');
2134            if fc == '`' || fc == '~' {
2135                let fl = trimmed.chars().take_while(|&c| c == fc).count();
2136                if fl >= 3 {
2137                    in_fence = true;
2138                    fence_char = fc;
2139                    fence_len = fl;
2140                    continue;
2141                }
2142            }
2143        } else {
2144            let fc = trimmed.chars().next().unwrap_or('\0');
2145            if fc == fence_char {
2146                let fl = trimmed.chars().take_while(|&c| c == fc).count();
2147                if fl >= fence_len && trimmed[fl..].trim().is_empty() {
2148                    in_fence = false;
2149                    continue;
2150                }
2151            }
2152        }
2153
2154        if !line.starts_with('+')
2155            || line.starts_with("+++")
2156            || in_fence
2157            || content.starts_with('>')
2158            || trimmed_is_markdown_list_item(trimmed)
2159        {
2160            // A markdown list item (`- `/`* `/`+ `/`N. `) is a queue/backlog/review
2161            // task entry, never a same-turn exchange compaction directive — a real
2162            // `compact exchange` request is exchange prose (optionally `❯`-prefixed).
2163            // Skipping bullets prevents a queued bug-report head such as
2164            // "Compact exchange should commit the compacted content" from falsely
2165            // aborting finalize (#qcompactfp).
2166            continue;
2167        }
2168
2169        let Some(normalized) = normalize_imperative_candidate(content) else {
2170            continue;
2171        };
2172        let lower = normalized.to_ascii_lowercase();
2173        if lower.starts_with("compact exchange") || lower.starts_with("compact the exchange") {
2174            return true;
2175        }
2176    }
2177
2178    false
2179}
2180
2181/// Extract ordered user-authored request blocks from added diff lines.
2182///
2183/// This is a prompt-building helper, not a semantic proof that every request
2184/// was answered. It makes the changed exchange tail explicit so the agent is
2185/// reminded to address the full oldest-first set of prompts instead of only the
2186/// newest visible question.
2187#[allow(dead_code)]
2188pub fn extract_required_response_blocks(diff: &str) -> Vec<String> {
2189    extract_prompt_target_blocks(diff)
2190}
2191
2192/// Render extracted request blocks as a prompt-ready turn-completeness section.
2193#[allow(dead_code)]
2194pub fn format_required_response_targets(diff: &str) -> Option<String> {
2195    let blocks = extract_required_response_blocks(diff);
2196    if blocks.is_empty() {
2197        return None;
2198    }
2199
2200    let mut out = String::from(
2201        "Required response targets (oldest first):\n\
2202         Do not stop at the newest question. The turn is incomplete until each item below is answered or explicitly grouped into one response.\n\n",
2203    );
2204    for (idx, block) in blocks.iter().enumerate() {
2205        out.push_str(&format!(
2206            "<target index=\"{}\">\n{}\n</target>\n\n",
2207            idx + 1,
2208            block
2209        ));
2210    }
2211    Some(out)
2212}
2213
2214/// Render all prompt-bearing changes as a prompt-ready section.
2215pub fn format_prompt_bearing_changes(diff: &str) -> Option<String> {
2216    let changes = classify_prompt_bearing_changes(diff);
2217    if changes.is_empty() {
2218        return None;
2219    }
2220
2221    let mut out = String::from(
2222        "User-authored prompt-bearing changes (oldest first):\n\
2223         Do not stop at the newest question. The turn is incomplete until each `prompt_target` item below is answered or explicitly grouped into one response.\n\
2224         Treat `content_edit` items as user corrections to incorporate, and treat `recovery_artifact` / `boundary_artifact` items as document-state signals to normalize rather than ordinary conversation.\n\n",
2225    );
2226    for (idx, change) in changes.iter().enumerate() {
2227        out.push_str(&format!(
2228            "<change index=\"{}\" kind=\"{}\">\n{}\n</change>\n\n",
2229            idx + 1,
2230            serde_json::to_string(&change.kind)
2231                .unwrap_or_else(|_| "\"prompt_target\"".to_string())
2232                .trim_matches('"'),
2233            change.text
2234        ));
2235    }
2236    Some(out)
2237}
2238
2239fn looks_like_imperative_directive(line: &str) -> bool {
2240    let lower = line.to_ascii_lowercase();
2241    let compact = lower.trim();
2242    let exact = compact.trim_end_matches(|c: char| c.is_ascii_punctuation());
2243    if APPROVAL_WORDS.contains(&exact) {
2244        return true;
2245    }
2246    compact.starts_with("do #")
2247        || compact.starts_with("do [#")
2248        || compact.starts_with("fix #")
2249        || compact.starts_with("fix this")
2250        || compact.contains(" run tests")
2251        || compact.starts_with("run tests")
2252        || compact.contains(" run benchmarks")
2253        || compact.starts_with("run benchmarks")
2254        || compact.contains(" build + install")
2255        || compact.starts_with("build + install")
2256        || compact.contains(" build and install")
2257        || compact.starts_with("build and install")
2258        || compact.contains(" commit + push")
2259        || compact.starts_with("commit + push")
2260        || compact.contains(" commit and push")
2261        || compact.starts_with("commit and push")
2262        || starts_with_imperative_verb(compact)
2263}
2264
2265fn normalize_imperative_candidate(line: &str) -> Option<String> {
2266    let mut trimmed = line.trim();
2267    if trimmed.is_empty() {
2268        return None;
2269    }
2270
2271    if let Some(rest) = trimmed.strip_prefix("- ") {
2272        trimmed = rest.trim_start();
2273    }
2274
2275    trimmed = strip_pending_checkbox_prefix(trimmed);
2276
2277    if let Some(rest) = trimmed.strip_prefix("[#")
2278        && let Some(close) = rest.find(']')
2279    {
2280        let id = &rest[..close];
2281        if crate::pending::is_valid_pending_id(id) {
2282            trimmed = rest[close + 1..].trim_start();
2283        }
2284    }
2285
2286    let normalized = trimmed
2287        .trim_start_matches('❯')
2288        .trim_start()
2289        .trim_end_matches(|c: char| c.is_ascii_punctuation() && c != ']')
2290        .trim();
2291    if normalized.is_empty() {
2292        None
2293    } else {
2294        Some(normalized.to_string())
2295    }
2296}
2297
2298fn collect_added_text_blocks(diff: &str) -> Vec<Vec<String>> {
2299    let mut blocks = Vec::new();
2300    let mut current = Vec::new();
2301    let mut in_fence = false;
2302    let mut fence_char = '`';
2303    let mut fence_len = 0usize;
2304    let mut in_recovery_response_tail = false;
2305
2306    for line in diff.lines() {
2307        if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
2308            if !current.is_empty() {
2309                blocks.push(std::mem::take(&mut current));
2310            }
2311            in_recovery_response_tail = false;
2312            continue;
2313        }
2314
2315        let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
2316            &line[1..]
2317        } else {
2318            line
2319        };
2320        let trimmed = content.trim_start();
2321
2322        if !in_fence {
2323            let fc = trimmed.chars().next().unwrap_or('\0');
2324            if fc == '`' || fc == '~' {
2325                let fl = trimmed.chars().take_while(|&c| c == fc).count();
2326                if fl >= 3 {
2327                    in_fence = true;
2328                    fence_char = fc;
2329                    fence_len = fl;
2330                    continue;
2331                }
2332            }
2333        } else {
2334            let fc = trimmed.chars().next().unwrap_or('\0');
2335            if fc == fence_char {
2336                let fl = trimmed.chars().take_while(|&c| c == fc).count();
2337                if fl >= fence_len && trimmed[fl..].trim().is_empty() {
2338                    in_fence = false;
2339                }
2340            }
2341            continue;
2342        }
2343
2344        if !line.starts_with('+') || line.starts_with("+++") {
2345            if !current.is_empty() {
2346                blocks.push(std::mem::take(&mut current));
2347            }
2348            in_recovery_response_tail = false;
2349            continue;
2350        }
2351
2352        if content.starts_with('>') {
2353            continue;
2354        }
2355
2356        if is_exchange_response_heading(trimmed) {
2357            if !current.is_empty() {
2358                blocks.push(std::mem::take(&mut current));
2359            }
2360            in_recovery_response_tail = true;
2361            continue;
2362        }
2363
2364        if in_recovery_response_tail {
2365            if line_looks_like_targeted_prompt_prefix_repair_start(
2366                trimmed,
2367                line_looks_like_prompt_target(trimmed),
2368            ) {
2369                in_recovery_response_tail = false;
2370            } else {
2371                continue;
2372            }
2373        }
2374
2375        current.push(content.trim_end().to_string());
2376    }
2377
2378    if !current.is_empty() {
2379        blocks.push(current);
2380    }
2381
2382    blocks
2383}
2384
2385fn parse_markdown_list_item(line: &str) -> Option<&str> {
2386    let trimmed = strip_prompt_prefix(line.trim_start());
2387    for prefix in ["- ", "* ", "+ "] {
2388        if let Some(rest) = trimmed.strip_prefix(prefix) {
2389            let rest = rest.trim();
2390            return (!rest.is_empty()).then_some(rest);
2391        }
2392    }
2393
2394    let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
2395    if digits > 0 {
2396        let rest = &trimmed[digits..];
2397        if let Some(rest) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
2398            let rest = rest.trim();
2399            return (!rest.is_empty()).then_some(rest);
2400        }
2401    }
2402
2403    None
2404}
2405
2406pub fn line_looks_like_markdown_list_item(line: &str) -> bool {
2407    parse_markdown_list_item(line).is_some()
2408}
2409
2410fn strip_prompt_prefix(line: &str) -> &str {
2411    line.strip_prefix("❯ ")
2412        .or_else(|| line.strip_prefix('❯'))
2413        .map(str::trim_start)
2414        .unwrap_or(line)
2415}
2416
2417fn detect_orchestration_mode(text: &str) -> Option<OrchestrationRequestMode> {
2418    let lower = text.to_ascii_lowercase();
2419
2420    if contains_any(
2421        &lower,
2422        &[
2423            "dependency graph",
2424            "depends on",
2425            "blocked by",
2426            "prerequisite",
2427            "then unblock",
2428        ],
2429    ) || lower.contains("after #")
2430    {
2431        return Some(OrchestrationRequestMode::Dag);
2432    }
2433
2434    if contains_any(
2435        &lower,
2436        &[
2437            "fan out",
2438            "concurrent",
2439            "at the same time",
2440            "in parallel",
2441            "simultaneously",
2442            "parallelize",
2443        ],
2444    ) {
2445        return Some(OrchestrationRequestMode::Parallel);
2446    }
2447
2448    if contains_any(
2449        &lower,
2450        &[
2451            "orchestra",
2452            "orcestra",
2453            "orchestrate",
2454            "chain",
2455            "in order",
2456            "one by one",
2457            "sequential",
2458            "sequentially",
2459            "synchronous",
2460            "run these sequentially",
2461        ],
2462    ) {
2463        return Some(OrchestrationRequestMode::Sequential);
2464    }
2465
2466    None
2467}
2468
2469fn contains_any(text: &str, needles: &[&str]) -> bool {
2470    needles.iter().any(|needle| text.contains(needle))
2471}
2472
2473fn strip_pending_checkbox_prefix(line: &str) -> &str {
2474    let trimmed = line.trim_start();
2475    for prefix in ["[ ]", "[/]", "[x]", "[X]"] {
2476        if let Some(rest) = trimmed.strip_prefix(prefix) {
2477            return rest.trim_start();
2478        }
2479    }
2480    if let Some(inner) = trimmed.strip_prefix("[/")
2481        && let Some(close) = inner.find(']')
2482    {
2483        let gate_type = &inner[..close];
2484        if !gate_type.is_empty()
2485            && gate_type
2486                .chars()
2487                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
2488        {
2489            return inner[close + 1..].trim_start();
2490        }
2491    }
2492    trimmed
2493}
2494
2495fn starts_with_imperative_verb(line: &str) -> bool {
2496    let mut words = line.split_whitespace();
2497    let Some(first) = words.next() else {
2498        return false;
2499    };
2500    if IMPERATIVE_LEADING_VERBS.contains(&first) {
2501        return true;
2502    }
2503    matches!((first, words.next()), ("clean", Some("up")))
2504}
2505
2506/// Detect whether the diff between snapshot and document is a stale snapshot
2507/// (previous cycle wrote the response but didn't update the snapshot).
2508///
2509/// Returns `true` if:
2510/// - The document contains the snapshot content as a prefix
2511/// - The content after the snapshot is only complete `## Assistant` / `## User` exchanges
2512/// - The trailing `## User` block is empty (no new user content)
2513///
2514/// Returns `false` if there is any new user content that needs a response.
2515pub fn is_stale_snapshot(snapshot_content: &str, document_content: &str) -> bool {
2516    let snap_stripped = strip_comments(snapshot_content);
2517    let doc_stripped = strip_comments(document_content);
2518
2519    // Document must be longer than snapshot
2520    if doc_stripped.len() <= snap_stripped.len() {
2521        return false;
2522    }
2523
2524    // Check that the document starts with the snapshot content
2525    // Use trimmed comparison to handle trailing whitespace differences
2526    let snap_trimmed = snap_stripped.trim_end();
2527    let doc_trimmed = doc_stripped.trim_end();
2528
2529    if !doc_trimmed.starts_with(snap_trimmed) {
2530        return false;
2531    }
2532
2533    // Get the "extra" content beyond the snapshot
2534    let extra = &doc_stripped[snap_trimmed.len()..];
2535    let extra_trimmed = extra.trim();
2536
2537    if extra_trimmed.is_empty() {
2538        return false;
2539    }
2540
2541    // The extra content should contain at least one ## Assistant block
2542    if !extra_trimmed.contains("## Assistant") {
2543        return false;
2544    }
2545
2546    // Check if the last ## User block is empty (no new user content)
2547    // Split on "## User" and check the last segment
2548    let parts: Vec<&str> = extra_trimmed.split("## User").collect();
2549    if let Some(last_user_block) = parts.last() {
2550        let user_content = last_user_block.trim();
2551        // Empty user block = stale snapshot recovery
2552        // Non-empty user block = user has new input
2553        user_content.is_empty()
2554    } else {
2555        // No ## User block at all — not a standard exchange pattern
2556        false
2557    }
2558}
2559
2560/// Print the diff to stdout (for the `diff` subcommand).
2561///
2562/// When `wait` is true, reads the file and snapshot, runs truncation
2563/// detection via `wait_for_stable_content()`, then outputs the diff.
2564/// Approval words (case-insensitive).
2565const APPROVAL_WORDS: &[&str] = &[
2566    "go", "yes", "do", "ok", "continue", "approve", "approved", "y", "yep", "yeah", "sure",
2567    "proceed", "lgtm",
2568];
2569
2570/// Classify a unified diff into a `DiffType` with a human-readable reason.
2571///
2572/// Operates purely on the diff text — no file I/O.
2573pub fn classify_diff(diff_text: &str) -> DiffClassification {
2574    // Parse added and removed lines from the unified diff, skipping headers.
2575    let mut added: Vec<String> = Vec::new();
2576    let mut removed: Vec<String> = Vec::new();
2577    let mut added_block_count = 0usize;
2578    let mut in_added_block = false;
2579
2580    for line in diff_text.lines() {
2581        // Skip unified diff headers
2582        if line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("@@ ") {
2583            in_added_block = false;
2584            continue;
2585        }
2586        if let Some(content) = line.strip_prefix('+') {
2587            added.push(content.to_string());
2588            if !in_added_block {
2589                added_block_count += 1;
2590                in_added_block = true;
2591            }
2592        } else if let Some(content) = line.strip_prefix('-') {
2593            removed.push(content.to_string());
2594            in_added_block = false;
2595        } else {
2596            // Context line
2597            in_added_block = false;
2598        }
2599    }
2600
2601    // Filter out empty/whitespace-only lines for classification purposes.
2602    let added_content: Vec<&str> = added
2603        .iter()
2604        .map(|s| s.trim())
2605        .filter(|s| !s.is_empty())
2606        .collect();
2607    let removed_content: Vec<&str> = removed
2608        .iter()
2609        .map(|s| s.trim())
2610        .filter(|s| !s.is_empty())
2611        .collect();
2612
2613    // 1. BoundaryArtifact: only (HEAD) or boundary UUID changes
2614    if is_boundary_artifact(&added_content, &removed_content) {
2615        return DiffClassification {
2616            diff_type: DiffType::BoundaryArtifact,
2617            diff_type_reason: "only boundary marker or (HEAD) changes".into(),
2618        };
2619    }
2620
2621    // 2. Approval: single non-empty added word matching approval list, no removals
2622    if added_content.len() == 1 && removed_content.is_empty() {
2623        let word = added_content[0].to_lowercase();
2624        // Strip trailing punctuation for matching
2625        let word_clean = word.trim_end_matches(|c: char| c.is_ascii_punctuation());
2626        if APPROVAL_WORDS.contains(&word_clean) {
2627            return DiffClassification {
2628                diff_type: DiffType::Approval,
2629                diff_type_reason: format!("single approval word: \"{}\"", added_content[0]),
2630            };
2631        }
2632    }
2633
2634    // 3. SimpleQuestion: single added line ending with `?`, no removals
2635    if added_content.len() == 1 && removed_content.is_empty() && added_content[0].ends_with('?') {
2636        return DiffClassification {
2637            diff_type: DiffType::SimpleQuestion,
2638            diff_type_reason: format!(
2639                "single question: \"{}\"",
2640                truncate_for_reason(added_content[0])
2641            ),
2642        };
2643    }
2644
2645    // 4. Annotation: modifications to existing lines (added + removed with similar content)
2646    if !added_content.is_empty()
2647        && !removed_content.is_empty()
2648        && is_annotation(&added_content, &removed_content)
2649    {
2650        return DiffClassification {
2651            diff_type: DiffType::Annotation,
2652            diff_type_reason: "inline edit to existing content".into(),
2653        };
2654    }
2655
2656    // 5. StructuralChange: only removals, no additions
2657    if added_content.is_empty() && !removed_content.is_empty() {
2658        return DiffClassification {
2659            diff_type: DiffType::StructuralChange,
2660            diff_type_reason: format!("{} lines removed, no additions", removed_content.len()),
2661        };
2662    }
2663
2664    // 6. MultiTopic: multiple separated added blocks or --- separators
2665    let has_separator = added.iter().any(|l| l.trim() == "---");
2666    if has_separator && added_content.len() >= 2 {
2667        let section_count = added_content
2668            .split(|l| *l == "---")
2669            .filter(|s| !s.is_empty())
2670            .count();
2671        if section_count >= 2 {
2672            return DiffClassification {
2673                diff_type: DiffType::MultiTopic,
2674                diff_type_reason: format!("{} topics separated by ---", section_count),
2675            };
2676        }
2677    }
2678    if added_block_count >= 2 && added_content.len() >= 2 {
2679        return DiffClassification {
2680            diff_type: DiffType::MultiTopic,
2681            diff_type_reason: format!("{} distinct added blocks", added_block_count),
2682        };
2683    }
2684
2685    // 7. Default: ContentAddition
2686    DiffClassification {
2687        diff_type: DiffType::ContentAddition,
2688        diff_type_reason: format!(
2689            "{} lines added{}",
2690            added_content.len(),
2691            if !removed_content.is_empty() {
2692                format!(", {} removed", removed_content.len())
2693            } else {
2694                String::new()
2695            }
2696        ),
2697    }
2698}
2699
2700/// Check if the diff contains only boundary-related changes.
2701fn is_boundary_artifact(added: &[&str], removed: &[&str]) -> bool {
2702    let is_boundary_line =
2703        |line: &str| -> bool { is_boundary_artifact_line(line) || line.is_empty() };
2704    // Must have at least one change
2705    if added.is_empty() && removed.is_empty() {
2706        return false;
2707    }
2708    // Direct check: all lines are boundary-related
2709    if added.iter().all(|l| is_boundary_line(l)) && removed.iter().all(|l| is_boundary_line(l)) {
2710        return true;
2711    }
2712    // Paired check: added/removed pairs differ only by (HEAD) or boundary UUID
2713    if added.len() == removed.len() {
2714        return added.iter().zip(removed.iter()).all(|(a, r)| {
2715            let a_trim = a.trim();
2716            let r_trim = r.trim();
2717            if a_trim == r_trim {
2718                return true;
2719            }
2720            if a_trim.starts_with("<!-- agent:boundary:")
2721                && r_trim.starts_with("<!-- agent:boundary:")
2722            {
2723                return true;
2724            }
2725            if is_head_boundary_artifact(a_trim)
2726                && a_trim
2727                    .strip_suffix(" (HEAD)")
2728                    .is_some_and(|base| base.trim() == r_trim)
2729            {
2730                return true;
2731            }
2732            if is_head_boundary_artifact(r_trim)
2733                && r_trim
2734                    .strip_suffix(" (HEAD)")
2735                    .is_some_and(|base| base.trim() == a_trim)
2736            {
2737                return true;
2738            }
2739            false
2740        });
2741    }
2742    false
2743}
2744
2745/// Check if the diff looks like an annotation (inline edits to existing content).
2746///
2747/// Heuristic: each removed line has a corresponding added line that starts with
2748/// the same prefix (the added line extends the removed one, e.g., colon-appended).
2749fn is_annotation(added: &[&str], removed: &[&str]) -> bool {
2750    if added.len() != removed.len() {
2751        return false;
2752    }
2753    added.iter().zip(removed.iter()).all(|(a, r)| {
2754        // Added line starts with the removed line content (colon-append, extension)
2755        a.starts_with(r)
2756            // Or the lines share a significant common prefix (>60% of the shorter line)
2757            || {
2758                let min_len = a.len().min(r.len());
2759                if min_len < 3 {
2760                    return false;
2761                }
2762                let common = a
2763                    .chars()
2764                    .zip(r.chars())
2765                    .take_while(|(a, b)| a == b)
2766                    .count();
2767                common > min_len * 6 / 10
2768            }
2769    })
2770}
2771
2772/// Truncate a string for use in reason messages.
2773fn truncate_for_reason(s: &str) -> &str {
2774    if s.len() <= 80 { s } else { &s[..80] }
2775}
2776
2777#[cfg(test)]
2778mod tests {
2779    use super::*;
2780
2781    #[test]
2782    fn diff_format_additions() {
2783        use similar::{ChangeTag, TextDiff};
2784        let previous = "line1\n";
2785        let current = "line1\nline2\n";
2786        let diff = TextDiff::from_lines(previous, current);
2787        let has_insert = diff
2788            .iter_all_changes()
2789            .any(|c| c.tag() == ChangeTag::Insert);
2790        assert!(has_insert);
2791    }
2792
2793    #[test]
2794    fn diff_format_deletions() {
2795        use similar::{ChangeTag, TextDiff};
2796        let previous = "line1\nline2\n";
2797        let current = "line1\n";
2798        let diff = TextDiff::from_lines(previous, current);
2799        let has_delete = diff
2800            .iter_all_changes()
2801            .any(|c| c.tag() == ChangeTag::Delete);
2802        assert!(has_delete);
2803    }
2804
2805    #[test]
2806    fn diff_format_unchanged() {
2807        use similar::{ChangeTag, TextDiff};
2808        let content = "line1\nline2\n";
2809        let diff = TextDiff::from_lines(content, content);
2810        let all_equal = diff.iter_all_changes().all(|c| c.tag() == ChangeTag::Equal);
2811        assert!(all_equal);
2812    }
2813
2814    #[test]
2815    fn diff_format_mixed() {
2816        use similar::{ChangeTag, TextDiff};
2817        let previous = "line1\nline2\nline3\n";
2818        let current = "line1\nchanged\nline3\n";
2819        let diff = TextDiff::from_lines(previous, current);
2820
2821        let mut output = String::new();
2822        for change in diff.iter_all_changes() {
2823            let prefix = match change.tag() {
2824                ChangeTag::Delete => "-",
2825                ChangeTag::Insert => "+",
2826                ChangeTag::Equal => " ",
2827            };
2828            output.push_str(prefix);
2829            output.push_str(change.value());
2830        }
2831        assert!(output.contains(" line1\n"));
2832        assert!(output.contains("-line2\n"));
2833        assert!(output.contains("+changed\n"));
2834        assert!(output.contains(" line3\n"));
2835    }
2836
2837    // --- Comment stripping tests ---
2838
2839    #[test]
2840    fn strip_html_comment() {
2841        let input = "before\n<!-- a comment -->\nafter\n";
2842        assert_eq!(strip_comments(input), "before\nafter\n");
2843    }
2844
2845    #[test]
2846    fn strip_multiline_html_comment() {
2847        let input = "before\n<!--\nmulti\nline\n-->\nafter\n";
2848        assert_eq!(strip_comments(input), "before\nafter\n");
2849    }
2850
2851    #[test]
2852    fn strip_link_ref_comment() {
2853        let input = "before\n[//]: # (a comment)\nafter\n";
2854        assert_eq!(strip_comments(input), "before\nafter\n");
2855    }
2856
2857    #[test]
2858    fn preserve_agent_markers() {
2859        let input = "<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
2860        assert_eq!(strip_comments(input), input);
2861    }
2862
2863    #[test]
2864    fn strip_regular_keep_agent_marker() {
2865        let input = "<!-- regular comment -->\n<!-- agent:s -->\ndata\n<!-- /agent:s -->\n";
2866        assert_eq!(
2867            strip_comments(input),
2868            "<!-- agent:s -->\ndata\n<!-- /agent:s -->\n"
2869        );
2870    }
2871
2872    #[test]
2873    fn strip_inline_comment() {
2874        // Comment not on its own line — strip just the comment text
2875        let input = "text <!-- note --> more\n";
2876        let result = strip_comments(input);
2877        assert_eq!(result, "text  more\n");
2878    }
2879
2880    #[test]
2881    fn no_comments_unchanged() {
2882        let input = "# Title\n\nJust text.\n";
2883        assert_eq!(strip_comments(input), input);
2884    }
2885
2886    #[test]
2887    fn empty_document() {
2888        assert_eq!(strip_comments(""), "");
2889    }
2890
2891    // --- Stale snapshot detection tests ---
2892
2893    #[test]
2894    fn stale_snapshot_detects_completed_exchange() {
2895        let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\n";
2896        let document = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\nWhat's up\n\n## Assistant\n\nNot much\n\n## User\n\n";
2897        assert!(is_stale_snapshot(snapshot, document));
2898    }
2899
2900    #[test]
2901    fn stale_snapshot_false_when_user_has_new_content() {
2902        let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\n";
2903        let document =
2904            "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\nNew question here\n";
2905        assert!(!is_stale_snapshot(snapshot, document));
2906    }
2907
2908    #[test]
2909    fn stale_snapshot_false_when_identical() {
2910        let content = "## User\n\nHello\n\n## Assistant\n\nHi\n\n## User\n\n";
2911        assert!(!is_stale_snapshot(content, content));
2912    }
2913
2914    #[test]
2915    fn stale_snapshot_false_when_no_assistant_block() {
2916        let snapshot = "## User\n\nHello\n\n";
2917        let document = "## User\n\nHello\n\nSome random text\n\n## User\n\n";
2918        assert!(!is_stale_snapshot(snapshot, document));
2919    }
2920
2921    #[test]
2922    fn stale_snapshot_multiple_exchanges_stale() {
2923        let snapshot = "## User\n\nQ1\n\n## Assistant\n\nA1\n\n## User\n\n";
2924        let document = "## User\n\nQ1\n\n## Assistant\n\nA1\n\n## User\n\nQ2\n\n## Assistant\n\nA2\n\n## User\n\nQ3\n\n## Assistant\n\nA3\n\n## User\n\n";
2925        assert!(is_stale_snapshot(snapshot, document));
2926    }
2927
2928    #[test]
2929    fn stale_snapshot_with_inline_annotation_not_stale() {
2930        let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\n";
2931        // User added inline annotation within an existing assistant block
2932        let document =
2933            "## User\n\nHello\n\n## Assistant\n\nHi there\n\nPlease elaborate\n\n## User\n\n";
2934        // This modifies the snapshot prefix, so starts_with check fails
2935        assert!(!is_stale_snapshot(snapshot, document));
2936    }
2937
2938    #[test]
2939    fn stale_snapshot_ignores_comments_in_detection() {
2940        let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi\n\n## User\n\n";
2941        let document = "## User\n\nHello\n\n## Assistant\n\nHi\n\n## User\n\n<!-- scratch -->\n\n## Assistant\n\nResponse\n\n## User\n\n";
2942        // Comments are stripped, so the user block between snapshot and new assistant is empty
2943        assert!(is_stale_snapshot(snapshot, document));
2944    }
2945
2946    #[test]
2947    fn copy_on_read_guard_skips_recovery_when_snapshot_modified() {
2948        // Verifies the copy-on-read guard logic: if snapshot mtime changes
2949        // between read and recovery, the save must be skipped.
2950        use std::time::SystemTime;
2951
2952        let t1 = Some(SystemTime::UNIX_EPOCH);
2953        let t2 = Some(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1));
2954
2955        // Same mtime → recovery should proceed (guard passes)
2956        assert_eq!(t1, t1, "same mtime should allow recovery");
2957
2958        // Different mtime → recovery should be skipped (guard blocks)
2959        assert_ne!(t1, t2, "different mtime should block recovery");
2960
2961        // Both None (no snapshot file) → recovery should proceed
2962        let none: Option<SystemTime> = None;
2963        assert_eq!(none, none, "both None should allow recovery");
2964    }
2965
2966    // --- Code-aware comment stripping tests ---
2967
2968    #[test]
2969    fn strip_preserves_comment_syntax_in_inline_backticks() {
2970        // `<!--` inside backticks should NOT be treated as a comment start
2971        let input =
2972            "Use `<!--` to start a comment.\n<!-- agent:foo -->\ncontent\n<!-- /agent:foo -->\n";
2973        let result = strip_comments(input);
2974        assert_eq!(
2975            result,
2976            "Use `<!--` to start a comment.\n<!-- agent:foo -->\ncontent\n<!-- /agent:foo -->\n"
2977        );
2978    }
2979
2980    #[test]
2981    fn strip_preserves_comment_syntax_in_fenced_code_block() {
2982        let input = "before\n```\n<!-- not a comment -->\n```\nafter\n";
2983        let result = strip_comments(input);
2984        assert_eq!(result, input);
2985    }
2986
2987    #[test]
2988    fn strip_backtick_comment_before_agent_marker() {
2989        // Regression: `<!--` in backticks matched `-->` in the agent marker,
2990        // swallowing all content between them
2991        let input = "\
2992Text mentions `<!--` as a trigger.\n\
2993More text here.\n\
2994New user content.\n\
2995<!-- /agent:exchange -->\n";
2996        let result = strip_comments(input);
2997        assert_eq!(result, input);
2998    }
2999
3000    #[test]
3001    fn strip_multiple_backtick_comments_in_exchange() {
3002        // Real-world scenario: discussion about `<!--` syntax inside an exchange component
3003        let snapshot = "\
3004<!-- agent:exchange -->\n\
3005Discussion about `<!--` triggers.\n\
3006- `<!-- agent:NAME -->` paired markers\n\
3007<!-- /agent:exchange -->\n";
3008        let current = "\
3009<!-- agent:exchange -->\n\
3010Discussion about `<!--` triggers.\n\
3011- `<!-- agent:NAME -->` paired markers\n\
3012\n\
3013Please fix the bug.\n\
3014<!-- /agent:exchange -->\n";
3015
3016        let snap_stripped = strip_comments(snapshot);
3017        let curr_stripped = strip_comments(current);
3018        assert_ne!(
3019            snap_stripped, curr_stripped,
3020            "inline edits after backtick-comment text must be detected"
3021        );
3022    }
3023
3024    // --- classify_diff tests ---
3025
3026    fn make_diff(added: &[&str], removed: &[&str]) -> String {
3027        let mut lines = vec!["--- snapshot", "+++ document", "@@ -1,5 +1,5 @@"];
3028        for r in removed {
3029            lines.push(r);
3030        }
3031        lines.push(" context line");
3032        for a in added {
3033            lines.push(a);
3034        }
3035        lines.join("\n")
3036    }
3037
3038    #[test]
3039    fn classify_approval() {
3040        let diff = make_diff(&["+go"], &[]);
3041        let c = classify_diff(&diff);
3042        assert_eq!(c.diff_type, DiffType::Approval);
3043        assert!(c.diff_type_reason.contains("go"));
3044    }
3045
3046    #[test]
3047    fn classify_approval_case_insensitive() {
3048        let diff = make_diff(&["+Yes"], &[]);
3049        let c = classify_diff(&diff);
3050        assert_eq!(c.diff_type, DiffType::Approval);
3051    }
3052
3053    #[test]
3054    fn classify_simple_question() {
3055        let diff = make_diff(&["+what is the release process?"], &[]);
3056        let c = classify_diff(&diff);
3057        assert_eq!(c.diff_type, DiffType::SimpleQuestion);
3058    }
3059
3060    #[test]
3061    fn classify_boundary_artifact() {
3062        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n-### Re: Something (HEAD)\n+### Re: Something\n";
3063        let c = classify_diff(diff);
3064        assert_eq!(c.diff_type, DiffType::BoundaryArtifact);
3065    }
3066
3067    #[test]
3068    fn classify_boundary_uuid() {
3069        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n-<!-- agent:boundary:abc123 -->\n+<!-- agent:boundary:def456 -->\n";
3070        let c = classify_diff(diff);
3071        assert_eq!(c.diff_type, DiffType::BoundaryArtifact);
3072    }
3073
3074    #[test]
3075    fn classify_head_mention_in_user_prose_as_content_addition() {
3076        let diff = make_diff(
3077            &[
3078                "+`❯ ` prompt prefix is being stripped away by the uncommitted user affordance that adds the ` (HEAD)` suffix. spec-test-build-install-commit-push",
3079            ],
3080            &[],
3081        );
3082        let c = classify_diff(&diff);
3083        assert_eq!(c.diff_type, DiffType::ContentAddition);
3084    }
3085
3086    #[test]
3087    fn classify_structural_change() {
3088        let diff = "--- snapshot\n+++ document\n@@ -1,5 +1,3 @@\n context\n-removed line one\n-removed line two\n context\n";
3089        let c = classify_diff(diff);
3090        assert_eq!(c.diff_type, DiffType::StructuralChange);
3091    }
3092
3093    #[test]
3094    fn classify_multi_topic() {
3095        // Two added blocks separated by context
3096        let diff = "--- snapshot\n+++ document\n@@ -1,5 +1,7 @@\n context\n+first topic\n context middle\n+second topic\n context end\n";
3097        let c = classify_diff(diff);
3098        assert_eq!(c.diff_type, DiffType::MultiTopic);
3099    }
3100
3101    #[test]
3102    fn classify_multi_topic_with_separator() {
3103        // Contiguous added block with --- separator — still detected as multi-topic
3104        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,6 @@\n context\n+question one?\n+---\n+do something else\n context end\n";
3105        let c = classify_diff(diff);
3106        assert_eq!(c.diff_type, DiffType::MultiTopic);
3107    }
3108
3109    #[test]
3110    fn classify_content_addition() {
3111        let diff = make_diff(&["+implement the feature using Rust"], &[]);
3112        let c = classify_diff(&diff);
3113        assert_eq!(c.diff_type, DiffType::ContentAddition);
3114    }
3115
3116    #[test]
3117    fn classify_annotation() {
3118        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n context\n-The fix is deployed\n+The fix is deployed: confirmed working in prod\n context\n";
3119        let c = classify_diff(diff);
3120        assert_eq!(c.diff_type, DiffType::Annotation);
3121    }
3122
3123    #[test]
3124    fn classify_approval_in_sentence_is_content() {
3125        // "go" inside a longer sentence should NOT be classified as Approval
3126        let diff = make_diff(&["+let's go ahead and implement the feature"], &[]);
3127        let c = classify_diff(&diff);
3128        assert_eq!(c.diff_type, DiffType::ContentAddition);
3129    }
3130
3131    #[test]
3132    fn classify_single_separator_not_multi_topic() {
3133        // Single --- with no content on either side is not multi-topic
3134        let diff = make_diff(&["+---"], &[]);
3135        let c = classify_diff(&diff);
3136        // Only 1 section (the --- itself is filtered as empty), not multi-topic
3137        assert_ne!(c.diff_type, DiffType::MultiTopic);
3138    }
3139
3140    #[test]
3141    fn classify_question_mark_in_multiline_is_content() {
3142        // Question mark at end of a multi-line addition is content, not simple question
3143        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,5 @@\n context\n+first line of paragraph\n+is this a question?\n context\n";
3144        let c = classify_diff(diff);
3145        // Two added lines = multi-topic (two blocks is actually one contiguous block)
3146        // The key point: it should NOT be SimpleQuestion (that requires exactly 1 added line)
3147        assert_ne!(c.diff_type, DiffType::SimpleQuestion);
3148    }
3149
3150    // --- annotate_diff tests ---
3151
3152    #[test]
3153    fn annotate_diff_additions() {
3154        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n context line\n+new user line\n more context\n";
3155        let annotated = annotate_diff(diff).unwrap();
3156        assert!(annotated.contains("[user+] new user line"));
3157        assert!(annotated.contains("[agent] context line"));
3158    }
3159
3160    #[test]
3161    fn annotate_diff_removals() {
3162        let diff =
3163            "--- snapshot\n+++ document\n@@ -1,3 +1,2 @@\n context\n-removed line\n context\n";
3164        let annotated = annotate_diff(diff).unwrap();
3165        assert!(annotated.contains("[user-] removed line"));
3166    }
3167
3168    #[test]
3169    fn annotate_diff_modifications() {
3170        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n context\n-The fix is deployed\n+The fix is deployed: confirmed in prod\n context\n";
3171        let annotated = annotate_diff(diff).unwrap();
3172        assert!(annotated.contains("[user~] The fix is deployed: confirmed in prod"));
3173        // Should NOT have separate [user-] and [user+] for the paired lines
3174        assert!(!annotated.contains("[user-] The fix is deployed"));
3175    }
3176
3177    #[test]
3178    fn annotate_diff_context() {
3179        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n line one\n line two\n+added\n line three\n";
3180        let annotated = annotate_diff(diff).unwrap();
3181        assert!(annotated.contains("[agent] line one"));
3182        assert!(annotated.contains("[agent] line two"));
3183        assert!(annotated.contains("[agent] line three"));
3184    }
3185
3186    #[test]
3187    fn annotate_diff_empty() {
3188        let diff = "--- snapshot\n+++ document\n";
3189        assert!(annotate_diff(diff).is_none());
3190    }
3191
3192    // --- extract_inline_annotations tests ---
3193
3194    #[test]
3195    fn inline_annotations_user_addition_between_agent_lines() {
3196        let annotated = "[agent] previous agent line\n\
3197                         [user+] This is wrong, fix it\n\
3198                         [agent] more agent content";
3199        let anns = extract_inline_annotations(annotated);
3200        assert_eq!(anns, vec!["This is wrong, fix it"]);
3201    }
3202
3203    #[test]
3204    fn inline_annotations_user_modification_between_agent_lines() {
3205        let annotated = "[agent] context before\n\
3206                         [user~] The corrected line\n\
3207                         [agent] context after";
3208        let anns = extract_inline_annotations(annotated);
3209        assert_eq!(anns, vec!["The corrected line"]);
3210    }
3211
3212    #[test]
3213    fn inline_annotations_user_addition_at_end_is_not_inline() {
3214        let annotated = "[agent] agent content\n\
3215                         [agent] more agent content\n\
3216                         [user+] New user input at end";
3217        let anns = extract_inline_annotations(annotated);
3218        assert!(anns.is_empty());
3219    }
3220
3221    #[test]
3222    fn inline_annotations_component_markers_not_substantive() {
3223        // Component closing + section header after user input should NOT classify as inline
3224        let annotated = "[agent] response prose\n\
3225                         [user+] The fix did not seem to work\n\
3226                         [agent] <!-- /agent:exchange -->\n\
3227                         [agent] \n\
3228                         [agent] ## Pending / Not Built\n\
3229                         [agent] \n\
3230                         [agent] <!-- agent:pending -->";
3231        let anns = extract_inline_annotations(annotated);
3232        assert!(
3233            anns.is_empty(),
3234            "component markers should not make end-of-exchange input inline"
3235        );
3236    }
3237
3238    #[test]
3239    fn inline_annotations_head_boundary_artifact_excluded() {
3240        // [user~] that only appended (HEAD) to a heading is a reposition artifact
3241        let annotated = "[agent] previous content\n\
3242                         [user~] ### Re: topic — sonnet-4-6 (HEAD)\n\
3243                         [agent] response prose\n\
3244                         [agent] more content";
3245        let anns = extract_inline_annotations(annotated);
3246        assert!(
3247            anns.is_empty(),
3248            "(HEAD) boundary reposition should not be an inline annotation"
3249        );
3250    }
3251
3252    #[test]
3253    fn inline_annotations_real_correction_between_prose() {
3254        // Genuine user correction inside agent prose (not a structural marker)
3255        let annotated = "[agent] The score is 5 out of 10.\n\
3256                         [user+] This is wrong. Score should be 8-9.\n\
3257                         [agent] Here is more analysis below.";
3258        let anns = extract_inline_annotations(annotated);
3259        assert_eq!(anns, vec!["This is wrong. Score should be 8-9."]);
3260    }
3261
3262    #[test]
3263    fn inline_annotations_multiple() {
3264        let annotated = "[agent] ### Re: topic\n\
3265                         [user+] First correction\n\
3266                         [agent] agent paragraph\n\
3267                         [user+] Second correction\n\
3268                         [agent] agent closing\n\
3269                         [user+] New input at end";
3270        let anns = extract_inline_annotations(annotated);
3271        assert_eq!(anns, vec!["First correction", "Second correction"]);
3272    }
3273
3274    #[test]
3275    fn inline_annotations_skips_blank_user_lines() {
3276        let annotated = "[agent] agent line\n\
3277                         [user+] \n\
3278                         [agent] more agent";
3279        let anns = extract_inline_annotations(annotated);
3280        assert!(anns.is_empty());
3281    }
3282
3283    #[test]
3284    fn inline_annotations_empty_annotated_diff() {
3285        let anns = extract_inline_annotations("");
3286        assert!(anns.is_empty());
3287    }
3288
3289    #[test]
3290    fn inline_annotations_claudescore_table_scenario() {
3291        // Reproduces the claudescore bug: user corrections inside agent response
3292        // with table rows as agent content after them
3293        let annotated = "[agent] | Category | Score |\n\
3294                         [agent] |----------|-------|\n\
3295                         [agent] | Quality  | 7     |\n\
3296                         [user+] This is wrong. Do not lower expert scores.\n\
3297                         [agent] | Speed    | 8     |\n\
3298                         [user+] We may need to broaden the gate.\n\
3299                         [agent] | Total    | 7.5   |";
3300        let anns = extract_inline_annotations(annotated);
3301        assert_eq!(
3302            anns,
3303            vec![
3304                "This is wrong. Do not lower expert scores.",
3305                "We may need to broaden the gate.",
3306            ]
3307        );
3308    }
3309
3310    #[test]
3311    fn classify_prompt_bearing_changes_promotes_inline_prompt_to_prompt_target() {
3312        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
3313            The prior explanation was incomplete\n\
3314            +Why was the `❯` prefix omitted here?\n\
3315            The rest of the response stays the same\n";
3316        let changes = classify_prompt_bearing_changes(diff);
3317        assert_eq!(changes.len(), 1);
3318        assert_eq!(changes[0].kind, PromptBearingChangeKind::PromptTarget);
3319        assert_eq!(changes[0].text, "Why was the `❯` prefix omitted here?");
3320    }
3321
3322    #[test]
3323    fn classify_prompt_bearing_changes_promotes_plain_exchange_tail_to_prompt_target() {
3324        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
3325Done.\n\
3326+When I run `Run Agent Doc` on this document...nothing happens. Please diagnose the root cause failure and fix the root cause. spec-test-build-install-commit-push\n\
3327<!-- /agent:exchange -->\n";
3328        let changes = classify_prompt_bearing_changes(diff);
3329        assert_eq!(changes.len(), 1);
3330        assert_eq!(changes[0].kind, PromptBearingChangeKind::PromptTarget);
3331        assert_eq!(
3332            changes[0].text,
3333            "When I run `Run Agent Doc` on this document...nothing happens. Please diagnose the root cause failure and fix the root cause. spec-test-build-install-commit-push"
3334        );
3335    }
3336
3337    #[test]
3338    fn classify_prompt_bearing_changes_promotes_bare_slash_command_to_prompt_target() {
3339        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
3340### Re: older — gpt-5\n\
3341+/clear\n\
3342<!-- /agent:exchange -->\n";
3343        let changes = classify_prompt_bearing_changes(diff);
3344        assert_eq!(changes.len(), 1);
3345        assert_eq!(changes[0].kind, PromptBearingChangeKind::PromptTarget);
3346        assert_eq!(changes[0].text, "/clear");
3347        assert!(line_looks_like_fresh_prompt_after_response("/clear"));
3348        assert!(!text_line_looks_like_prompt_target(
3349            "/home/brian/work/foo.md"
3350        ));
3351    }
3352
3353    #[test]
3354    fn prefixed_markdown_response_labels_are_not_prompt_targets() {
3355        for line in [
3356            "❯ **Verification:** Both redirects confirmed via `curl`.",
3357            "❯ Commit / push:",
3358            "❯ **Commit / push:**",
3359            "❯ - **Verification:** `cargo test` passed.",
3360            "❯ 1. **What changed:** normalized response labels.",
3361        ] {
3362            assert!(
3363                !text_line_looks_like_prompt_target(line),
3364                "assistant response label must not be classified as a prompt target: {line}"
3365            );
3366            assert!(
3367                line_looks_like_plain_response_after_prompt(line),
3368                "assistant response label must remain response prose: {line}"
3369            );
3370        }
3371    }
3372
3373    #[test]
3374    fn prefixed_user_followup_after_response_still_starts_prompt() {
3375        for line in [
3376            "❯ verify deploy status",
3377            "❯ Verification failed; what next?",
3378            "❯ do [#respfx]. spec-test-build-install-commit-push",
3379        ] {
3380            assert!(
3381                text_line_looks_like_prompt_target(line),
3382                "real user follow-up must stay prompt-bearing: {line}"
3383            );
3384            assert!(
3385                line_looks_like_fresh_prompt_after_response(line),
3386                "real user follow-up must start a new prompt run: {line}"
3387            );
3388        }
3389    }
3390
3391    #[test]
3392    fn classify_prompt_bearing_changes_ignores_prefixed_markdown_response_label() {
3393        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
3394            ### Re: done — gpt-5\n\
3395            +❯ **Verification:** Both redirects confirmed via `curl`.\n\
3396            <!-- /agent:exchange -->\n";
3397        let changes = classify_prompt_bearing_changes(diff);
3398        assert!(
3399            changes.is_empty(),
3400            "prefixed assistant response label must not reopen a cycle: {changes:?}"
3401        );
3402    }
3403
3404    #[test]
3405    fn classify_prompt_bearing_changes_ignores_prefixed_recovery_evidence() {
3406        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,11 @@\n\
3407            ctx\n\
3408            +### Re: stalled Run Agent Doc recovery — gpt-5\n\
3409            +\n\
3410            +Recovered the stranded closeout first.\n\
3411            +\n\
3412            +❯ - `./gradlew test --tests 'TerminalUtilTest.protected prompt input route refusal is actionable not persistent failure'`\n\
3413            +❯ - `cargo test -p agent-doc-orchestration protected_prompt_draft_preview_redacts_and_bounds_latest_draft`\n\
3414            +❯ - `612b1552` `test: cover protected prompt route refusal`\n\
3415            +\n\
3416            +No code files changed in this follow-up.\n\
3417            <!-- /agent:exchange -->\n";
3418        let changes = classify_prompt_bearing_changes(diff);
3419
3420        assert_eq!(
3421            changes.len(),
3422            1,
3423            "recovery evidence must stay grouped: {changes:?}"
3424        );
3425        assert_eq!(changes[0].kind, PromptBearingChangeKind::RecoveryArtifact);
3426        assert!(
3427            !changes
3428                .iter()
3429                .any(|change| change.kind == PromptBearingChangeKind::PromptTarget),
3430            "prompt-prefixed recovery evidence must not become prompt targets: {changes:?}"
3431        );
3432    }
3433
3434    #[test]
3435    fn classify_prompt_bearing_changes_marks_inline_correction_as_content_edit() {
3436        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
3437            The service returned 401 from this endpoint\n\
3438            +The service returned 503 from this endpoint\n\
3439            The rest of the response stays the same\n";
3440        let changes = classify_prompt_bearing_changes(diff);
3441        assert_eq!(changes.len(), 1);
3442        assert_eq!(changes[0].kind, PromptBearingChangeKind::ContentEdit);
3443        assert_eq!(
3444            changes[0].text,
3445            "The service returned 503 from this endpoint"
3446        );
3447    }
3448
3449    #[test]
3450    fn classify_prompt_bearing_changes_marks_response_heading_as_recovery_artifact() {
3451        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,5 @@\n\
3452            ctx\n\
3453            +### Re: missed patchback — gpt-5\n\
3454            +Patched after the fact.\n\
3455            context end\n";
3456        let changes = classify_prompt_bearing_changes(diff);
3457        assert_eq!(changes.len(), 1);
3458        assert_eq!(changes[0].kind, PromptBearingChangeKind::RecoveryArtifact);
3459        assert_eq!(
3460            changes[0].text,
3461            "### Re: missed patchback — gpt-5\nPatched after the fact."
3462        );
3463    }
3464
3465    // `#ipcproofnostall`: the binary-authored interrupted-cycle IPC-proof
3466    // recovery diagnostic (heading + fenced block) must classify as a
3467    // RecoveryArtifact and never as an unresolved user PromptTarget.
3468    #[test]
3469    fn classify_prompt_bearing_changes_marks_ipc_proof_diagnostic_block_as_recovery_artifact() {
3470        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,12 @@\n\
3471            ctx\n\
3472            +### Re: IPC proof diagnostic (interrupted-cycle recovery) — agent-doc\n\
3473            +\n\
3474            +```text\n\
3475            +**IPC proof issue dogfood log**\n\
3476            +This is binary-authored diagnostic content, not a user prompt, so it does not require a separate response cycle.\n\
3477            +Issue class: `ipc_proof_insufficient`\n\
3478            +ipc_proof_insufficient file=/tmp/session.md source=socket_ack_content patch_id=abc invariant=live_prompt_drift_after_preflight recovery=content_ours_snapshot_next_cycle\n\
3479            +```\n\
3480            <!-- /agent:exchange -->\n";
3481        let changes = classify_prompt_bearing_changes(diff);
3482        assert!(
3483            !changes
3484                .iter()
3485                .any(|change| change.kind == PromptBearingChangeKind::PromptTarget),
3486            "intact IPC-proof diagnostic must not classify as a PromptTarget: {changes:?}"
3487        );
3488    }
3489
3490    // The corruption case: a post-commit worktree corruption separated the
3491    // `ipc_proof_insufficient` event line from its `### Re:` heading and fence,
3492    // leaving the bare structured line at the exchange tail. It must STILL be a
3493    // RecoveryArtifact, not the unresolved PromptTarget that stalls the queue.
3494    #[test]
3495    fn classify_prompt_bearing_changes_marks_separated_ipc_proof_line_as_recovery_artifact() {
3496        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,5 @@\n\
3497            ctx\n\
3498            +ipc_proof_insufficient file=/tmp/session.md source=socket_ack_content patch_id=abc invariant=live_prompt_drift_after_preflight recovery=content_ours_snapshot_next_cycle\n\
3499            <!-- /agent:exchange -->\n";
3500        let changes = classify_prompt_bearing_changes(diff);
3501        assert!(
3502            !changes
3503                .iter()
3504                .any(|change| change.kind == PromptBearingChangeKind::PromptTarget),
3505            "a separated binary-authored ipc_proof_insufficient line must not be a PromptTarget: {changes:?}"
3506        );
3507        assert!(
3508            changes
3509                .iter()
3510                .any(|change| change.kind == PromptBearingChangeKind::RecoveryArtifact),
3511            "the separated diagnostic line must classify as a RecoveryArtifact: {changes:?}"
3512        );
3513    }
3514
3515    // Non-regression: a genuine unresolved user prompt at the exchange tail that
3516    // happens to mention "ipc" and "drift" in prose must STILL be a PromptTarget.
3517    // The exemption is token-specific, not keyword-broad.
3518    #[test]
3519    fn classify_prompt_bearing_changes_keeps_real_prompt_mentioning_ipc_as_prompt_target() {
3520        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,5 @@\n\
3521            ctx\n\
3522            +The IPC drift keeps breaking my finalize — please diagnose the root cause and fix it.\n\
3523            <!-- /agent:exchange -->\n";
3524        let changes = classify_prompt_bearing_changes(diff);
3525        assert!(
3526            changes
3527                .iter()
3528                .any(|change| change.kind == PromptBearingChangeKind::PromptTarget),
3529            "a real user prompt that mentions ipc/drift must remain a PromptTarget: {changes:?}"
3530        );
3531    }
3532
3533    #[test]
3534    fn classify_prompt_bearing_changes_marks_boundary_only_edit_as_boundary_artifact() {
3535        let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,2 @@\n\
3536            -### Re: Something\n\
3537            +### Re: Something (HEAD)\n";
3538        let changes = classify_prompt_bearing_changes(diff);
3539        assert_eq!(changes.len(), 1);
3540        assert_eq!(changes[0].kind, PromptBearingChangeKind::BoundaryArtifact);
3541        assert_eq!(changes[0].text, "### Re: Something (HEAD)");
3542    }
3543
3544    #[test]
3545    fn classify_prompt_bearing_changes_preserves_mixed_triage_order() {
3546        let diff = "--- snapshot\n+++ document\n@@ -1,7 +1,12 @@\n\
3547            The service returned 401 from this endpoint\n\
3548            +The service returned 503 from this endpoint\n\
3549            The rest of the response stays the same\n\
3550            +### Re: delayed patchback — gpt-5\n\
3551            +Patched after the fact.\n\
3552            Context after repair\n\
3553            +<!-- agent:boundary:test-boundary -->\n\
3554            More context\n\
3555            +Why was the `❯` prefix omitted here?\n\
3556            The response continues below\n";
3557        let changes = classify_prompt_bearing_changes(diff);
3558        assert_eq!(changes.len(), 4);
3559        assert_eq!(changes[0].kind, PromptBearingChangeKind::ContentEdit);
3560        assert_eq!(
3561            changes[0].text,
3562            "The service returned 503 from this endpoint"
3563        );
3564        assert_eq!(changes[1].kind, PromptBearingChangeKind::RecoveryArtifact);
3565        assert_eq!(
3566            changes[1].text,
3567            "### Re: delayed patchback — gpt-5\nPatched after the fact."
3568        );
3569        assert_eq!(changes[2].kind, PromptBearingChangeKind::BoundaryArtifact);
3570        assert_eq!(changes[2].text, "<!-- agent:boundary:test-boundary -->");
3571        assert_eq!(changes[3].kind, PromptBearingChangeKind::PromptTarget);
3572        assert_eq!(changes[3].text, "Why was the `❯` prefix omitted here?");
3573    }
3574
3575    #[test]
3576    fn classify_prompt_bearing_changes_ignores_raw_answered_stale_exchange_tail() {
3577        let diff = concat!(
3578            "--- snapshot\n",
3579            "+++ document\n",
3580            "@@ -19,11 +19,11 @@\n",
3581            " ---\n",
3582            " \n",
3583            " ## Status\n",
3584            " \n",
3585            " <!-- agent:status patch=replace -->\n",
3586            "-Blocked on environment: GitHub is unreachable from this sandbox, so I cannot rename `ClaudeScore/BuildPartyInvestorDemo` or add the follow-up submodule here.\n",
3587            "+Updated local references for the renamed `ClaudeScore/buildparty-investor-demo` repo: `.gitmodules` now points at the new SSH URL, and the checked-out submodule's `origin` remote has been synced to match.\n",
3588            " <!-- /agent:status -->\n",
3589            " \n",
3590            " ## Exchange\n",
3591            " \n",
3592            " <!-- agent:exchange patch=append -->\n",
3593            "@@ -60,10 +60,17 @@\n",
3594            " ```bash\n",
3595            " git submodule add git@github.com:ClaudeScore/buildparty-investor-demo.git buildparty-investor-demo\n",
3596            " ```\n",
3597            " \n",
3598            " GitHub will redirect normal clone/fetch/push traffic from the old repo name, but it still recommends updating local remotes, and workflows that use the old repo as a GitHub Action reference will not redirect cleanly. Sources: [GitHub CLI `gh repo rename`](https://cli.github.com/manual/gh_repo_rename), [GitHub repo rename docs](https://docs.github.com/github/administering-a-repository/managing-repository-settings/renaming-a-repository).\n",
3599            "+I renamed the repo to ClaudeScore/buildparty-investor-demo. Please update references\n",
3600            "+I updated the repo-local references to the renamed GitHub repo.\n",
3601            "+\n",
3602            "+- `.gitmodules` now uses `git@github.com:ClaudeScore/buildparty-investor-demo.git`\n",
3603            "+- The checked-out submodule at `buildparty-investor-demo/` now has `origin` set to the same URL\n",
3604            "+\n",
3605            "+The only remaining stale reference I found is the submodule README title (`BuildPartyInvestorDemo` in `buildparty-investor-demo/README.md`). I left that untouched because it belongs to the submodule's own content rather than this parent repo's wiring.\n",
3606            " <!-- /agent:exchange -->\n",
3607            " \n",
3608            " ## Queue\n",
3609            " \n",
3610            " <!-- agent:queue -->\n",
3611        );
3612        let changes = classify_prompt_bearing_changes(diff);
3613        assert_eq!(changes.len(), 1);
3614        assert_eq!(changes[0].kind, PromptBearingChangeKind::ContentEdit);
3615        assert_eq!(
3616            changes[0].text,
3617            "Updated local references for the renamed `ClaudeScore/buildparty-investor-demo` repo: `.gitmodules` now points at the new SSH URL, and the checked-out submodule's `origin` remote has been synced to match."
3618        );
3619    }
3620
3621    #[test]
3622    fn classify_prompt_bearing_changes_ignores_answered_prompt_before_blank_heading_gap() {
3623        let diff = concat!(
3624            "--- snapshot\n",
3625            "+++ document\n",
3626            "@@ -1,3 +1,8 @@\n",
3627            " <!-- agent:exchange patch=append -->\n",
3628            "-<!-- agent:boundary:initial -->\n",
3629            "+❯ do #sim1. spec-test-build-install-commit-push\n",
3630            "+\n",
3631            "+### Re: sim closeout — gpt-5 (HEAD)\n",
3632            "+\n",
3633            "+Done.\n",
3634            "+<!-- agent:boundary:new -->\n",
3635            " <!-- /agent:exchange -->\n",
3636        );
3637
3638        let changes = classify_prompt_bearing_changes(diff);
3639        assert!(
3640            changes
3641                .iter()
3642                .all(|change| change.kind != PromptBearingChangeKind::PromptTarget),
3643            "answered prompt should not remain unresolved: {changes:?}"
3644        );
3645    }
3646
3647    // parse_slash_commands tests
3648
3649    #[test]
3650    fn parse_slash_commands_simple() {
3651        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n context\n+/clear\n";
3652        let cmds = parse_slash_commands(diff);
3653        assert_eq!(cmds, vec!["/clear"]);
3654    }
3655
3656    #[test]
3657    fn parse_slash_command_only_added_diff_accepts_bare_clear() {
3658        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n context\n+/clear\n";
3659        assert_eq!(
3660            parse_slash_command_only_added_diff(diff),
3661            Some(vec!["/clear".to_string()])
3662        );
3663    }
3664
3665    #[test]
3666    fn parse_slash_command_only_added_diff_rejects_mixed_prompt_text() {
3667        let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n context\n+/clear\n+Why was this answered?\n";
3668        assert_eq!(parse_slash_command_only_added_diff(diff), None);
3669    }
3670
3671    #[test]
3672    fn parse_slash_command_only_added_diff_rejects_fenced_or_blockquoted_commands() {
3673        let fenced = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n+```\n+/clear\n+```\n";
3674        assert_eq!(parse_slash_command_only_added_diff(fenced), None);
3675
3676        let quoted = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+> /clear\n";
3677        assert_eq!(parse_slash_command_only_added_diff(quoted), None);
3678    }
3679
3680    #[test]
3681    fn parse_slash_commands_trims_surrounding_whitespace() {
3682        let diff = "--- snapshot\n+++ queue\n@@ -0,0 +1,1 @@\n+   /clear  \n";
3683        let cmds = parse_slash_commands(diff);
3684        assert_eq!(cmds, vec!["/clear"]);
3685    }
3686
3687    #[test]
3688    fn parse_slash_commands_with_args() {
3689        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+/agent-doc foo.md\n";
3690        let cmds = parse_slash_commands(diff);
3691        assert_eq!(cmds, vec!["/agent-doc foo.md"]);
3692    }
3693
3694    #[test]
3695    fn parse_slash_commands_ignores_fenced() {
3696        let diff = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n+```\n+/clear\n+```\n";
3697        let cmds = parse_slash_commands(diff);
3698        assert!(cmds.is_empty());
3699    }
3700
3701    #[test]
3702    fn parse_slash_commands_ignores_blockquote() {
3703        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+> /clear\n";
3704        let cmds = parse_slash_commands(diff);
3705        assert!(cmds.is_empty());
3706    }
3707
3708    #[test]
3709    fn parse_slash_commands_ignores_context_lines() {
3710        let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,2 @@\n /clear\n context\n";
3711        let cmds = parse_slash_commands(diff);
3712        assert!(cmds.is_empty());
3713    }
3714
3715    #[test]
3716    fn parse_slash_commands_ignores_removed_lines() {
3717        let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,1 @@\n-/clear\n context\n";
3718        let cmds = parse_slash_commands(diff);
3719        assert!(cmds.is_empty());
3720    }
3721
3722    #[test]
3723    fn parse_slash_commands_requires_letter_after_slash() {
3724        // "/ " (space after slash) and "//comment" should not match.
3725        let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n+/ foo\n+//comment\n";
3726        let cmds = parse_slash_commands(diff);
3727        assert!(cmds.is_empty());
3728    }
3729
3730    #[test]
3731    fn parse_slash_commands_multiple() {
3732        let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n+/clear\n+/agent-doc foo.md\n";
3733        let cmds = parse_slash_commands(diff);
3734        assert_eq!(cmds, vec!["/clear", "/agent-doc foo.md"]);
3735    }
3736
3737    #[test]
3738    fn parse_slash_commands_rejects_absolute_paths() {
3739        // #xzz5: `/home/brian/...` looks like a command but is a filesystem
3740        // path. The tightened grammar (`/[a-z][a-z0-9:_-]*` with no second `/`)
3741        // must reject any token containing a second slash.
3742        let diff = "--- snapshot\n+++ document\n@@ -1 +1,5 @@\n ctx\n\
3743            +/home/brian/work/foo.md\n\
3744            +/tmp/scratch\n\
3745            +/usr/local/bin/agent-doc\n\
3746            +/var\n";
3747        let cmds = parse_slash_commands(diff);
3748        // "/var" is a bare token with no slash → allowed by grammar (it's a
3749        // valid command name shape). Only the three path-shaped entries are
3750        // rejected. This is the minimum contract: reject second-slash.
3751        assert_eq!(cmds, vec!["/var"]);
3752    }
3753
3754    #[test]
3755    fn parse_slash_commands_rejects_uppercase_and_symbols() {
3756        // Grammar: first char must be [a-z]; rest must be [a-z0-9:_-].
3757        let diff = "--- snapshot\n+++ document\n@@ -1 +1,5 @@\n ctx\n\
3758            +/Clear\n\
3759            +/foo.bar\n\
3760            +/foo!bang\n\
3761            +/foo#hash\n";
3762        let cmds = parse_slash_commands(diff);
3763        assert!(cmds.is_empty(), "all four must be rejected; got: {cmds:?}");
3764    }
3765
3766    #[test]
3767    fn parse_slash_commands_accepts_namespaced_and_hyphenated() {
3768        // Grammar allows `:`, `_`, `-`, digits — namespaced/versioned commands.
3769        let diff = "--- snapshot\n+++ document\n@@ -1 +1,5 @@\n ctx\n\
3770            +/mcp:reload\n\
3771            +/agent-doc file.md\n\
3772            +/some_thing\n\
3773            +/v2\n";
3774        let cmds = parse_slash_commands(diff);
3775        assert_eq!(
3776            cmds,
3777            vec!["/mcp:reload", "/agent-doc file.md", "/some_thing", "/v2"]
3778        );
3779    }
3780
3781    #[test]
3782    fn detect_orchestration_request_for_synchronous_task_list() {
3783        let diff = "--- snapshot\n+++ document\n@@ -1 +1,7 @@\n ctx\n\
3784+Today is 2026-04-25. Synchronous orcestra.\n\
3785+- do #ss01. Add tests. Run benchmarks.\n\
3786+- do #ss02. Add tests. Run benchmarks.\n\
3787+- do #ss03. Add tests. Run benchmarks.\n";
3788        let request = detect_orchestration_request(diff).expect("expected orchestration request");
3789        assert_eq!(request.mode, OrchestrationRequestMode::Sequential);
3790        assert_eq!(request.task_count, 3);
3791        assert_eq!(
3792            request.trigger_text,
3793            "Today is 2026-04-25. Synchronous orcestra."
3794        );
3795    }
3796
3797    #[test]
3798    fn detect_orchestration_request_for_parallel_batch() {
3799        let diff = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n\
3800+Fan out these benchmark tasks.\n\
3801+- do #a1\n\
3802+- do #a2\n";
3803        let request = detect_orchestration_request(diff).expect("expected orchestration request");
3804        assert_eq!(request.mode, OrchestrationRequestMode::Parallel);
3805        assert_eq!(request.task_count, 2);
3806    }
3807
3808    #[test]
3809    fn detect_orchestration_request_requires_batch_shape() {
3810        let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n\
3811+Run these in order.\n\
3812+- do #a1\n";
3813        assert!(
3814            detect_orchestration_request(diff).is_none(),
3815            "single-item lists should stay as ordinary work, not forced orchestration"
3816        );
3817    }
3818
3819    #[test]
3820    fn detect_orchestration_request_for_prefixed_synchronous_opera_batch() {
3821        let diff = "--- snapshot\n+++ document\n@@ -1 +1,6 @@\n ctx\n\
3822+❯ synchronous opera\n\
3823+❯ preset #spec-test-build-install-commit-push\n\
3824+❯ - do #jbpfx1\n\
3825+❯ - do #jbpfx2\n";
3826        let request = detect_orchestration_request(diff).expect("expected orchestration request");
3827        assert_eq!(request.mode, OrchestrationRequestMode::Sequential);
3828        assert_eq!(request.task_count, 2);
3829        assert_eq!(
3830            request.trigger_text,
3831            "❯ synchronous opera ❯ preset #spec-test-build-install-commit-push"
3832        );
3833    }
3834
3835    #[test]
3836    fn detect_orchestration_request_ignores_prefixed_recovery_evidence() {
3837        let diff = "--- snapshot\n+++ document\n@@ -1 +1,10 @@\n ctx\n\
3838+### Re: stalled Run Agent Doc recovery — gpt-5\n\
3839+\n\
3840+I dry-ran the emitted orchestration command. It resolved only already-recorded verification evidence.\n\
3841+\n\
3842+❯ - `./gradlew test --tests TerminalUtilTest`\n\
3843+❯ - `cargo test -p agent-doc-orchestration protected_prompt_draft_preview_redacts_and_bounds_latest_draft`\n\
3844+❯ - `612b1552` `test: cover protected prompt route refusal`\n\
3845+\n\
3846+No code files changed in this follow-up.\n";
3847        assert!(
3848            detect_orchestration_request(diff).is_none(),
3849            "recovered response evidence must not emit orchestration work"
3850        );
3851    }
3852
3853    #[test]
3854    fn detect_queue_trigger_do_queue() {
3855        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+do queue\n";
3856        assert!(detect_queue_trigger(diff));
3857    }
3858
3859    #[test]
3860    fn detect_queue_trigger_run_queue() {
3861        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+run queue\n";
3862        assert!(detect_queue_trigger(diff));
3863    }
3864
3865    #[test]
3866    fn detect_queue_trigger_case_insensitive() {
3867        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+Do Queue\n";
3868        assert!(detect_queue_trigger(diff));
3869    }
3870
3871    #[test]
3872    fn detect_queue_trigger_with_prompt_prefix() {
3873        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+❯ do queue\n";
3874        assert!(detect_queue_trigger(diff));
3875    }
3876
3877    #[test]
3878    fn detect_queue_trigger_not_pending_ref() {
3879        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+do #queue Phase 2\n";
3880        assert!(!detect_queue_trigger(diff));
3881    }
3882
3883    #[test]
3884    fn detect_queue_trigger_not_in_code_fence() {
3885        let diff = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n+```\n+do queue\n+```\n";
3886        assert!(!detect_queue_trigger(diff));
3887    }
3888
3889    #[test]
3890    fn detect_queue_trigger_not_in_blockquote() {
3891        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+> do queue\n";
3892        assert!(!detect_queue_trigger(diff));
3893    }
3894
3895    #[test]
3896    fn detect_queue_trigger_with_trailing_punct() {
3897        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+do queue.\n";
3898        assert!(detect_queue_trigger(diff));
3899    }
3900
3901    #[test]
3902    fn detect_queue_trigger_not_on_context_line() {
3903        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n do queue\n+other\n";
3904        assert!(!detect_queue_trigger(diff));
3905    }
3906
3907    #[test]
3908    fn suppress_inactive_queue_additions_removes_queue_prompt_lines() {
3909        let current = concat!(
3910            "---\nqueue_active: false\n---\n\n",
3911            "<!-- agent:exchange -->\n",
3912            "### Re: prior — gpt-5\n",
3913            "Done.\n",
3914            "<!-- /agent:exchange -->\n\n",
3915            "<!-- agent:queue -->\n",
3916            "dispatch #spec-test-build-install-commit-push\n",
3917            "- do [#gdbpropscan]\n",
3918            "<!-- /agent:queue -->\n"
3919        );
3920        let diff = concat!(
3921            "--- snapshot\n",
3922            "+++ document\n",
3923            "@@ -7,5 +7,7 @@\n",
3924            " Done.\n",
3925            " <!-- /agent:exchange -->\n",
3926            " \n",
3927            " <!-- agent:queue -->\n",
3928            "+dispatch #spec-test-build-install-commit-push\n",
3929            "+- do [#gdbpropscan]\n",
3930            " <!-- /agent:queue -->\n",
3931        );
3932
3933        let filtered = suppress_inactive_queue_additions(diff, current);
3934
3935        assert!(!filtered.contains("[#gdbpropscan]"));
3936        assert!(!filtered.contains("dispatch #spec-test-build-install-commit-push"));
3937        assert!(classify_prompt_bearing_changes(&filtered).is_empty());
3938        assert!(extract_imperative_directives(&filtered).is_empty());
3939    }
3940
3941    #[test]
3942    fn detect_prompt_preset_requests_from_diff() {
3943        let diff = "--- snapshot\n+++ document\n@@ -1 +1,6 @@\n ctx\n\
3944+preset #1\n\
3945+presets release-check, #2\n\
3946+preset #1\n\
3947+> preset ignored\n\
3948+```md\n\
3949+preset fenced\n";
3950        assert_eq!(
3951            detect_prompt_preset_requests(diff),
3952            vec![
3953                "#1".to_string(),
3954                "release-check".to_string(),
3955                "#2".to_string()
3956            ]
3957        );
3958    }
3959
3960    #[test]
3961    fn extract_prompt_preset_requests_from_text_ignores_fences_and_blockquotes() {
3962        let text = "synchronous orchestra\npreset #1\n> preset quoted\n\n```md\npreset fenced\n```\npresets release-check and #2\n";
3963        assert_eq!(
3964            extract_prompt_preset_requests_from_text(text),
3965            vec![
3966                "#1".to_string(),
3967                "release-check".to_string(),
3968                "#2".to_string()
3969            ]
3970        );
3971    }
3972
3973    #[test]
3974    fn extract_imperative_directives_detects_do_and_build_push() {
3975        let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n\
3976            +do #6zyp. update spec + tests. build + install for local testing. commit + push\n\
3977            +do [#dodone]. spec-test-build-install-commit-push\n\
3978            +do [#plainid]\n\
3979            +run benchmarks\n";
3980        let directives = extract_imperative_directives(diff);
3981        assert_eq!(
3982            directives,
3983            vec![
3984                "do #6zyp. update spec + tests. build + install for local testing. commit + push",
3985                "do [#dodone]. spec-test-build-install-commit-push",
3986                "do [#plainid]",
3987                "run benchmarks",
3988            ]
3989        );
3990        assert!(diff_contains_imperative_directive(diff));
3991    }
3992
3993    #[test]
3994    fn extract_imperative_directives_detects_pending_item_natural_language() {
3995        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n\
3996            +- [ ] [#n8q4] Fix the cross-repo `no-permissions-bypass` miss now dominating benchmark MAE\n";
3997        let directives = extract_imperative_directives(diff);
3998        assert_eq!(
3999            directives,
4000            vec!["Fix the cross-repo `no-permissions-bypass` miss now dominating benchmark MAE"]
4001        );
4002        assert!(diff_contains_imperative_directive(diff));
4003    }
4004
4005    #[test]
4006    fn extract_imperative_directives_detects_long_custom_pending_id() {
4007        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n\
4008            +- [ ] [#sdig2matrix] Fix the custom backlog id normalization path\n";
4009        let directives = extract_imperative_directives(diff);
4010        assert_eq!(
4011            directives,
4012            vec!["Fix the custom backlog id normalization path"]
4013        );
4014        assert!(diff_contains_imperative_directive(diff));
4015    }
4016
4017    #[test]
4018    fn extract_imperative_directives_ignores_blockquotes_and_fences() {
4019        let diff = "--- snapshot\n+++ document\n@@ -1 +1,6 @@\n ctx\n\
4020            +> do #skip\n\
4021            +```\n\
4022            +run tests\n\
4023            +```\n\
4024            +plain note\n";
4025        let directives = extract_imperative_directives(diff);
4026        assert!(
4027            directives.is_empty(),
4028            "unexpected directives: {directives:?}"
4029        );
4030        assert!(!diff_contains_imperative_directive(diff));
4031    }
4032
4033    #[test]
4034    fn diff_contains_imperative_directive_for_approval_word() {
4035        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+go\n";
4036        assert!(diff_contains_imperative_directive(diff));
4037    }
4038
4039    #[test]
4040    fn detect_exchange_compaction_request_matches_bare_directive() {
4041        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+compact exchange\n";
4042        assert!(detect_exchange_compaction_request(diff));
4043    }
4044
4045    #[test]
4046    fn detect_exchange_compaction_request_matches_prompt_prefixed_variant() {
4047        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+❯ compact exchange...do not add...summarize the content and delete the rest\n";
4048        assert!(detect_exchange_compaction_request(diff));
4049    }
4050
4051    #[test]
4052    fn detect_exchange_compaction_request_ignores_non_directive_mentions() {
4053        let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+I failed to compact exchange earlier.\n";
4054        assert!(!detect_exchange_compaction_request(diff));
4055    }
4056
4057    #[test]
4058    fn detect_exchange_compaction_request_ignores_queue_list_item() {
4059        // #qcompactfp: a queued bug-report head that merely begins with "compact
4060        // exchange" is a task entry, not a same-turn directive — it must not abort
4061        // finalize. Bullet spellings `- `, `* `, and ordered `N. ` are all skipped.
4062        for diff in [
4063            "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+- Compact exchange should commit the compacted content\n",
4064            "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+* compact exchange should commit the compacted content\n",
4065            "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+1. compact exchange should commit the compacted content\n",
4066        ] {
4067            assert!(
4068                !detect_exchange_compaction_request(diff),
4069                "list-item compaction mention must not be a directive: {diff:?}"
4070            );
4071        }
4072        // A genuine prose directive (no bullet) still matches.
4073        let prose = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+compact exchange\n";
4074        assert!(detect_exchange_compaction_request(prose));
4075    }
4076
4077    #[test]
4078    fn extract_required_response_blocks_multiple_prompts() {
4079        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,9 @@\n\
4080            ctx\n\
4081            +❯ First question?\n\
4082            +Context line.\n\
4083            +\n\
4084            +❯ Second question?\n\
4085            +do #n8q4. run tests. build + install. commit + push\n";
4086
4087        let blocks = extract_required_response_blocks(diff);
4088        assert_eq!(blocks.len(), 2);
4089        assert_eq!(blocks[0], "❯ First question?\nContext line.");
4090        assert_eq!(
4091            blocks[1],
4092            "❯ Second question?\ndo #n8q4. run tests. build + install. commit + push"
4093        );
4094    }
4095
4096    #[test]
4097    fn extract_required_response_blocks_preserves_code_fence_context() {
4098        let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,7 @@\n\
4099            ctx\n\
4100            +❯ In src/boost-client, why did patchback miss the prefix?\n\
4101            +See my inquiry:\n\
4102            +```text\n\
4103            +line one\n\
4104            +line two\n\
4105            +```\n";
4106
4107        let blocks = extract_required_response_blocks(diff);
4108        assert_eq!(blocks.len(), 1);
4109        assert!(blocks[0].contains("❯ In src/boost-client"));
4110        assert!(blocks[0].contains("```text\nline one\nline two\n```"));
4111    }
4112
4113    #[test]
4114    fn format_required_response_targets_mentions_turn_completeness() {
4115        let diff =
4116            "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n+❯ Why were two prompts left unresolved?\n";
4117        let rendered = format_required_response_targets(diff).unwrap();
4118        assert!(rendered.contains("Do not stop at the newest question"));
4119        assert!(rendered.contains("<target index=\"1\">"));
4120        assert!(rendered.contains("❯ Why were two prompts left unresolved?"));
4121    }
4122
4123    #[test]
4124    fn format_prompt_bearing_changes_mentions_edit_and_artifact_contract() {
4125        let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,6 @@\n\
4126            context before\n\
4127            +❯ Why was this missed?\n\
4128            +\n\
4129            +This line should say 503.\n\
4130            context after\n";
4131        let rendered = format_prompt_bearing_changes(diff).unwrap();
4132        assert!(rendered.contains("User-authored prompt-bearing changes (oldest first):"));
4133        assert!(rendered.contains("kind=\"prompt_target\""));
4134        assert!(rendered.contains("kind=\"content_edit\""));
4135        assert!(rendered.contains("Treat `content_edit` items as user corrections"));
4136    }
4137
4138    #[test]
4139    fn prompt_prefix_normalization_targets_preserve_prompt_context_and_skip_fences() {
4140        let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,7 @@\n\
4141            ctx\n\
4142            +❯ In src/boost-client, why did patchback miss the prefix?\n\
4143            +See my inquiry:\n\
4144            +- keep this markdown bullet bare\n\
4145            +  - keep nested markdown bullets bare\n\
4146            +1. keep ordered markdown bullets bare\n\
4147            +```text\n\
4148            +line one\n\
4149            +line two\n\
4150            +```\n";
4151
4152        let targets = prompt_prefix_normalization_targets(diff);
4153        assert_eq!(
4154            targets,
4155            vec!["See my inquiry:".to_string(),],
4156            "only bare prompt-context prose should need fresh prefixing"
4157        );
4158    }
4159
4160    #[test]
4161    fn first_bare_prompt_prefix_target_detects_unprefixed_prompt_block_line() {
4162        let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,5 @@\n\
4163            ctx\n\
4164            +❯ Existing question?\n\
4165            +Follow-up context.\n\
4166            +### Re: answer — gpt-5\n\
4167            +Body\n";
4168
4169        let bare = first_bare_prompt_prefix_target(diff);
4170        assert_eq!(bare.as_deref(), Some("Follow-up context."));
4171    }
4172
4173    #[test]
4174    fn first_bare_prompt_prefix_target_skips_markdown_lists() {
4175        let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,6 @@\n\
4176            ctx\n\
4177            +❯ Please compare these options:\n\
4178            +- option one\n\
4179            +  - nested option detail\n\
4180            +1. ordered option\n\
4181            +### Re: answer — gpt-5\n";
4182
4183        let bare = first_bare_prompt_prefix_target(diff);
4184        assert_eq!(bare, None);
4185    }
4186
4187    // Plan: tasks/agent-doc/plan-claude-code-queue-auto-loop.md `#ccloopguard`.
4188    // Managed-component state edits (queue/backlog/done body, queue activity
4189    // toggle, frontmatter queue flag) must not block the Claude Code auto-loop.
4190    // Real user prompts must continue to block it.
4191    fn pbc(kind: PromptBearingChangeKind, text: &str) -> PromptBearingChange {
4192        PromptBearingChange {
4193            kind,
4194            text: text.to_string(),
4195        }
4196    }
4197
4198    #[test]
4199    fn change_is_managed_state_only_accepts_queue_activity_toggle() {
4200        assert!(change_is_managed_state_only(&pbc(
4201            PromptBearingChangeKind::ContentEdit,
4202            "<!-- agent:queue auto -->"
4203        )));
4204        assert!(change_is_managed_state_only(&pbc(
4205            PromptBearingChangeKind::ContentEdit,
4206            "<!-- agent:queue -->"
4207        )));
4208    }
4209
4210    #[test]
4211    fn change_is_managed_state_only_accepts_frontmatter_queue_active_flip() {
4212        assert!(change_is_managed_state_only(&pbc(
4213            PromptBearingChangeKind::ContentEdit,
4214            "queue_active: true"
4215        )));
4216        assert!(change_is_managed_state_only(&pbc(
4217            PromptBearingChangeKind::ContentEdit,
4218            "queue_active: false"
4219        )));
4220    }
4221
4222    #[test]
4223    fn pipeline_only_frontmatter_write_is_no_change() {
4224        // #22a8: writing / clearing the managed agent_doc_pipeline block on a
4225        // phase transition must read as no change (diff cancels both sides).
4226        let snapshot = "---\nqueue: start\n---\n\n## Body\n- item\n";
4227        let with_pipeline = "---\nqueue: start\nagent_doc_pipeline:\n  run_id: cycle-123\n  step: response_captured\n  turn_id: \"#x\"\n---\n\n## Body\n- item\n";
4228        assert!(
4229            unified_diff_from_contents(snapshot, with_pipeline).is_none(),
4230            "adding a pipeline block must not register as a change"
4231        );
4232        assert!(
4233            unified_diff_from_contents(with_pipeline, snapshot).is_none(),
4234            "clearing a pipeline block must not register as a change"
4235        );
4236        // A real body edit alongside a pipeline write is still detected.
4237        let with_pipeline_and_edit = "---\nqueue: start\nagent_doc_pipeline:\n  run_id: cycle-123\n  step: committed\n---\n\n## Body\n- item changed\n";
4238        assert!(
4239            unified_diff_from_contents(snapshot, with_pipeline_and_edit).is_some(),
4240            "a real body edit must still be detected through a pipeline write"
4241        );
4242    }
4243
4244    #[test]
4245    fn change_is_managed_state_only_accepts_pipeline_block_lines() {
4246        for line in [
4247            "agent_doc_pipeline:",
4248            "  run_id: cycle-123",
4249            "  step: write_applied",
4250            "  turn_id: \"#x\"",
4251            "  queue_task_id: \"#x\"",
4252        ] {
4253            assert!(
4254                change_is_managed_state_only(&pbc(PromptBearingChangeKind::ContentEdit, line)),
4255                "pipeline line should be managed state: {line:?}"
4256            );
4257        }
4258    }
4259
4260    #[test]
4261    fn change_is_managed_state_only_accepts_queue_item_lines() {
4262        assert!(change_is_managed_state_only(&pbc(
4263            PromptBearingChangeKind::PromptTarget,
4264            "- do [#newitem]"
4265        )));
4266        assert!(change_is_managed_state_only(&pbc(
4267            PromptBearingChangeKind::PromptTarget,
4268            "- ~do [#consumed]~"
4269        )));
4270    }
4271
4272    #[test]
4273    fn change_is_managed_state_only_accepts_backlog_and_done_item_lines() {
4274        assert!(change_is_managed_state_only(&pbc(
4275            PromptBearingChangeKind::ContentEdit,
4276            "- [ ] [#newitem] short description"
4277        )));
4278        assert!(change_is_managed_state_only(&pbc(
4279            PromptBearingChangeKind::ContentEdit,
4280            "- [/] [#gated] partial progress note"
4281        )));
4282        assert!(change_is_managed_state_only(&pbc(
4283            PromptBearingChangeKind::ContentEdit,
4284            "- 2026-05-25 [#done] closed last cycle"
4285        )));
4286    }
4287
4288    #[test]
4289    fn change_is_managed_state_only_accepts_multi_line_managed_blocks() {
4290        assert!(change_is_managed_state_only(&pbc(
4291            PromptBearingChangeKind::PromptTarget,
4292            "- do [#a]\n- do [#b]\n- do [#c]"
4293        )));
4294    }
4295
4296    #[test]
4297    fn change_is_managed_state_only_rejects_real_user_prompts() {
4298        assert!(!change_is_managed_state_only(&pbc(
4299            PromptBearingChangeKind::PromptTarget,
4300            "Why is the queue not auto-looping?"
4301        )));
4302        assert!(!change_is_managed_state_only(&pbc(
4303            PromptBearingChangeKind::PromptTarget,
4304            "❯ do this thing please"
4305        )));
4306        assert!(!change_is_managed_state_only(&pbc(
4307            PromptBearingChangeKind::ContentEdit,
4308            "Fix the regression on line 42."
4309        )));
4310    }
4311
4312    #[test]
4313    fn change_is_managed_state_only_rejects_mixed_managed_and_user_text() {
4314        assert!(!change_is_managed_state_only(&pbc(
4315            PromptBearingChangeKind::PromptTarget,
4316            "- do [#newitem]\nAnd please also fix the older bug."
4317        )));
4318    }
4319
4320    #[test]
4321    fn line_is_binary_authored_compact_summary_recognizes_summary_shapes() {
4322        // Binary-authored compaction summary shapes — with and without a `❯ `
4323        // prefix a prior content-inference repair may have wrongly applied.
4324        for line in [
4325            "### Session Summary",
4326            "❯ ### Session Summary",
4327            "Compacted content:",
4328            "*Compacted. Content archived to `.agent-doc/archives/x.md`*",
4329            "❯ *Compacted. Content archived to `.agent-doc/archives/x.md`*",
4330            "- Archived 6 response topic(s): a; b; c; 3 more",
4331            "❯ - Archived 6 response topic(s): a; b; c; 3 more",
4332            "- Prior summary/context: prior compacted content: ...",
4333            "- Trailing prompt/context: leftover prose",
4334        ] {
4335            assert!(
4336                line_is_binary_authored_compact_summary(line),
4337                "should recognize binary-authored summary line: {line:?}"
4338            );
4339        }
4340        // Genuine user prompts must NOT match, even when they mention compaction.
4341        for line in [
4342            "Why did the compaction archive my queue items?",
4343            "do #provauth3",
4344            "- Archived the old plan, please review",
4345            "Compacted the notes manually — is that ok?",
4346        ] {
4347            assert!(
4348                !line_is_binary_authored_compact_summary(line),
4349                "must not match a real user line: {line:?}"
4350            );
4351        }
4352    }
4353
4354    #[test]
4355    fn compact_summary_replacement_is_not_a_prompt_target() {
4356        // `#provauth3`: replacing the exchange tail with a compaction Session
4357        // Summary must classify as a recovery artifact, never an unresolved user
4358        // PromptTarget (which falsely INTERRUPTed session-check and stalled the
4359        // queue at the start of this dogfood session).
4360        let prev = "<!-- agent:exchange patch=append -->\n\
4361### Re: old topic - gpt-5\n\nA long archived answer body.\n\n\
4362<!-- agent:boundary:old -->\n<!-- /agent:exchange -->\n";
4363        let current = "<!-- agent:exchange patch=append -->\n\
4364### Session Summary\n\n\
4365*Compacted. Content archived to `.agent-doc/archives/x.md`*\n\n\
4366Compacted content:\n\
4367- Archived 6 response topic(s): a; b; c; 3 more\n\
4368- Prior summary/context: earlier work\n\
4369<!-- agent:boundary:new -->\n<!-- /agent:exchange -->\n";
4370        let diff = unified_diff_from_contents(prev, current).expect("diff");
4371        let changes = classify_prompt_bearing_changes(&diff);
4372        assert!(
4373            changes
4374                .iter()
4375                .all(|c| c.kind != PromptBearingChangeKind::PromptTarget),
4376            "compaction summary lines must not be PromptTargets: {changes:?}"
4377        );
4378    }
4379}