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