Skip to main content

agent_doc_core/
diff.rs

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