Skip to main content

agent_doc_core/
pending.rs

1//! # Module: pending
2//!
3//! Pure functions for parsing and mutating the `agent:pending` component body.
4//!
5//! Each pending item carries:
6//! - a GFM task-list checkbox (`- [ ]` / `1. [ ]` or `- [x]` / `1. [x]`)
7//! - an id prefix rendered as `[#xxxx]` (generated hash or caller-provided custom id)
8//! - free-form text
9//!
10//! Canonical unordered form: `- [ ] [#a3f2] refactor preflight commit path`
11//! Canonical ordered form: `1. [ ] [#a3f2] refactor preflight commit path`
12//!
13//! This module is I/O-free. Callers (`pending_cmd.rs`, `preflight.rs`, `write.rs`)
14//! handle reading/writing files, locking, and git commits.
15//!
16//! ## Spec
17//! - Parser accepts legacy forms and normalizes via `backfill`.
18//! - IDs are stable across edits/reorders; generated once (or supplied on insert), preserved thereafter.
19//! - `reap` removes `- [x]` items. `detect_reorder` diffs id order between snapshot/current.
20//! - Mutation ops (`op_add/done/edit/clear/reorder`) return a new body string, never mutate in place.
21
22use anyhow::{Context, Result, anyhow, bail};
23use std::collections::HashSet;
24
25/// Lifecycle state for a pending item, encoded by its GFM checkbox.
26///
27/// - `Open` (`[ ]`) — active or not started; default for new items.
28/// - `Gated` (`[/]`) — code-complete, awaiting an external gate (release,
29///   telemetry, field validation). Never auto-reaped.
30/// - `Done` (`[x]`) — fully complete; reaped on the next preflight cycle.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum PendingState {
33    Open,
34    Gated,
35    Done,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum PendingListMarker {
40    Bullet,
41    Ordered(usize),
42}
43
44impl PendingListMarker {
45    fn render_prefix(self, ordered_index: Option<usize>) -> String {
46        match ordered_index {
47            Some(index) => format!("{index}."),
48            None => match self {
49                PendingListMarker::Bullet => "-".to_string(),
50                PendingListMarker::Ordered(index) => format!("{index}."),
51            },
52        }
53    }
54
55    fn is_ordered(self) -> bool {
56        matches!(self, PendingListMarker::Ordered(_))
57    }
58}
59
60impl PendingState {
61    /// Single character for the GFM checkbox body.
62    pub fn box_char(self) -> char {
63        match self {
64            PendingState::Open => ' ',
65            PendingState::Gated => '/',
66            PendingState::Done => 'x',
67        }
68    }
69
70    /// Parse the inside of a `[…]` checkbox. Accepts `[X]` as `Done`.
71    /// Currently only used by tests; kept on the public API for parser callers
72    /// that may need to inspect a single checkbox char in isolation.
73    #[allow(dead_code)]
74    pub fn from_box_char(c: char) -> Option<PendingState> {
75        match c {
76            ' ' => Some(PendingState::Open),
77            '/' => Some(PendingState::Gated),
78            'x' | 'X' => Some(PendingState::Done),
79            _ => None,
80        }
81    }
82}
83
84/// Mutating operation on a pending item — used by the state-transition matrix.
85///
86/// `MarkDone` is referenced by the matrix tests and reserved for the `op_done`
87/// migration path (Phase 3); it is not yet wired through the CLI primitives,
88/// which keep the unconditional `op_done` semantics from Phase 1.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90#[allow(dead_code)]
91pub enum PendingOp {
92    /// `[ ] → [/]` — code-complete, awaiting external gate.
93    Gate,
94    /// `[/] → [ ]` — return a gated item to active.
95    Ungate,
96    /// `[ ] | [/] → [x]` — fully complete.
97    MarkDone,
98}
99
100/// Outcome of `validate_transition`. `NoOp` means "already in target state, do nothing".
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum TransitionResult {
103    Transition(PendingState),
104    NoOp,
105}
106
107/// Apply the state-machine matrix from `specs/pending-system.md` §4.
108///
109/// | from \ op | Gate     | Ungate   | MarkDone |
110/// |-----------|----------|----------|----------|
111/// | Open      | → Gated  | error    | → Done   |
112/// | Gated     | no-op    | → Open   | → Done   |
113/// | Done      | error    | error    | no-op    |
114///
115/// Rationale (spec §4):
116/// - `gate` from Done is an error: a fully-complete item cannot be re-gated; the
117///   intended workflow is to add a new pending item describing the follow-up gate.
118/// - `ungate` from Open or Done is an error: ungate is the inverse of gate, not
119///   a generic "reset" — it requires an explicit `[/]` source.
120/// - `Gate` on Gated and `MarkDone` on Done are idempotent no-ops, not errors,
121///   so the granular CLI flags can be re-run safely (skill retries, watch loops).
122pub fn validate_transition(from: PendingState, op: PendingOp) -> Result<TransitionResult> {
123    use PendingOp::*;
124    use PendingState as S;
125    use TransitionResult::*;
126    match (from, op) {
127        (S::Open, Gate) => Ok(Transition(S::Gated)),
128        (S::Open, Ungate) => bail!("cannot ungate Open item: source must be `[/]`"),
129        (S::Open, MarkDone) => Ok(Transition(S::Done)),
130        (S::Gated, Gate) => Ok(NoOp),
131        (S::Gated, Ungate) => Ok(Transition(S::Open)),
132        (S::Gated, MarkDone) => Ok(Transition(S::Done)),
133        (S::Done, Gate) => {
134            bail!("cannot gate Done item: add a new pending item for the follow-up gate")
135        }
136        (S::Done, Ungate) => bail!("cannot ungate Done item: source must be `[/]`"),
137        (S::Done, MarkDone) => Ok(NoOp),
138    }
139}
140
141/// A parsed pending list item.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct PendingItem {
144    /// Parent list marker. `-` is the default; ordered lists store the parsed
145    /// source ordinal and are renumbered canonically on render.
146    pub marker: PendingListMarker,
147    /// Pending item id (no `#` prefix). Generated ids are lowercase base32; custom ids
148    /// and nested subtask ids may be hyphenated ASCII alphanumeric strings and are
149    /// normalized to lowercase.
150    pub id: String,
151    /// Lifecycle state encoded by the GFM checkbox.
152    pub state: PendingState,
153    /// Optional typed gate (e.g., "release" for `[/release]`, "deploy" for `[/deploy]`).
154    /// Only meaningful when `state == Gated`. `None` means untyped `[/]`.
155    pub gate_type: Option<String>,
156    /// Bullet text after the hash prefix.
157    pub text: String,
158    /// Raw indented continuation lines that belong to this item (for nested
159    /// lists, dependency notes, etc.). Stored without the leading item line.
160    pub continuation: String,
161}
162
163impl PendingItem {
164    /// Render to canonical `- [<state>] [#id] text` form.
165    /// Typed gates render as `[/release]`, `[/deploy]`, etc.
166    pub fn render(&self) -> String {
167        self.render_with_ordered_index(None)
168    }
169
170    fn render_with_ordered_index(&self, ordered_index: Option<usize>) -> String {
171        let checkbox = match (&self.state, &self.gate_type) {
172            (PendingState::Gated, Some(gt)) => format!("[/{}]", gt),
173            _ => format!("[{}]", self.state.box_char()),
174        };
175        let mut out = format!(
176            "{} {} [#{}] {}",
177            self.marker.render_prefix(ordered_index),
178            checkbox,
179            self.id,
180            self.text
181        );
182        if !self.continuation.is_empty() {
183            out.push('\n');
184            out.push_str(&self.continuation);
185        }
186        out
187    }
188
189    /// Convenience: true when state is `Done` (`[x]`).
190    pub fn is_done(&self) -> bool {
191        matches!(self.state, PendingState::Done)
192    }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
196enum PendingSegment {
197    Text(String),
198    Item {
199        item: PendingItem,
200        has_newline: bool,
201    },
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Default)]
205struct PendingLayout {
206    segments: Vec<PendingSegment>,
207}
208
209impl PendingLayout {
210    fn parse(body: &str) -> Self {
211        if body.is_empty() {
212            return Self {
213                segments: Vec::new(),
214            };
215        }
216
217        let mut segments = Vec::new();
218        let lines: Vec<&str> = body.split_inclusive('\n').collect();
219        let mut index = 0usize;
220        while index < lines.len() {
221            let raw_line = lines[index];
222            let has_newline = raw_line.ends_with('\n');
223            let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
224            if let Some(mut item) = parse_item_line(line) {
225                index += 1;
226                let mut continuation = String::new();
227                let mut pending_blank_lines = String::new();
228                while index < lines.len() {
229                    let next_raw = lines[index];
230                    let next_line = next_raw.strip_suffix('\n').unwrap_or(next_raw);
231                    if parse_item_line(next_line).is_some() {
232                        break;
233                    }
234                    if next_line.is_empty() {
235                        pending_blank_lines.push_str(next_raw);
236                        index += 1;
237                        continue;
238                    }
239                    if is_indented_continuation_line(next_line) {
240                        continuation.push_str(&pending_blank_lines);
241                        pending_blank_lines.clear();
242                        continuation.push_str(next_raw);
243                        index += 1;
244                        continue;
245                    }
246                    break;
247                }
248                item.continuation = continuation;
249                segments.push(PendingSegment::Item { item, has_newline });
250                if !pending_blank_lines.is_empty() {
251                    segments.push(PendingSegment::Text(pending_blank_lines));
252                }
253            } else {
254                segments.push(PendingSegment::Text(raw_line.to_string()));
255                index += 1;
256            }
257        }
258        Self { segments }
259    }
260
261    fn render(&self) -> String {
262        let mut out = String::new();
263        let ordered_mode = self
264            .segments
265            .iter()
266            .filter_map(|segment| match segment {
267                PendingSegment::Item { item, .. } => Some(item.marker.is_ordered()),
268                PendingSegment::Text(_) => None,
269            })
270            .any(|is_ordered| is_ordered);
271        let mut ordered_index = 1usize;
272        for segment in &self.segments {
273            match segment {
274                PendingSegment::Text(raw) => out.push_str(raw),
275                PendingSegment::Item { item, has_newline } => {
276                    let render = if ordered_mode {
277                        let render = item.render_with_ordered_index(Some(ordered_index));
278                        ordered_index += 1;
279                        render
280                    } else {
281                        item.render()
282                    };
283                    out.push_str(&render);
284                    if *has_newline && item.continuation.is_empty() {
285                        out.push('\n');
286                    }
287                }
288            }
289        }
290        out
291    }
292
293    fn items(&self) -> Vec<PendingItem> {
294        self.segments
295            .iter()
296            .filter_map(|segment| match segment {
297                PendingSegment::Item { item, .. } => Some(item.clone()),
298                PendingSegment::Text(_) => None,
299            })
300            .collect()
301    }
302
303    fn first_item_index(&self) -> Option<usize> {
304        self.segments
305            .iter()
306            .position(|segment| matches!(segment, PendingSegment::Item { .. }))
307    }
308
309    fn ensure_separator_before(&mut self, index: usize) {
310        if index == 0 {
311            return;
312        }
313        match &mut self.segments[index - 1] {
314            PendingSegment::Text(raw) => {
315                if !raw.is_empty() && !raw.ends_with('\n') {
316                    raw.push('\n');
317                }
318            }
319            PendingSegment::Item { has_newline, .. } => {
320                *has_newline = true;
321            }
322        }
323    }
324
325    fn insert_first_item(&mut self, item: PendingItem) {
326        let index = self.first_item_index().unwrap_or(self.segments.len());
327        self.insert_item_at(index, item);
328    }
329
330    /// Segment index of the item whose id matches `id` (already normalized by
331    /// the caller), or `None` when no such item exists.
332    fn item_segment_index(&self, id: &str) -> Option<usize> {
333        self.segments.iter().position(
334            |segment| matches!(segment, PendingSegment::Item { item, .. } if item.id == id),
335        )
336    }
337
338    /// Segment index just past the last item (before any trailing postlude
339    /// text), suitable for appending a new item at the tail of the active list.
340    fn last_item_index_plus_one(&self) -> usize {
341        self.segments
342            .iter()
343            .rposition(|segment| matches!(segment, PendingSegment::Item { .. }))
344            .map(|idx| idx + 1)
345            .unwrap_or_else(|| self.first_item_index().unwrap_or(self.segments.len()))
346    }
347
348    /// Insert `item` as a new segment at `index`, fixing the separator before it.
349    fn insert_item_at(&mut self, index: usize, item: PendingItem) {
350        self.ensure_separator_before(index);
351        self.segments.insert(
352            index,
353            PendingSegment::Item {
354                item,
355                has_newline: true,
356            },
357        );
358    }
359
360    fn replace_items<F>(&self, mut replacer: F) -> Self
361    where
362        F: FnMut(&PendingItem) -> Option<PendingItem>,
363    {
364        let mut segments = Vec::with_capacity(self.segments.len());
365        for segment in &self.segments {
366            match segment {
367                PendingSegment::Text(raw) => segments.push(PendingSegment::Text(raw.clone())),
368                PendingSegment::Item { item, has_newline } => {
369                    if let Some(next_item) = replacer(item) {
370                        segments.push(PendingSegment::Item {
371                            item: next_item,
372                            has_newline: *has_newline,
373                        });
374                    }
375                }
376            }
377        }
378        Self { segments }
379    }
380
381    fn non_item_segments(&self) -> Vec<String> {
382        self.segments
383            .iter()
384            .filter_map(|segment| match segment {
385                PendingSegment::Text(raw) => Some(raw.clone()),
386                PendingSegment::Item { .. } => None,
387            })
388            .collect()
389    }
390}
391
392#[derive(Debug, Clone, PartialEq, Eq)]
393pub struct ShadowPendingItem {
394    pub id: String,
395    pub text: String,
396    pub line: usize,
397}
398
399impl ShadowPendingItem {
400    pub fn reference(&self) -> String {
401        format!("#{} (line {})", self.id, self.line)
402    }
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Default)]
406pub struct ShadowPendingReport {
407    pub duplicated_in_live_backlog: Vec<ShadowPendingItem>,
408    pub shadow_only: Vec<ShadowPendingItem>,
409}
410
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct MalformedPendingItemLine {
413    pub id: String,
414    pub line: usize,
415    pub text: String,
416}
417
418impl MalformedPendingItemLine {
419    pub fn reference(&self) -> String {
420        format!("#{} (line {}): {}", self.id, self.line, self.text)
421    }
422}
423
424/// Find lines that look like tracked checklist items but are not parseable as
425/// live pending items. These lines are dangerous during closeout because guards
426/// that operate on parsed items would otherwise treat the matching id as absent.
427pub fn detect_malformed_item_lines(body: &str) -> Vec<MalformedPendingItemLine> {
428    body.lines()
429        .enumerate()
430        .filter_map(|(idx, line)| {
431            if parse_item_line(line).is_some() {
432                return None;
433            }
434            let trimmed = line.trim();
435            let (id, id_start) = find_valid_hash_id(trimmed)?;
436            let prefix = &trimmed[..id_start];
437            if !prefix_contains_task_checkbox(prefix) {
438                return None;
439            }
440            Some(MalformedPendingItemLine {
441                id: id.to_string(),
442                line: idx + 1,
443                text: trimmed.to_string(),
444            })
445        })
446        .collect()
447}
448
449fn find_valid_hash_id(line: &str) -> Option<(&str, usize)> {
450    let start = line.find("[#")?;
451    let after = &line[start + 2..];
452    let close = after.find(']')?;
453    let id = &after[..close];
454    is_valid_pending_id(id).then_some((id, start))
455}
456
457fn prefix_contains_task_checkbox(prefix: &str) -> bool {
458    prefix.contains("[ ]")
459        || prefix.contains("[/]")
460        || prefix.contains("[x]")
461        || prefix.contains("[X]")
462        || prefix
463            .split("[/")
464            .nth(1)
465            .and_then(|tail| tail.split(']').next())
466            .is_some_and(|gate| {
467                !gate.is_empty()
468                    && gate
469                        .chars()
470                        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
471            })
472}
473
474/// Parse the pending component body into (prelude, items, postlude).
475///
476/// - Prelude: leading non-list lines (whitespace, non tracked parent lines).
477/// - Items: parsed tracked entries (`- ...` / `1. ...`, legacy or fully-migrated).
478/// - Postlude: trailing non-list lines after the last item.
479///
480/// Interleaved non-item lines between backlog entries are intentionally not
481/// surfaced here. Mutation helpers preserve those lines through `PendingLayout`;
482/// `parse_items` remains the lossy item-only view used by reorder/diff checks.
483pub fn parse_items(body: &str) -> (String, Vec<PendingItem>, String) {
484    let layout = PendingLayout::parse(body);
485    let Some(first_item) = layout.first_item_index() else {
486        return (body.to_string(), Vec::new(), String::new());
487    };
488    let last_item = layout
489        .segments
490        .iter()
491        .rposition(|segment| matches!(segment, PendingSegment::Item { .. }))
492        .unwrap_or(first_item);
493
494    let mut prelude = String::new();
495    for segment in &layout.segments[..first_item] {
496        if let PendingSegment::Text(raw) = segment {
497            prelude.push_str(raw);
498        }
499    }
500
501    let mut postlude = String::new();
502    if last_item + 1 < layout.segments.len() {
503        for segment in &layout.segments[last_item + 1..] {
504            if let PendingSegment::Text(raw) = segment {
505                postlude.push_str(raw);
506            }
507        }
508    }
509
510    let mut items = Vec::new();
511    for segment in &layout.segments[first_item..=last_item] {
512        if let PendingSegment::Item { item, .. } = segment {
513            items.push(item.clone());
514        }
515    }
516
517    (prelude, items, postlude)
518}
519
520/// Parse a single list item line into a `PendingItem` (id optional).
521///
522/// Returns `None` when the line is not a list item. When the id is missing,
523/// the returned item has an empty id — callers must run `backfill` to assign one.
524fn parse_item_line(line: &str) -> Option<PendingItem> {
525    let (marker, rest) = parse_parent_list_marker(line)?;
526    let rest = rest.trim_start();
527
528    // Checkbox? Supports typed gates: [/release], [/deploy], etc.
529    let (state, gate_type, after_box) = if let Some(r) = rest.strip_prefix("[ ]") {
530        (PendingState::Open, None, r.trim_start())
531    } else if let Some(r) = rest.strip_prefix("[/]") {
532        (PendingState::Gated, None, r.trim_start())
533    } else if let Some(inner) = rest.strip_prefix("[/") {
534        // Typed gate: [/release], [/deploy], etc.
535        if let Some(close) = inner.find(']') {
536            let gt = &inner[..close];
537            if !gt.is_empty()
538                && gt
539                    .chars()
540                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
541            {
542                let r = &inner[close + 1..];
543                (PendingState::Gated, Some(gt.to_lowercase()), r.trim_start())
544            } else {
545                (PendingState::Open, None, rest)
546            }
547        } else {
548            (PendingState::Open, None, rest)
549        }
550    } else if let Some(r) = rest.strip_prefix("[x]") {
551        (PendingState::Done, None, r.trim_start())
552    } else if let Some(r) = rest.strip_prefix("[X]") {
553        (PendingState::Done, None, r.trim_start())
554    } else if let Some(r) = rest.strip_prefix("[~]") {
555        // Legacy/in-progress task marker. Treat as open so an existing hash
556        // immediately after it is preserved instead of re-keyed by backfill.
557        (PendingState::Open, None, r.trim_start())
558    } else {
559        (PendingState::Open, None, rest)
560    };
561
562    // Hash id?
563    let (id, text) = if let Some(after_hash) = after_box.strip_prefix("[#") {
564        if let Some(close) = after_hash.find(']') {
565            let id_raw = &after_hash[..close];
566            let tail = after_hash[close + 1..].trim_start();
567            if is_valid_pending_id(id_raw) {
568                (id_raw.to_lowercase(), tail.to_string())
569            } else if id_raw.is_empty() {
570                // Bare [#] placeholder — consume it, text starts after ]
571                (String::new(), tail.to_string())
572            } else {
573                (String::new(), after_box.to_string())
574            }
575        } else {
576            (String::new(), after_box.to_string())
577        }
578    } else {
579        (String::new(), after_box.to_string())
580    };
581
582    // Self-heal a redundant self-referential `[#id]` token embedded in the text
583    // (`#pending-redundant-self-id-strip`): when an item was created with its own
584    // id repeated inside the content (e.g. `[#x] [recommended] [#x] ...` from a
585    // `--pending-add` whose text already carried the id), the leading `[#x]`
586    // becomes the parsed id and the second `[#x]` would otherwise render as a
587    // duplicate. Cross-references to *other* ids are left intact.
588    let text = if id.is_empty() {
589        text
590    } else {
591        strip_redundant_self_id_tag(&text, &id)
592    };
593
594    Some(PendingItem {
595        marker,
596        id,
597        state,
598        gate_type,
599        text: text.trim_end().to_string(),
600        continuation: String::new(),
601    })
602}
603
604/// Remove the first bracketed `[#id]` token in `text` that equals the item's own
605/// `id`, collapsing the surrounding spaces it leaves behind. Only a token that
606/// matches the item's own id is removed; references to other ids stay.
607fn strip_redundant_self_id_tag(text: &str, id: &str) -> String {
608    let mut out = String::with_capacity(text.len());
609    let mut rest = text;
610    let mut stripped = false;
611    while let Some(pos) = rest.find("[#") {
612        if !stripped {
613            let after = &rest[pos + 2..];
614            if let Some(close) = after.find(']') {
615                let tok = &after[..close];
616                if is_valid_pending_id(tok) && tok.eq_ignore_ascii_case(id) {
617                    out.push_str(&rest[..pos]);
618                    rest = &after[close + 1..];
619                    stripped = true;
620                    continue;
621                }
622            }
623        }
624        let keep_to = pos + 2;
625        out.push_str(&rest[..keep_to]);
626        rest = &rest[keep_to..];
627    }
628    out.push_str(rest);
629    if stripped {
630        collapse_inline_spaces(&out)
631    } else {
632        out
633    }
634}
635
636/// Collapse runs of ASCII spaces/tabs to a single space and trim ends, leaving
637/// newlines intact. Used after removing an inline token from a one-line item.
638fn collapse_inline_spaces(text: &str) -> String {
639    let mut out = String::with_capacity(text.len());
640    let mut prev_space = false;
641    for ch in text.chars() {
642        if ch == ' ' || ch == '\t' {
643            if !prev_space {
644                out.push(' ');
645            }
646            prev_space = true;
647        } else {
648            prev_space = false;
649            out.push(ch);
650        }
651    }
652    out.trim().to_string()
653}
654
655fn parse_parent_list_marker(line: &str) -> Option<(PendingListMarker, &str)> {
656    if let Some(rest) = line.strip_prefix("- ") {
657        return Some((PendingListMarker::Bullet, rest));
658    }
659
660    let digit_len = line.bytes().take_while(|b| b.is_ascii_digit()).count();
661    if digit_len == 0 {
662        return None;
663    }
664
665    let (digits, tail) = line.split_at(digit_len);
666    let tail = tail.strip_prefix('.')?;
667    if !tail.starts_with(char::is_whitespace) {
668        return None;
669    }
670    let rest = tail.trim_start();
671    let ordinal = digits.parse::<usize>().ok()?;
672    Some((PendingListMarker::Ordered(ordinal), rest))
673}
674
675fn is_indented_continuation_line(line: &str) -> bool {
676    line.starts_with(' ') || line.starts_with('\t')
677}
678
679fn is_structural_inter_item_line(line: &str) -> bool {
680    let trimmed = line.trim();
681    trimmed.starts_with('#')
682        || trimmed.starts_with("<!--")
683        || matches!(trimmed, "---" | "***" | "___")
684}
685
686fn split_reapable_trailing_text_segment(raw: &str) -> Option<(String, String)> {
687    if raw.is_empty() {
688        return None;
689    }
690
691    let mut reap = String::new();
692    let mut keep = String::new();
693    let mut pending_blank = String::new();
694    let mut reaping = true;
695
696    for segment in raw.split_inclusive('\n') {
697        let line = segment.strip_suffix('\n').unwrap_or(segment);
698        if !reaping {
699            keep.push_str(segment);
700            continue;
701        }
702        if line.trim().is_empty() {
703            pending_blank.push_str(segment);
704            continue;
705        }
706        if is_structural_inter_item_line(line) {
707            keep.push_str(&pending_blank);
708            pending_blank.clear();
709            keep.push_str(segment);
710            reaping = false;
711            continue;
712        }
713        reap.push_str(&pending_blank);
714        pending_blank.clear();
715        reap.push_str(segment);
716    }
717
718    if reap.is_empty() {
719        return None;
720    }
721    reap.push_str(&pending_blank);
722    Some((reap, keep))
723}
724
725pub fn is_valid_pending_id(s: &str) -> bool {
726    !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
727}
728
729pub fn normalize_pending_id(id: &str) -> String {
730    id.trim().trim_start_matches('#').to_ascii_lowercase()
731}
732
733#[derive(Debug, Clone, PartialEq, Eq, Hash)]
734pub struct SymptomDedupeKey {
735    pub invariant_id: String,
736    pub document_id: String,
737    pub component: String,
738    pub content_hash: String,
739}
740
741impl SymptomDedupeKey {
742    pub fn new(
743        invariant_id: impl Into<String>,
744        document_id: impl Into<String>,
745        component: impl Into<String>,
746        content_hash: impl Into<String>,
747    ) -> Result<Self> {
748        Ok(Self {
749            invariant_id: validate_symptom_token("invariant", invariant_id.into())?,
750            document_id: validate_symptom_token("document", document_id.into())?,
751            component: validate_symptom_token("component", component.into())?,
752            content_hash: validate_symptom_token("content_hash", content_hash.into())?,
753        })
754    }
755
756    pub fn marker(&self) -> String {
757        format!(
758            "[symptom-key invariant={} document={} component={} content_hash={}]",
759            self.invariant_id, self.document_id, self.component, self.content_hash
760        )
761    }
762
763    pub fn log_fields(&self) -> String {
764        format!(
765            "invariant={} document={} component={} content_hash={}",
766            self.invariant_id, self.document_id, self.component, self.content_hash
767        )
768    }
769}
770
771#[derive(Debug, Clone, PartialEq, Eq)]
772pub struct PendingAddOutcome {
773    pub body: String,
774    pub id: String,
775    pub inserted: bool,
776    pub deduped_key: Option<SymptomDedupeKey>,
777}
778
779pub fn symptom_dedupe_key_from_text(text: &str) -> Result<Option<SymptomDedupeKey>> {
780    let Some(marker_start) = text.find("[symptom-key ") else {
781        return Ok(None);
782    };
783    let marker_body_start = marker_start + "[symptom-key ".len();
784    let Some(marker_end_rel) = text[marker_body_start..].find(']') else {
785        bail!("symptom-key marker is missing closing `]`");
786    };
787    let marker_body = &text[marker_body_start..marker_body_start + marker_end_rel];
788
789    let mut invariant_id = None;
790    let mut document_id = None;
791    let mut component = None;
792    let mut content_hash = None;
793    for field in marker_body.split_whitespace() {
794        let Some((name, value)) = field.split_once('=') else {
795            bail!("symptom-key field must be key=value: {field}");
796        };
797        let slot = match name {
798            "invariant" => &mut invariant_id,
799            "document" => &mut document_id,
800            "component" => &mut component,
801            "content_hash" => &mut content_hash,
802            _ => bail!("unknown symptom-key field `{name}`"),
803        };
804        if slot.is_some() {
805            bail!("duplicate symptom-key field `{name}`");
806        }
807        *slot = Some(validate_symptom_token(name, value.to_string())?);
808    }
809
810    Ok(Some(SymptomDedupeKey::new(
811        invariant_id.context("symptom-key missing invariant")?,
812        document_id.context("symptom-key missing document")?,
813        component.context("symptom-key missing component")?,
814        content_hash.context("symptom-key missing content_hash")?,
815    )?))
816}
817
818fn validate_symptom_token(field: &str, value: String) -> Result<String> {
819    if value.trim().is_empty() {
820        bail!("symptom-key {field} must not be empty");
821    }
822    if value.trim() != value || !value.chars().all(is_symptom_token_char) {
823        bail!("symptom-key {field} must be a single field-safe token: {value}");
824    }
825    Ok(value)
826}
827
828fn is_symptom_token_char(ch: char) -> bool {
829    ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | ':' | '/')
830}
831
832fn strip_symptom_dedupe_key_marker(text: &str) -> String {
833    let Some(marker_start) = text.find("[symptom-key ") else {
834        return text.trim().to_string();
835    };
836    let marker_body_start = marker_start + "[symptom-key ".len();
837    let Some(marker_end_rel) = text[marker_body_start..].find(']') else {
838        return text.trim().to_string();
839    };
840    let marker_end = marker_body_start + marker_end_rel + 1;
841    let mut out = String::with_capacity(text.len());
842    out.push_str(&text[..marker_start]);
843    out.push_str(&text[marker_end..]);
844    collapse_inline_spaces(&out)
845}
846
847fn symptom_evidence_line(text: &str) -> String {
848    let evidence = strip_symptom_dedupe_key_marker(text);
849    if evidence.is_empty() {
850        "  evidence: repeated symptom".to_string()
851    } else {
852        format!("  evidence: {evidence}")
853    }
854}
855
856fn append_unique_continuation_line(continuation: &str, line: &str) -> String {
857    if continuation
858        .lines()
859        .any(|existing| existing.trim() == line.trim())
860    {
861        return continuation.to_string();
862    }
863    let mut out = continuation.to_string();
864    if !out.is_empty() && !out.ends_with('\n') {
865        out.push('\n');
866    }
867    out.push_str(line);
868    out.push('\n');
869    out
870}
871
872#[derive(Debug, Clone, Copy, PartialEq, Eq)]
873enum LeadingCustomIdPrefix {
874    Explicit,
875    Bracketed,
876    BarePlaceholder,
877}
878
879fn detect_leading_custom_id_prefix(text: &str) -> Option<LeadingCustomIdPrefix> {
880    let trimmed = text.trim_start();
881    if trimmed.starts_with("id=") {
882        return Some(LeadingCustomIdPrefix::Explicit);
883    }
884    let after_hash = trimmed.strip_prefix("[#")?;
885    let close = after_hash.find(']')?;
886    if close == 0 {
887        Some(LeadingCustomIdPrefix::BarePlaceholder)
888    } else {
889        Some(LeadingCustomIdPrefix::Bracketed)
890    }
891}
892
893pub(crate) fn ensure_no_leading_custom_id_prefix(text: &str, context: &str) -> Result<()> {
894    match detect_leading_custom_id_prefix(text) {
895        None => Ok(()),
896        Some(LeadingCustomIdPrefix::BarePlaceholder) => bail!(
897            "{}: bare `[#]` placeholder is invalid — omit it or use `id=<id> <text>`",
898            context
899        ),
900        Some(LeadingCustomIdPrefix::Explicit) | Some(LeadingCustomIdPrefix::Bracketed) => bail!(
901            "{}: duplicate leading custom id prefix in item text — use exactly one leading `id=<id>` or `[#id]` prefix",
902            context
903        ),
904    }
905}
906
907pub fn ensure_no_new_leading_custom_id_prefix(
908    item_id: &str,
909    text: &str,
910    existing_ids: &HashSet<String>,
911    context: &str,
912) -> Result<()> {
913    match detect_leading_custom_id_prefix(text) {
914        None => Ok(()),
915        Some(LeadingCustomIdPrefix::BarePlaceholder) => bail!(
916            "{}: bare `[#]` placeholder is invalid — omit it or use `id=<id> <text>`",
917            context
918        ),
919        Some(LeadingCustomIdPrefix::Explicit) => bail!(
920            "{}: duplicate leading custom id prefix in item text — use exactly one leading `id=<id>` or `[#id]` prefix",
921            context
922        ),
923        Some(LeadingCustomIdPrefix::Bracketed) if existing_ids.contains(item_id) => Ok(()),
924        Some(LeadingCustomIdPrefix::Bracketed) => bail!(
925            "{}: duplicate leading custom id prefix in item text — use exactly one leading `id=<id>` or `[#id]` prefix",
926            context
927        ),
928    }
929}
930
931fn extract_inline_tag_id(text: &str) -> Option<(String, String)> {
932    let mut search_from = 0;
933    while search_from < text.len() {
934        let remaining = &text[search_from..];
935        let pos = match remaining.find("[#") {
936            Some(p) => p,
937            None => break,
938        };
939        let abs_pos = search_from + pos;
940        let after_open = match text.get(abs_pos + 2..) {
941            Some(s) => s,
942            None => {
943                search_from = abs_pos + 2;
944                continue;
945            }
946        };
947        let close = match after_open.find(']') {
948            Some(c) => c,
949            None => {
950                search_from = abs_pos + 2;
951                continue;
952            }
953        };
954        let raw_id = &after_open[..close];
955        if !raw_id.is_empty() && is_valid_pending_id(raw_id) {
956            let tag_end = abs_pos + 2 + close + 1;
957            let before = text[..abs_pos].trim_end();
958            let after = text[tag_end..].trim_start();
959            let new_text = if before.is_empty() {
960                after.to_string()
961            } else if after.is_empty() {
962                before.to_string()
963            } else {
964                format!("{} {}", before, after)
965            };
966            if !new_text.is_empty() {
967                return Some((raw_id.to_lowercase(), new_text));
968            }
969        }
970        search_from = abs_pos + 2;
971    }
972    None
973}
974
975fn custom_id_error(raw_id: &str) -> anyhow::Error {
976    anyhow!(
977        "pending add: invalid custom id `{}` — ids must be non-empty ASCII alphanumeric strings (hyphen allowed)",
978        raw_id.trim()
979    )
980}
981
982fn parse_explicit_custom_id_prefix(rest: &str) -> Result<(Option<String>, String)> {
983    let Some((raw_id, remainder)) = rest.split_once(char::is_whitespace) else {
984        bail!(
985            "pending add: custom id prefix must be followed by item text (expected `id=<id> <text>`)"
986        );
987    };
988    let custom_id = raw_id.trim().trim_start_matches('#');
989    if custom_id.is_empty() {
990        bail!("pending add: empty custom id after `id=` — expected `id=<id> <text>`");
991    }
992    if !is_valid_pending_id(custom_id) {
993        return Err(custom_id_error(raw_id));
994    }
995    let remainder = remainder.trim();
996    if remainder.is_empty() {
997        bail!(
998            "pending add: custom id prefix must be followed by item text (expected `id=<id> <text>`)"
999        );
1000    }
1001    ensure_no_leading_custom_id_prefix(remainder, "pending add")?;
1002    Ok((Some(custom_id.to_lowercase()), remainder.to_string()))
1003}
1004
1005fn parse_bracketed_custom_id_prefix(trimmed: &str) -> Result<(Option<String>, String)> {
1006    let Some(after_hash) = trimmed.strip_prefix("[#") else {
1007        return Ok((None, trimmed.to_string()));
1008    };
1009    let Some(close) = after_hash.find(']') else {
1010        return Ok((None, trimmed.to_string()));
1011    };
1012    let raw_id = &after_hash[..close];
1013    let remainder = after_hash[close + 1..].trim_start();
1014    if raw_id.is_empty() {
1015        bail!("pending add: bare `[#]` placeholder is invalid — use `id=<id> <text>` or omit it");
1016    }
1017    if !is_valid_pending_id(raw_id) {
1018        return Err(custom_id_error(raw_id));
1019    }
1020    if remainder.is_empty() {
1021        bail!(
1022            "pending add: bracketed custom id prefix must be followed by item text (expected `[#id] <text>`)"
1023        );
1024    }
1025    ensure_no_leading_custom_id_prefix(remainder, "pending add")?;
1026    Ok((Some(raw_id.to_lowercase()), remainder.to_string()))
1027}
1028
1029fn parse_custom_id_prefix(text: &str) -> Result<(Option<String>, String)> {
1030    let trimmed = text.trim();
1031    if let Some(rest) = trimmed.strip_prefix("id=") {
1032        return parse_explicit_custom_id_prefix(rest);
1033    }
1034    parse_bracketed_custom_id_prefix(trimmed)
1035}
1036
1037/// Return the explicit, caller-provided custom id from a `--pending-add` item
1038/// string (`id=<id> <text>` or `[#<id>] <text>`), normalized (lowercase, no
1039/// `#`). Returns `None` for auto-id items (no explicit prefix) and for malformed
1040/// prefixes — callers that need the strict parse error use the add path itself.
1041/// Used by mutation-time collision enforcement (#preset-item-id-collision-enforce)
1042/// so an explicit id that collides with a prompt preset or active item id is
1043/// rejected before the add is written.
1044pub fn explicit_custom_id(item: &str) -> Option<String> {
1045    match parse_custom_id_prefix(item) {
1046        Ok((id, _)) => id,
1047        Err(_) => None,
1048    }
1049}
1050
1051#[test]
1052fn existing_item_may_keep_leading_alias_tag() {
1053    let mut existing_ids = HashSet::new();
1054    existing_ids.insert("yckq".to_string());
1055
1056    ensure_no_new_leading_custom_id_prefix(
1057        "yckq",
1058        "[#ss01] ShipStation fix",
1059        &existing_ids,
1060        "pending/backlog patch",
1061    )
1062    .expect("existing alias tag should not be rejected");
1063}
1064
1065#[test]
1066fn new_item_still_rejects_leading_alias_tag() {
1067    let existing_ids = HashSet::new();
1068    let err = ensure_no_new_leading_custom_id_prefix(
1069        "yckq",
1070        "[#ss01] ShipStation fix",
1071        &existing_ids,
1072        "pending/backlog patch",
1073    )
1074    .expect_err("new item alias tag should still be rejected");
1075    assert!(
1076        err.to_string()
1077            .contains("duplicate leading custom id prefix"),
1078        "unexpected error: {}",
1079        err
1080    );
1081}
1082
1083/// Serialize items back to a body string.
1084#[allow(dead_code)]
1085pub fn render_items(prelude: &str, items: &[PendingItem], postlude: &str) -> String {
1086    let mut out = String::new();
1087    let ordered_mode = items.iter().any(|item| item.marker.is_ordered());
1088    let mut ordered_index = 1usize;
1089    out.push_str(prelude);
1090    if !prelude.is_empty() && !prelude.ends_with('\n') {
1091        out.push('\n');
1092    }
1093    for item in items {
1094        let render = if ordered_mode {
1095            let render = item.render_with_ordered_index(Some(ordered_index));
1096            ordered_index += 1;
1097            render
1098        } else {
1099            item.render()
1100        };
1101        out.push_str(&render);
1102        if item.continuation.is_empty() || !item.continuation.ends_with('\n') {
1103            out.push('\n');
1104        }
1105    }
1106    if !postlude.is_empty() {
1107        out.push_str(postlude);
1108        if !postlude.ends_with('\n') {
1109            out.push('\n');
1110        }
1111    }
1112    out
1113}
1114
1115/// Remove a tracked item by id and return the updated body plus the removed item.
1116pub fn op_take_item(body: &str, id: &str) -> Result<(String, PendingItem)> {
1117    let id = normalize_pending_id(id);
1118    let layout = PendingLayout::parse(body);
1119    let mut taken = None;
1120    let rewritten = layout.replace_items(|item| {
1121        if item.id == id {
1122            taken = Some(item.clone());
1123            None
1124        } else {
1125            Some(item.clone())
1126        }
1127    });
1128    let Some(item) = taken else {
1129        bail!("id not found in pending list: {}", id);
1130    };
1131    Ok((rewritten.render(), item))
1132}
1133
1134/// Remove every item whose id matches `id`, returning the rewritten body and
1135/// the removed items. Unlike [`op_take_item`] (which keeps a single
1136/// representative and errors on a miss), this collapses duplicate-id entries in
1137/// one pass and returns them all — used by `--review-remove` so a duplicated
1138/// review id (e.g. an interleaved finalize that wrote the same `#id` twice) can
1139/// be cleared without an ambiguous edit-by-id.
1140pub fn op_take_all_by_id(body: &str, id: &str) -> (String, Vec<PendingItem>) {
1141    let id = normalize_pending_id(id);
1142    let layout = PendingLayout::parse(body);
1143    let mut taken = Vec::new();
1144    let rewritten = layout.replace_items(|item| {
1145        if item.id == id {
1146            taken.push(item.clone());
1147            None
1148        } else {
1149            Some(item.clone())
1150        }
1151    });
1152    (rewritten.render(), taken)
1153}
1154
1155/// Collapse runs of identical same-id entries to a single representative,
1156/// preserving first-seen order. Two items are "identical" when their id, state,
1157/// gate type, text, and continuation all match — this targets the exact-dup
1158/// shape an interleaved finalize produces, never distinct items that merely
1159/// share an id (those remain so an ambiguity warning still surfaces). Returns
1160/// the rewritten body and the ids that had duplicates removed.
1161pub fn op_dedupe_identical_items(body: &str) -> (String, Vec<String>) {
1162    let layout = PendingLayout::parse(body);
1163    let mut seen: Vec<PendingItem> = Vec::new();
1164    let mut deduped_ids: Vec<String> = Vec::new();
1165    let rewritten = layout.replace_items(|item| {
1166        if seen.iter().any(|prev| prev == item) {
1167            if !deduped_ids.contains(&item.id) {
1168                deduped_ids.push(item.id.clone());
1169            }
1170            None
1171        } else {
1172            seen.push(item.clone());
1173            Some(item.clone())
1174        }
1175    });
1176    (rewritten.render(), deduped_ids)
1177}
1178
1179/// Insert an existing tracked item at the first item slot of a component body.
1180pub fn op_insert_item_first(body: &str, item: PendingItem) -> String {
1181    let mut layout = PendingLayout::parse(body);
1182    layout.insert_first_item(item);
1183    layout.render()
1184}
1185
1186/// Append existing tracked items after the current item list, preserving order.
1187pub fn op_append_items(body: &str, items: &[PendingItem]) -> String {
1188    let mut layout = PendingLayout::parse(body);
1189    let insert_at = layout
1190        .segments
1191        .iter()
1192        .rposition(|segment| matches!(segment, PendingSegment::Item { .. }))
1193        .map(|idx| idx + 1)
1194        .unwrap_or_else(|| layout.first_item_index().unwrap_or(layout.segments.len()));
1195    layout.ensure_separator_before(insert_at);
1196    for (offset, item) in items.iter().enumerate() {
1197        layout.segments.insert(
1198            insert_at + offset,
1199            PendingSegment::Item {
1200                item: item.clone(),
1201                has_newline: true,
1202            },
1203        );
1204    }
1205    layout.render()
1206}
1207
1208/// Extract all items matching `state`, returning the updated body and removed items.
1209pub fn op_take_items_by_state(body: &str, state: PendingState) -> (String, Vec<PendingItem>) {
1210    let layout = PendingLayout::parse(body);
1211    let mut taken = Vec::new();
1212    let rewritten = layout.replace_items(|item| {
1213        if item.state == state {
1214            taken.push(item.clone());
1215            None
1216        } else {
1217            Some(item.clone())
1218        }
1219    });
1220    (rewritten.render(), taken)
1221}
1222
1223/// Extract non-done items with ids in `ids`, returning the updated body and removed items.
1224pub fn op_take_active_items_by_ids(
1225    body: &str,
1226    ids: &HashSet<String>,
1227) -> (String, Vec<PendingItem>) {
1228    if ids.is_empty() {
1229        return (body.to_string(), Vec::new());
1230    }
1231    let ids: HashSet<String> = ids.iter().map(|id| normalize_pending_id(id)).collect();
1232    let layout = PendingLayout::parse(body);
1233    let mut taken = Vec::new();
1234    let rewritten = layout.replace_items(|item| {
1235        if !item.is_done() && ids.contains(&item.id) {
1236            taken.push(item.clone());
1237            None
1238        } else {
1239            Some(item.clone())
1240        }
1241    });
1242    (rewritten.render(), taken)
1243}
1244
1245pub fn canonicalize_preserving_non_item_lines(body: &str) -> String {
1246    PendingLayout::parse(body).render()
1247}
1248
1249fn parse_pending_edit_payload(new_text: &str) -> Result<(String, String)> {
1250    let synthetic = format!("- [ ] [#edit] {}", new_text);
1251    let layout = PendingLayout::parse(&synthetic);
1252    let items = layout.items();
1253    if items.len() != 1 {
1254        bail!(
1255            "pending edit: multiline text may only describe one parent item; use indented continuation lines for child subtasks"
1256        );
1257    }
1258    if layout
1259        .non_item_segments()
1260        .into_iter()
1261        .any(|segment| !segment.trim().is_empty())
1262    {
1263        bail!(
1264            "pending edit: multiline text may only contain indented continuation lines after the first line"
1265        );
1266    }
1267    let mut item = items.into_iter().next().expect("single parsed edit item");
1268    if !item.continuation.is_empty() && !item.continuation.ends_with('\n') {
1269        item.continuation.push('\n');
1270    }
1271    Ok((item.text, item.continuation))
1272}
1273
1274fn trim_boundary_blank_segments(mut segments: Vec<String>) -> Vec<String> {
1275    while matches!(segments.first(), Some(segment) if segment.trim().is_empty()) {
1276        segments.remove(0);
1277    }
1278    while matches!(segments.last(), Some(segment) if segment.trim().is_empty()) {
1279        segments.pop();
1280    }
1281    segments
1282}
1283
1284pub fn preserves_non_item_structure(lhs: &str, rhs: &str) -> bool {
1285    trim_boundary_blank_segments(PendingLayout::parse(lhs).non_item_segments())
1286        == trim_boundary_blank_segments(PendingLayout::parse(rhs).non_item_segments())
1287}
1288
1289pub fn merge_partial_backlog_prefix(current_body: &str, target_body: &str) -> Option<String> {
1290    let current = PendingLayout::parse(current_body);
1291    let target = PendingLayout::parse(target_body);
1292
1293    let current_texts: Vec<(usize, String)> = current
1294        .segments
1295        .iter()
1296        .enumerate()
1297        .filter_map(|(idx, segment)| match segment {
1298            PendingSegment::Text(raw) if !raw.trim().is_empty() => Some((idx, raw.clone())),
1299            _ => None,
1300        })
1301        .collect();
1302    let target_texts: Vec<String> = target
1303        .segments
1304        .iter()
1305        .filter_map(|segment| match segment {
1306            PendingSegment::Text(raw) if !raw.trim().is_empty() => Some(raw.clone()),
1307            _ => None,
1308        })
1309        .collect();
1310
1311    if current_texts.is_empty()
1312        || target_texts.is_empty()
1313        || target_texts.len() >= current_texts.len()
1314        || !target_texts
1315            .iter()
1316            .zip(current_texts.iter())
1317            .all(|(target_text, (_, current_text))| target_text == current_text)
1318    {
1319        return None;
1320    }
1321
1322    let tail_start = current_texts[target_texts.len()].0;
1323    let mut segments = target.segments.clone();
1324    segments.extend(current.segments[tail_start..].iter().cloned());
1325    Some(PendingLayout { segments }.render())
1326}
1327
1328pub fn detect_shadow_open_items(doc: &str) -> Result<ShadowPendingReport> {
1329    let components = crate::component::parse(doc)?;
1330    let Some(backlog_component) = components
1331        .iter()
1332        .find(|component| crate::component::is_backlog_component(&component.name))
1333    else {
1334        return Ok(ShadowPendingReport::default());
1335    };
1336
1337    let (_, live_items, _) = parse_items(backlog_component.content(doc));
1338    let live_open_ids: HashSet<String> = live_items
1339        .into_iter()
1340        .filter(|item| !item.id.is_empty() && !item.is_done())
1341        .map(|item| item.id)
1342        .collect();
1343
1344    let excluded_ranges: Vec<(usize, usize)> = components
1345        .iter()
1346        .filter(|component| {
1347            crate::component::is_tracked_work_component(&component.name)
1348                || component.name == "exchange"
1349                // `agent:queue` `[#id]` / `do [#id]` heads are legitimate queue
1350                // references to backlog items, NOT shadow backlog items hiding in
1351                // commented-out prose. A reaped id's lingering queue head (e.g.
1352                // `[#jbacceptwedge]: 0.2.170 installed`) or any `#mirrorall`-mirrored
1353                // head must not be misclassified as an "open backlog item outside
1354                // the live backlog" and hard-wedge preflight — the done-strike
1355                // maintenance pass owns clearing resolved queue heads
1356                // (#qheadsync orphan-shadow). The shadow guard targets stray prose
1357                // (HTML comments, non-component sections), so exclude the queue too.
1358                || component.name == "queue"
1359        })
1360        .map(|component| (component.open_start, component.close_end))
1361        .collect();
1362    let code_ranges = crate::component::find_code_ranges(doc);
1363
1364    let mut report = ShadowPendingReport::default();
1365    let mut seen_ids = HashSet::new();
1366    let mut offset = 0usize;
1367
1368    for (line_idx, raw_line) in doc.split_inclusive('\n').enumerate() {
1369        let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
1370        let line_start = offset;
1371        let line_end = offset + raw_line.len();
1372        offset = line_end;
1373
1374        if excluded_ranges
1375            .iter()
1376            .any(|(start, end)| line_start < *end && line_end > *start)
1377        {
1378            continue;
1379        }
1380        if code_ranges
1381            .iter()
1382            .any(|(start, end)| line_start < *end && line_end > *start)
1383        {
1384            continue;
1385        }
1386
1387        let Some(item) = parse_item_line(line) else {
1388            continue;
1389        };
1390        if item.id.is_empty() || item.is_done() || !seen_ids.insert(item.id.clone()) {
1391            continue;
1392        }
1393
1394        let shadow = ShadowPendingItem {
1395            id: item.id.clone(),
1396            text: item.text,
1397            line: line_idx + 1,
1398        };
1399        if live_open_ids.contains(&item.id) {
1400            report.duplicated_in_live_backlog.push(shadow);
1401        } else {
1402            report.shadow_only.push(shadow);
1403        }
1404    }
1405
1406    Ok(report)
1407}
1408
1409#[derive(Debug, Clone, PartialEq, Eq)]
1410pub struct DroppedBacklogItem {
1411    pub id: String,
1412    pub text: String,
1413}
1414
1415impl DroppedBacklogItem {
1416    pub fn reference(&self) -> String {
1417        format!("#{}", self.id)
1418    }
1419}
1420
1421#[derive(Debug, Clone, PartialEq, Eq, Default)]
1422pub struct DroppedBacklogReport {
1423    pub dropped: Vec<DroppedBacklogItem>,
1424}
1425
1426/// Compare current document against a baseline to find open backlog items that
1427/// existed in the baseline but are completely absent from the current document.
1428///
1429/// "Completely absent" means the item's ID does not appear in:
1430/// - the live `agent:backlog` (any state: open, gated, or done)
1431/// - the `agent:icebox` component
1432/// - shadow/commented sections outside the live backlog
1433///
1434/// Items found in shadow sections are NOT reported here — the shadow guard
1435/// (`detect_shadow_open_items`) handles those separately.
1436///
1437/// `done_ids` allows callers to exclude items that were explicitly marked done
1438/// during the current cycle (from `cycle_state.pending_done_ids`).
1439#[allow(dead_code)]
1440pub fn detect_dropped_from_history(
1441    current_doc: &str,
1442    baseline_doc: &str,
1443    done_ids: &HashSet<String>,
1444) -> Result<DroppedBacklogReport> {
1445    detect_dropped_from_history_with_extra_current_ids(
1446        current_doc,
1447        baseline_doc,
1448        done_ids,
1449        &HashSet::new(),
1450    )
1451}
1452
1453pub fn detect_dropped_from_history_with_extra_current_ids(
1454    current_doc: &str,
1455    baseline_doc: &str,
1456    done_ids: &HashSet<String>,
1457    extra_current_ids: &HashSet<String>,
1458) -> Result<DroppedBacklogReport> {
1459    let baseline_components = crate::component::parse(baseline_doc)?;
1460    let Some(baseline_backlog) = baseline_components
1461        .iter()
1462        .find(|c| crate::component::is_backlog_component(&c.name))
1463    else {
1464        return Ok(DroppedBacklogReport::default());
1465    };
1466
1467    let (_, baseline_items, _) = parse_items(baseline_backlog.content(baseline_doc));
1468    let baseline_open: Vec<(String, String)> = baseline_items
1469        .into_iter()
1470        .filter(|item| !item.id.is_empty() && !item.is_done())
1471        .map(|item| (item.id, item.text))
1472        .collect();
1473
1474    if baseline_open.is_empty() {
1475        return Ok(DroppedBacklogReport::default());
1476    }
1477
1478    let current_components = crate::component::parse(current_doc)?;
1479
1480    let mut current_ids: HashSet<String> = HashSet::new();
1481
1482    for comp in &current_components {
1483        if crate::component::is_tracked_work_component(&comp.name) {
1484            let (_, items, _) = parse_items(comp.content(current_doc));
1485            for item in items {
1486                if !item.id.is_empty() {
1487                    current_ids.insert(item.id);
1488                }
1489            }
1490        } else if crate::component::is_backlog_done_component(&comp.name) {
1491            current_ids.extend(extract_pending_ids_from_text(comp.content(current_doc)));
1492        }
1493    }
1494    current_ids.extend(extra_current_ids.iter().cloned());
1495
1496    let excluded_ranges: Vec<(usize, usize)> = current_components
1497        .iter()
1498        .filter(|c| crate::component::is_tracked_work_component(&c.name))
1499        .map(|c| (c.open_start, c.close_end))
1500        .collect();
1501    let code_ranges = crate::component::find_code_ranges(current_doc);
1502
1503    let mut offset = 0usize;
1504    for raw_line in current_doc.split_inclusive('\n') {
1505        let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
1506        let line_start = offset;
1507        let line_end = offset + raw_line.len();
1508        offset = line_end;
1509
1510        if excluded_ranges
1511            .iter()
1512            .any(|(start, end)| line_start < *end && line_end > *start)
1513        {
1514            continue;
1515        }
1516        if code_ranges
1517            .iter()
1518            .any(|(start, end)| line_start < *end && line_end > *start)
1519        {
1520            continue;
1521        }
1522
1523        if let Some(item) = parse_item_line(line)
1524            && !item.id.is_empty()
1525        {
1526            current_ids.insert(item.id);
1527        }
1528    }
1529
1530    let mut dropped = Vec::new();
1531    for (id, text) in baseline_open {
1532        if done_ids.contains(&id) {
1533            continue;
1534        }
1535        if !current_ids.contains(&id) {
1536            dropped.push(DroppedBacklogItem { id, text });
1537        }
1538    }
1539
1540    Ok(DroppedBacklogReport { dropped })
1541}
1542
1543/// Active (open `[ ]`, not gated, not done) backlog item ids in document order.
1544///
1545/// Used by the `queue` attribute on `agent:backlog` / `agent:icebox` to
1546/// regenerate `agent:queue` `do [#id]` prompts (`#backlog-queue-sync-attr`).
1547/// Gated (`[/]`) and done (`[x]`) items are excluded — they are not actionable
1548/// queue targets. Items without an id are skipped.
1549pub fn active_item_ids(body: &str) -> Vec<String> {
1550    let today = today_civil_day();
1551    PendingLayout::parse(body)
1552        .items()
1553        .into_iter()
1554        .filter(|item| matches!(item.state, PendingState::Open) && !item.id.is_empty())
1555        .filter(|item| !item_precondition_unmet(&item.text, today))
1556        .map(|item| item.id.clone())
1557        .collect()
1558}
1559
1560fn item_has_enqueue_marker(text: &str) -> bool {
1561    let lower = text.to_ascii_lowercase();
1562    lower.contains(":inbox_tray:") || lower.split_whitespace().any(token_is_enqueue_marker)
1563}
1564
1565fn token_is_enqueue_marker(token: &str) -> bool {
1566    let trimmed =
1567        token.trim_matches(|ch: char| matches!(ch, '[' | ']' | '(' | ')' | ':' | ',' | '.' | ';'));
1568    matches!(
1569        trimmed,
1570        "/enqueue" | "**enqueue**" | "__enqueue__" | "*enqueue*" | "_enqueue_" | "`enqueue`"
1571    )
1572}
1573
1574/// Active open backlog item ids marked for one-shot queue insertion.
1575///
1576/// `:inbox_tray:`, `/enqueue`, and Markdown-decorated `enqueue` markers let
1577/// an item opt into queue population without requiring the whole component to
1578/// carry the `queue` attribute.
1579pub fn active_enqueue_item_ids(body: &str) -> Vec<String> {
1580    let today = today_civil_day();
1581    PendingLayout::parse(body)
1582        .items()
1583        .into_iter()
1584        .filter(|item| matches!(item.state, PendingState::Open) && !item.id.is_empty())
1585        .filter(|item| item_has_enqueue_marker(&item.text))
1586        .filter(|item| !item_precondition_unmet(&item.text, today))
1587        .map(|item| item.id.clone())
1588        .collect()
1589}
1590
1591/// Rank an item's priority from a `priority=<1..9>` token anywhere in its text
1592/// (`#backlog-priority-attribute`). `1` is the highest priority and sorts first;
1593/// `9` is the lowest numbered priority. An item with no valid `priority=` token
1594/// ranks `10` — below every numbered item — so unprioritized items sort last
1595/// while preserving their authored relative order under a stable sort.
1596pub const PRIORITY_RANK_UNSET: u8 = 10;
1597
1598pub fn item_priority_rank(text: &str) -> u8 {
1599    for token in text.split_whitespace() {
1600        if let Some(value) = token.strip_prefix("priority=")
1601            && let Ok(n) = value.parse::<u8>()
1602            && (1..=9).contains(&n)
1603        {
1604            return n;
1605        }
1606    }
1607    PRIORITY_RANK_UNSET
1608}
1609
1610/// Active (open) backlog item ids paired with their priority rank, in document
1611/// order. Used to priority-order a synced `agent:queue` (`#backlog-priority-attribute`).
1612pub fn active_item_priorities(body: &str) -> Vec<(String, u8)> {
1613    let today = today_civil_day();
1614    PendingLayout::parse(body)
1615        .items()
1616        .into_iter()
1617        .filter(|item| matches!(item.state, PendingState::Open) && !item.id.is_empty())
1618        .filter(|item| !item_precondition_unmet(&item.text, today))
1619        .map(|item| (item.id.clone(), item_priority_rank(&item.text)))
1620        .collect()
1621}
1622
1623/// Parse a `not-before=YYYY-MM-DD` scheduling precondition from an item's text
1624/// (`#backlog-not-before`). Returns the threshold as a day number (days since
1625/// 1970-01-01) so callers can compare it against [`today_civil_day`]. The token
1626/// must start at a word boundary so prose does not match, and only a strict
1627/// zero-or-more-digit `YYYY-MM-DD` is accepted; a malformed value is ignored (the
1628/// item is then treated as having no precondition rather than silently held).
1629pub fn item_not_before_day(text: &str) -> Option<i64> {
1630    const TOK: &str = "not-before=";
1631    let mut search = 0;
1632    while let Some(rel) = text[search..].find(TOK) {
1633        let idx = search + rel;
1634        let boundary_ok = idx == 0
1635            || !text[..idx]
1636                .chars()
1637                .next_back()
1638                .map(|c| c.is_ascii_alphanumeric() || c == '-')
1639                .unwrap_or(false);
1640        search = idx + TOK.len();
1641        if !boundary_ok {
1642            continue;
1643        }
1644        let value: String = text[search..]
1645            .chars()
1646            .take_while(|c| c.is_ascii_digit() || *c == '-')
1647            .collect();
1648        if let Some(day) = parse_ymd_to_civil_day(&value) {
1649            return Some(day);
1650        }
1651    }
1652    None
1653}
1654
1655fn parse_ymd_to_civil_day(value: &str) -> Option<i64> {
1656    let parts: Vec<&str> = value.split('-').collect();
1657    if parts.len() != 3 {
1658        return None;
1659    }
1660    let y: i64 = parts[0].parse().ok()?;
1661    let m: u32 = parts[1].parse().ok()?;
1662    let d: u32 = parts[2].parse().ok()?;
1663    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
1664        return None;
1665    }
1666    Some(days_from_civil(y, m, d))
1667}
1668
1669/// Days from 1970-01-01 for a proleptic Gregorian calendar date (Howard
1670/// Hinnant's `days_from_civil`). Negative before the epoch.
1671fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
1672    let y = if m <= 2 { y - 1 } else { y };
1673    let era = (if y >= 0 { y } else { y - 399 }) / 400;
1674    let yoe = y - era * 400;
1675    let mp = ((m + 9) % 12) as i64;
1676    let doy = (153 * mp + 2) / 5 + d as i64 - 1;
1677    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1678    era * 146097 + doe - 719468
1679}
1680
1681/// Today as a day number (days since 1970-01-01, UTC) from the system clock,
1682/// for evaluating `not-before=` scheduling preconditions. Best-effort: a clock
1683/// before the epoch clamps to day 0.
1684pub fn today_civil_day() -> i64 {
1685    use std::time::{SystemTime, UNIX_EPOCH};
1686    SystemTime::now()
1687        .duration_since(UNIX_EPOCH)
1688        .map(|d| (d.as_secs() / 86_400) as i64)
1689        .unwrap_or(0)
1690}
1691
1692/// True when an item's `not-before=` scheduling precondition is still in the
1693/// future relative to `today` (so it must be held out of the backlog→queue
1694/// sync). An item with no `not-before=` token, or with a threshold on/<= `today`,
1695/// is eligible (`#backlog-not-before`).
1696pub fn item_precondition_unmet(text: &str, today: i64) -> bool {
1697    item_not_before_day(text).is_some_and(|day| day > today)
1698}
1699
1700/// Parse `after=#id` dependency tokens from an item's text
1701/// (`#queue-auto-dag-priority`). Declares that this item must be ordered *after*
1702/// each named id. Accepts repeated tokens and comma lists: `after=#a`,
1703/// `after=#a,#b`, `after=#a after=#b`. Ids are normalized lowercase with
1704/// `#`/`[`/`]` stripped; only valid pending ids are kept. The `after=` token must
1705/// start at a word boundary so prose like `hereafter=` does not match.
1706pub fn item_after_deps(text: &str) -> Vec<String> {
1707    let mut deps = Vec::new();
1708    let bytes = text.as_bytes();
1709    let mut search = 0;
1710    while let Some(rel) = text[search..].find("after=") {
1711        let idx = search + rel;
1712        search = idx + "after=".len();
1713        if idx > 0 && bytes[idx - 1].is_ascii_alphanumeric() {
1714            continue;
1715        }
1716        let value = &text[search..];
1717        let chunk: String = value
1718            .chars()
1719            .take_while(|c| {
1720                c.is_ascii_alphanumeric() || matches!(c, '#' | '-' | '_' | ',' | '[' | ']')
1721            })
1722            .collect();
1723        for part in chunk.split(',') {
1724            let id = part
1725                .trim()
1726                .trim_matches(|c| matches!(c, '#' | '[' | ']'))
1727                .to_ascii_lowercase();
1728            if !id.is_empty() && is_valid_pending_id(&id) {
1729                deps.push(id);
1730            }
1731        }
1732    }
1733    deps
1734}
1735
1736/// Active (open) backlog item ids paired with their `after=#id` dependency ids,
1737/// in document order. Feeds the auto-dag queue ordering (`#queue-auto-dag-priority`).
1738pub fn active_item_after_deps(body: &str) -> Vec<(String, Vec<String>)> {
1739    PendingLayout::parse(body)
1740        .items()
1741        .into_iter()
1742        .filter(|item| matches!(item.state, PendingState::Open) && !item.id.is_empty())
1743        .map(|item| (item.id.clone(), item_after_deps(&item.text)))
1744        .collect()
1745}
1746
1747/// Author-controlled execution-context tag literals on a backlog item
1748/// (`#goqueuestall`). They live as bracketed tokens in the item text alongside
1749/// markers like `[recommended]` / `[TOP]`, and gate whether a `go`-mode queue
1750/// may auto-drain the item in the current session type.
1751pub const CLEAN_SESSION_TAG: &str = "[clean-session]";
1752pub const OPERATOR_VERIFY_TAG: &str = "[operator-verify]";
1753pub const FOCUSED_CYCLE_TAG: &str = "[focused-cycle]";
1754
1755/// Machine-readable execution-context attributes parsed from a backlog item's
1756/// text (`#goqueuestall`).
1757///
1758/// - `clean_session_required` — `[clean-session]`: the item must run from a
1759///   session WITHOUT a live editor-IPC listener; running it under a live IPC
1760///   listener risks closeout corruption, so go-mode skips it while live. NOTE
1761///   (`#qcontdrain`): `[clean-session]` items now DRAIN IN PLACE in the
1762///   in-session loop, so this flag no longer defers the continuation decision.
1763/// - `operator_verify_required` — `[operator-verify]`: completion needs
1764///   operator-driven live verification the agent cannot perform, so go-mode
1765///   never auto-drains it.
1766/// - `focused_cycle_required` — `[focused-cycle]` (`#qstallguard` Layer A): the
1767///   OPERATOR has declared that this item needs its own dedicated, operator-
1768///   initiated cycle (e.g. merge-core / supervisor-core work that needs
1769///   `make tmux-ci` across live panes), so it must NOT be auto-drained inside
1770///   the queue loop even though the agent could perform the work. This is the
1771///   ONLY sanctioned way to mark an agent-doable item non-loop-drainable: it
1772///   moves the "this needs a focused cycle" judgment OUT of the agent's prose
1773///   reading (where it became a stall excuse) and INTO a binary-read tag. The
1774///   agent must never re-derive non-drainability from an item's description —
1775///   absent this tag, a drainable head is drained.
1776#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1777pub struct ExecutionContext {
1778    pub clean_session_required: bool,
1779    pub operator_verify_required: bool,
1780    pub focused_cycle_required: bool,
1781}
1782
1783impl ExecutionContext {
1784    /// True when at least one deferral tag is present.
1785    pub fn is_deferred(&self) -> bool {
1786        self.clean_session_required || self.operator_verify_required || self.focused_cycle_required
1787    }
1788
1789    /// True when the item is undrainable by the IN-SESSION queue loop — it needs a
1790    /// human (`[operator-verify]`) or a dedicated, freshly-cleared cycle
1791    /// (`[focused-cycle]`). `[clean-session]` is intentionally EXCLUDED: it
1792    /// drains in place (`#qcontdrain`). This is the single authority for
1793    /// "the in-session loop must not auto-drain this head" (`#qstallguard` Layer A).
1794    pub fn loop_undrainable(&self) -> bool {
1795        self.operator_verify_required || self.focused_cycle_required
1796    }
1797
1798    /// True when the item is undrainable even by the SUPERVISOR clear-and-continue
1799    /// drain — it needs a human (`[operator-verify]`). Unlike
1800    /// [`Self::loop_undrainable`], `[focused-cycle]` is NOT supervisor-undrainable
1801    /// (`#qfocsup`): the supervisor idle-watch force-`/clear`s the session and
1802    /// re-dispatches a `[focused-cycle]` head to a genuinely fresh context, which
1803    /// is exactly the fresh cycle the tag demands. So a `[focused-cycle]` item is
1804    /// deferred by the in-session loop (which cannot give it fresh context) but
1805    /// DRAINED by the supervisor's clear-and-continue path instead of stranding the
1806    /// queue idle. `[clean-session]` drains everywhere.
1807    pub fn supervisor_undrainable(&self) -> bool {
1808        self.operator_verify_required
1809    }
1810}
1811
1812/// True when the item text carries a `[clean-session]` tag token
1813/// (`#goqueuestall`). Matched case-insensitively as a whitespace-delimited
1814/// bracketed token so prose mentions of the word do not trip it.
1815pub fn item_clean_session_required(text: &str) -> bool {
1816    text_has_bracket_tag(text, "clean-session")
1817}
1818
1819/// True when the item text carries an `[operator-verify]` tag token
1820/// (`#goqueuestall`).
1821pub fn item_operator_verify_required(text: &str) -> bool {
1822    text_has_bracket_tag(text, "operator-verify")
1823}
1824
1825/// True when the item text carries a `[focused-cycle]` tag token
1826/// (`#qstallguard` Layer A).
1827pub fn item_focused_cycle_required(text: &str) -> bool {
1828    text_has_bracket_tag(text, "focused-cycle")
1829}
1830
1831/// Parse all execution-context tags from an item's text (`#goqueuestall` /
1832/// `#qstallguard`).
1833pub fn item_execution_context(text: &str) -> ExecutionContext {
1834    ExecutionContext {
1835        clean_session_required: item_clean_session_required(text),
1836        operator_verify_required: item_operator_verify_required(text),
1837        focused_cycle_required: item_focused_cycle_required(text),
1838    }
1839}
1840
1841fn text_has_bracket_tag(text: &str, tag: &str) -> bool {
1842    text.split_whitespace().any(|token| {
1843        let trimmed = token.trim_matches(|c: char| matches!(c, '(' | ')' | ',' | '.' | ';' | ':'));
1844        trimmed.len() == tag.len() + 2
1845            && trimmed.starts_with('[')
1846            && trimmed.ends_with(']')
1847            && trimmed[1..trimmed.len() - 1].eq_ignore_ascii_case(tag)
1848    })
1849}
1850
1851/// Remove the `[clean-session]` / `[operator-verify]` execution-context tags
1852/// from display text (`#goqueuestall`), collapsing the whitespace they leave
1853/// behind. Mirrors how machine-only markers are kept out of rendered prose.
1854pub fn strip_execution_context_tags(text: &str) -> String {
1855    let kept: Vec<&str> = text
1856        .split_whitespace()
1857        .filter(|token| {
1858            let trimmed =
1859                token.trim_matches(|c: char| matches!(c, '(' | ')' | ',' | '.' | ';' | ':'));
1860            !(trimmed.eq_ignore_ascii_case(CLEAN_SESSION_TAG)
1861                || trimmed.eq_ignore_ascii_case(OPERATOR_VERIFY_TAG)
1862                || trimmed.eq_ignore_ascii_case(FOCUSED_CYCLE_TAG))
1863        })
1864        .collect();
1865    kept.join(" ")
1866}
1867
1868/// Active (open) backlog item ids paired with their parsed execution context,
1869/// in document order (`#goqueuestall`). Feeds the go-mode backlog→queue sync
1870/// skip and the queue-continuation drainable-head computation.
1871pub fn active_item_execution_contexts(body: &str) -> Vec<(String, ExecutionContext)> {
1872    PendingLayout::parse(body)
1873        .items()
1874        .into_iter()
1875        .filter(|item| matches!(item.state, PendingState::Open) && !item.id.is_empty())
1876        .map(|item| (item.id.clone(), item_execution_context(&item.text)))
1877        .collect()
1878}
1879
1880/// Stable-sort the item lines of a pending body by per-item priority
1881/// (`#backlog-priority-attribute`), preserving every non-item segment (blank
1882/// lines, prose, ordered-list separators) at its original position. Returns
1883/// `Some(new_body)` when the order changes, `None` otherwise (idempotent).
1884pub fn sort_by_priority(body: &str) -> Option<String> {
1885    let layout = PendingLayout::parse(body);
1886    let positions: Vec<usize> = layout
1887        .segments
1888        .iter()
1889        .enumerate()
1890        .filter_map(|(i, s)| matches!(s, PendingSegment::Item { .. }).then_some(i))
1891        .collect();
1892    if positions.len() < 2 {
1893        return None;
1894    }
1895    let mut slots: Vec<(usize, PendingItem)> = positions
1896        .iter()
1897        .enumerate()
1898        .map(|(slot, &pos)| match &layout.segments[pos] {
1899            PendingSegment::Item { item, .. } => (slot, item.clone()),
1900            _ => unreachable!(),
1901        })
1902        .collect();
1903    let before: Vec<usize> = slots.iter().map(|(slot, _)| *slot).collect();
1904    slots.sort_by_key(|(_, item)| item_priority_rank(&item.text));
1905    let after: Vec<usize> = slots.iter().map(|(slot, _)| *slot).collect();
1906    if before == after {
1907        return None;
1908    }
1909    let mut segments = layout.segments.clone();
1910    for (target, &pos) in positions.iter().enumerate() {
1911        let item = slots[target].1.clone();
1912        if let PendingSegment::Item { has_newline, .. } = layout.segments[pos] {
1913            segments[pos] = PendingSegment::Item { item, has_newline };
1914        }
1915    }
1916    Some(PendingLayout { segments }.render())
1917}
1918
1919pub fn extract_pending_ids_from_text(text: &str) -> HashSet<String> {
1920    let mut ids = HashSet::new();
1921    let mut rest = text;
1922    while let Some(start) = rest.find("[#") {
1923        let after = &rest[start + 2..];
1924        let Some(end) = after.find(']') else {
1925            break;
1926        };
1927        let id = &after[..end];
1928        if is_valid_pending_id(id) {
1929            ids.insert(id.to_ascii_lowercase());
1930        }
1931        rest = &after[end + 1..];
1932    }
1933    ids
1934}
1935
1936/// Extract only each done/archive **item's own** `#id` — the FIRST `[#id]` on a
1937/// list-item line (`- <date> [#id] …` / `- [x] [#id] …`) — and ignore any other
1938/// `[#id]` cited in that item's prose.
1939///
1940/// `#donemirrorreap`: the whole-text [`extract_pending_ids_from_text`] harvests
1941/// every bracketed id anywhere in the text, so a `[#other]` cited inside one done
1942/// entry's body (e.g. "behind do `[#fullboundary]`" inside the `#ftstrike` entry)
1943/// is wrongly treated as done — which then falsely reaps a still-open `#other`
1944/// review/backlog mirror. An item's identity is its leading id, never a citation
1945/// in its description, so done-id collection must use this per-item extractor.
1946pub fn extract_done_item_own_ids(text: &str) -> HashSet<String> {
1947    let mut ids = HashSet::new();
1948    for line in text.lines() {
1949        let trimmed = line.trim_start();
1950        // Only list-item lines carry an item's own id; prose / continuation lines
1951        // contribute none (a cited id there is a reference, not an identity).
1952        if !(trimmed.starts_with("- ") || trimmed.starts_with("* ")) {
1953            continue;
1954        }
1955        // The item's own id is the FIRST bracketed id on the line; a leading
1956        // checkbox (`[x]` / `[ ]` / `[/]`) has no `#` so `find("[#")` skips it.
1957        if let Some(start) = trimmed.find("[#") {
1958            let after = &trimmed[start + 2..];
1959            if let Some(end) = after.find(']') {
1960                let id = &after[..end];
1961                if is_valid_pending_id(id) {
1962                    ids.insert(id.to_ascii_lowercase());
1963                }
1964            }
1965        }
1966    }
1967    ids
1968}
1969
1970/// Generate a stable 4-char base32 hash from `(text, doc_id, counter)`.
1971///
1972/// Backward-compat thin wrapper over [`generate_hash_n`] at width 4. Existing
1973/// docs keep their 4-char IDs on re-backfill because width-4 output is
1974/// bit-identical to the original formula. Kept in the public API for
1975/// backward compatibility and as the canonical entry point for width-4
1976/// hashing in tests.
1977#[allow(dead_code)]
1978pub fn generate_hash(text: &str, doc_id: &str, counter: u64) -> String {
1979    generate_hash_n(text, doc_id, counter, 4)
1980}
1981
1982/// Generate a stable variable-width base32 hash. `width` is clamped to `[4, 8]`
1983/// — the spec §1 ceiling on collision extension.
1984///
1985/// Width-4 output is bit-identical to the pre-#14z4 `generate_hash` formula,
1986/// so lazy backfill on existing docs is a no-op.
1987pub fn generate_hash_n(text: &str, doc_id: &str, counter: u64, width: usize) -> String {
1988    use sha2::{Digest, Sha256};
1989    let mut hasher = Sha256::new();
1990    hasher.update(text.as_bytes());
1991    hasher.update(b":");
1992    hasher.update(doc_id.as_bytes());
1993    hasher.update(b":");
1994    hasher.update(counter.to_le_bytes());
1995    let digest = hasher.finalize();
1996
1997    // Base32 lowercase alphabet (no 0/1/8/9 per crockford would complicate collisions;
1998    // stick with full a-z/0-9 subset for simplicity).
1999    const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz"; // 32 chars
2000
2001    let width = width.clamp(4, 8);
2002    let mut out = String::with_capacity(width);
2003
2004    // First 4 chars: preserve the original bit packing so width-4 output is
2005    // stable across the #14z4 refactor. (bottom 20 bits of b0<<16 | b1<<8 | b2)
2006    let b0 = digest[0] as u32;
2007    let b1 = digest[1] as u32;
2008    let b2 = digest[2] as u32;
2009    let v: u32 = (b0 << 16) | (b1 << 8) | b2;
2010    out.push(ALPHABET[((v >> 15) & 0x1f) as usize] as char);
2011    out.push(ALPHABET[((v >> 10) & 0x1f) as usize] as char);
2012    out.push(ALPHABET[((v >> 5) & 0x1f) as usize] as char);
2013    out.push(ALPHABET[(v & 0x1f) as usize] as char);
2014
2015    // Extra chars (5..=8): draw from bytes 3..=5 of the digest. Same 5-bit
2016    // packing layout, starting from a fresh 24-bit window so layer N+1 is not
2017    // a mechanical continuation of layer N (prevents widening from aliasing
2018    // to a near-neighbor that also collides).
2019    if width > 4 {
2020        let e0 = digest[3] as u32;
2021        let e1 = digest[4] as u32;
2022        let e2 = digest[5] as u32;
2023        let extra: u32 = (e0 << 16) | (e1 << 8) | e2;
2024        for i in 0..(width - 4) {
2025            let shift = 15 - (i as u32) * 5;
2026            out.push(ALPHABET[((extra >> shift) & 0x1f) as usize] as char);
2027        }
2028    }
2029    out
2030}
2031
2032/// Assign a hash id that does not collide with `taken`.
2033///
2034/// Starts at width 4 and extends up to the spec §1 ceiling of 8. Counter
2035/// cycles within each width before widening — at width 4 that's ~1M values
2036/// before we touch width 5, so normal docs never widen in practice.
2037fn assign_unique_hash(text: &str, doc_id: &str, taken: &HashSet<String>) -> String {
2038    // Per-width retry budget: small because a single widening step gives
2039    // another 5 bits of entropy, which is a much bigger win than continuing
2040    // to spin at the old width.
2041    const RETRIES_PER_WIDTH: u64 = 4;
2042    let mut counter: u64 = 0;
2043    loop {
2044        // width = 4 + (counter / 4), clamped at 8 (spec §1 ceiling).
2045        // Once we hit width 8, keep spinning counter forever — at 40 bits of
2046        // entropy a further collision is effectively impossible in practice.
2047        let width = std::cmp::min(4 + (counter / RETRIES_PER_WIDTH) as usize, 8);
2048        let id = generate_hash_n(text, doc_id, counter, width);
2049        if !taken.contains(&id) {
2050            return id;
2051        }
2052        counter = counter.saturating_add(1);
2053    }
2054}
2055
2056/// Assign a nested subtask id using the parent id as a visible prefix.
2057///
2058/// Shape: `<parent_id>-<suffix>`, where `suffix` is a stable 4..=8 char hash.
2059fn assign_unique_child_hash(
2060    parent_id: &str,
2061    text: &str,
2062    doc_id: &str,
2063    taken: &HashSet<String>,
2064) -> String {
2065    const RETRIES_PER_WIDTH: u64 = 4;
2066    let seed = format!("{parent_id}:{text}");
2067    let mut counter: u64 = 0;
2068    loop {
2069        let width = std::cmp::min(4 + (counter / RETRIES_PER_WIDTH) as usize, 8);
2070        let suffix = generate_hash_n(&seed, doc_id, counter, width);
2071        let candidate = format!("{parent_id}-{suffix}");
2072        if !taken.contains(&candidate) {
2073            return candidate;
2074        }
2075        counter = counter.saturating_add(1);
2076    }
2077}
2078
2079fn split_indent(line: &str) -> (&str, &str) {
2080    let idx = line
2081        .char_indices()
2082        .find_map(|(idx, ch)| (!ch.is_whitespace()).then_some(idx))
2083        .unwrap_or(line.len());
2084    line.split_at(idx)
2085}
2086
2087fn normalize_nested_subtasks(
2088    continuation: &str,
2089    parent_id: &str,
2090    doc_id: &str,
2091    taken: &mut HashSet<String>,
2092) -> (String, bool) {
2093    if continuation.is_empty() {
2094        return (String::new(), false);
2095    }
2096
2097    let mut changed = false;
2098    let mut out = String::with_capacity(continuation.len());
2099    for raw_line in continuation.split_inclusive('\n') {
2100        let has_newline = raw_line.ends_with('\n');
2101        let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
2102        if !is_indented_continuation_line(line) {
2103            out.push_str(raw_line);
2104            continue;
2105        }
2106
2107        let (indent, trimmed) = split_indent(line);
2108        let Some(mut child) = parse_item_line(trimmed) else {
2109            out.push_str(raw_line);
2110            continue;
2111        };
2112
2113        let duplicate_existing_id = !child.id.is_empty() && taken.contains(&child.id);
2114        if child.id.is_empty() || duplicate_existing_id {
2115            child.id = assign_unique_child_hash(parent_id, &child.text, doc_id, taken);
2116            taken.insert(child.id.clone());
2117            changed = true;
2118        } else {
2119            taken.insert(child.id.clone());
2120        }
2121
2122        let rendered = child.render();
2123        if rendered != trimmed {
2124            changed = true;
2125        }
2126        out.push_str(indent);
2127        out.push_str(&rendered);
2128        if has_newline {
2129            out.push('\n');
2130        }
2131    }
2132    (out, changed)
2133}
2134
2135/// Lazy backfill: ensure every item has a hash id and a checkbox.
2136///
2137/// - Items missing a hash get a new one (guaranteed unique within the component).
2138/// - Checkboxes are normalized (default `[ ]`).
2139/// - Returns `(new_body, changed)`. `changed = false` when the body was already canonical.
2140pub fn backfill(body: &str, doc_id: &str, existing_ids: &HashSet<String>) -> (String, bool) {
2141    let layout = PendingLayout::parse(body);
2142    let items = layout.items();
2143    let mut taken: HashSet<String> = existing_ids.clone();
2144    for item in &items {
2145        if !item.id.is_empty() {
2146            taken.insert(item.id.clone());
2147        }
2148    }
2149
2150    let mut changed = false;
2151    let rewritten = layout.replace_items(|item| {
2152        // Drop content-less items: a bullet with no description text and no
2153        // continuation carries no information (typically a stray empty `- [ ]`
2154        // from an editor/IPC insertion). Backfilling it would manufacture a
2155        // phantom `[#hash]` id for an empty line — the "description disappeared"
2156        // bug (#icebox-empty-item-phantom-id) — so remove it instead. This also
2157        // self-heals an already-cemented id-only empty item (`- [ ] [#hash]`).
2158        if item.text.trim().is_empty() && item.continuation.trim().is_empty() {
2159            changed = true;
2160            return None;
2161        }
2162        let mut next = item.clone();
2163        if next.id.is_empty() {
2164            let id = assign_unique_hash(&next.text, doc_id, &taken);
2165            taken.insert(id.clone());
2166            next.id = id;
2167            changed = true;
2168        }
2169        let (continuation, nested_changed) =
2170            normalize_nested_subtasks(&next.continuation, &next.id, doc_id, &mut taken);
2171        if nested_changed {
2172            next.continuation = continuation;
2173            changed = true;
2174        }
2175        Some(next)
2176    });
2177
2178    let new_body = rewritten.render();
2179
2180    // Also mark as changed when the canonical render differs from the input
2181    // (e.g., legacy whitespace / missing checkbox normalization).
2182    if new_body != body {
2183        changed = true;
2184    }
2185    (new_body, changed)
2186}
2187
2188/// Reap `[x]` items. `[/]` (gated) items are never reaped.
2189/// Returns `(new_body, removed_ids)`.
2190#[allow(dead_code)]
2191pub fn reap(body: &str) -> Result<(String, Vec<String>)> {
2192    let (new_body, removed) = reap_with_items(body)?;
2193    let ids = removed.iter().map(|i| i.id.clone()).collect();
2194    Ok((new_body, ids))
2195}
2196
2197/// Reap `[x]` items and return the removed items (with text), not just ids.
2198/// Used by preflight to archive reaped items to an `agent:done` component.
2199pub fn reap_with_items(body: &str) -> Result<(String, Vec<PendingItem>)> {
2200    let layout = PendingLayout::parse(body);
2201    let items = layout.items();
2202    let missing_done: Vec<&PendingItem> = items
2203        .iter()
2204        .filter(|item| item.is_done() && item.id.is_empty())
2205        .collect();
2206    if !missing_done.is_empty() {
2207        let refs = missing_done
2208            .iter()
2209            .map(|item| format!("\"{}\"", item.text))
2210            .collect::<Vec<_>>()
2211            .join(", ");
2212        bail!(
2213            "pending reap requires ids for completed items; run backfill first (missing ids on done item(s): {})",
2214            refs
2215        );
2216    }
2217    let mut removed = Vec::new();
2218    let mut segments = Vec::with_capacity(layout.segments.len());
2219    let mut index = 0usize;
2220    while index < layout.segments.len() {
2221        match &layout.segments[index] {
2222            PendingSegment::Text(raw) => {
2223                segments.push(PendingSegment::Text(raw.clone()));
2224                index += 1;
2225            }
2226            PendingSegment::Item { item, has_newline } => {
2227                if !item.is_done() {
2228                    segments.push(PendingSegment::Item {
2229                        item: item.clone(),
2230                        has_newline: *has_newline,
2231                    });
2232                    index += 1;
2233                    continue;
2234                }
2235
2236                let mut removed_item = item.clone();
2237                let mut next_index = index + 1;
2238                while let Some(PendingSegment::Text(raw)) = layout.segments.get(next_index) {
2239                    if let Some((reap_text, keep_text)) = split_reapable_trailing_text_segment(raw)
2240                    {
2241                        removed_item.continuation.push_str(&reap_text);
2242                        next_index += 1;
2243                        if !keep_text.is_empty() {
2244                            segments.push(PendingSegment::Text(keep_text));
2245                            break;
2246                        }
2247                    } else {
2248                        segments.push(PendingSegment::Text(raw.clone()));
2249                        next_index += 1;
2250                        break;
2251                    }
2252                }
2253                if !removed_item.id.is_empty() {
2254                    removed.push(removed_item);
2255                }
2256                index = next_index;
2257            }
2258        }
2259    }
2260    let new_layout = PendingLayout { segments };
2261    if removed.is_empty() {
2262        return Ok((body.to_string(), removed));
2263    }
2264    let new_body = new_layout.render();
2265    Ok((new_body, removed))
2266}
2267
2268/// Remove specific tracked items by id while preserving surrounding non-item
2269/// structure in the source body. Returns `(remaining_body, moved_body, matched_ids)`.
2270pub fn extract_items_by_id(body: &str, ids: &[String]) -> Result<(String, String, Vec<String>)> {
2271    let requested: Vec<String> = ids.iter().map(|id| id.trim().to_lowercase()).collect();
2272    let mut matched_ids = Vec::new();
2273    let mut moved_items = Vec::new();
2274    let remaining = PendingLayout::parse(body).replace_items(|item| {
2275        if requested.contains(&item.id) {
2276            matched_ids.push(item.id.clone());
2277            moved_items.push(item.clone());
2278            None
2279        } else {
2280            Some(item.clone())
2281        }
2282    });
2283    let moved_body = render_items("", &moved_items, "");
2284    Ok((remaining.render(), moved_body, matched_ids))
2285}
2286
2287/// Detect reorder: returns `Some(current_order)` when id-sets match but order differs.
2288/// Returns `None` when id-sets differ or order is identical.
2289pub fn detect_reorder(snapshot_body: &str, current_body: &str) -> Option<Vec<String>> {
2290    let (_, snap_items, _) = parse_items(snapshot_body);
2291    let (_, cur_items, _) = parse_items(current_body);
2292
2293    let snap_ids: Vec<String> = snap_items
2294        .iter()
2295        .filter(|i| !i.id.is_empty())
2296        .map(|i| i.id.clone())
2297        .collect();
2298    let cur_ids: Vec<String> = cur_items
2299        .iter()
2300        .filter(|i| !i.id.is_empty())
2301        .map(|i| i.id.clone())
2302        .collect();
2303
2304    if snap_ids.len() != cur_ids.len() {
2305        return None;
2306    }
2307    let snap_set: HashSet<&String> = snap_ids.iter().collect();
2308    let cur_set: HashSet<&String> = cur_ids.iter().collect();
2309    if snap_set != cur_set {
2310        return None;
2311    }
2312    if snap_ids == cur_ids {
2313        return None;
2314    }
2315    Some(cur_ids)
2316}
2317
2318/// Insert a new item at the beginning of the body. Binary assigns hash and `[ ]`
2319/// (or `[/]` if `gated`).
2320/// Returns `(new_body, assigned_id)`.
2321/// `#ah0s`: explicit insertion position for a newly added pending item. The
2322/// backlog is a priority-ordered pool with id-based consumption (not a stack or
2323/// queue), so position encodes author intent and is set explicitly when it
2324/// matters. `First` is the cheap default used by `--pending-add`.
2325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2326pub enum AddPosition<'a> {
2327    /// Insert at the front of the active list (default).
2328    First,
2329    /// Append at the tail of the active list (before any trailing text).
2330    Last,
2331    /// Insert immediately after the existing item with this id.
2332    After(&'a str),
2333    /// Insert immediately before the existing item with this id.
2334    Before(&'a str),
2335}
2336
2337pub fn op_add(body: &str, text: &str, doc_id: &str, gated: bool) -> Result<(String, String)> {
2338    op_add_at(body, text, doc_id, gated, AddPosition::First)
2339}
2340
2341pub fn op_add_with_outcome(
2342    body: &str,
2343    text: &str,
2344    doc_id: &str,
2345    gated: bool,
2346) -> Result<PendingAddOutcome> {
2347    op_add_at_with_outcome(body, text, doc_id, gated, AddPosition::First)
2348}
2349
2350/// Position-aware variant of [`op_add`] (`#ah0s`). Assigns/validates the id and
2351/// dedups exactly like `op_add`, then inserts the new item at `position`.
2352/// `After`/`Before` error if the anchor id is absent.
2353pub fn op_add_at(
2354    body: &str,
2355    text: &str,
2356    doc_id: &str,
2357    gated: bool,
2358    position: AddPosition<'_>,
2359) -> Result<(String, String)> {
2360    let outcome = op_add_at_with_outcome(body, text, doc_id, gated, position)?;
2361    Ok((outcome.body, outcome.id))
2362}
2363
2364/// Add a pending item and report whether it inserted a new item or attached
2365/// evidence to an existing symptom with the same de-duplication key.
2366pub fn op_add_at_with_outcome(
2367    body: &str,
2368    text: &str,
2369    doc_id: &str,
2370    gated: bool,
2371    position: AddPosition<'_>,
2372) -> Result<PendingAddOutcome> {
2373    let (custom_id, text) = parse_custom_id_prefix(text)?;
2374    let mut text = text.trim().to_string();
2375    if text.is_empty() {
2376        bail!("pending add: text must be non-empty");
2377    }
2378    if text.starts_with("[ ]")
2379        || text.starts_with("[/]")
2380        || text.starts_with("[x]")
2381        || text.starts_with("[X]")
2382    {
2383        bail!(
2384            "pending add: text must not start with a state marker ([ ], [/], [x]); use --pending-add-gated for gated items"
2385        );
2386    }
2387
2388    let mut inline_custom_id = None;
2389    if custom_id.is_none()
2390        && let Some((tag_id, cleaned)) = extract_inline_tag_id(&text)
2391    {
2392        inline_custom_id = Some(tag_id);
2393        text = cleaned;
2394    }
2395
2396    let mut layout = PendingLayout::parse(body);
2397    let items = layout.items();
2398
2399    if let Some(key) = symptom_dedupe_key_from_text(&text)?
2400        && let Some(existing) = items
2401            .iter()
2402            .find(|item| {
2403                item.state != PendingState::Done
2404                    && symptom_dedupe_key_from_text(&item.text)
2405                        .ok()
2406                        .flatten()
2407                        .as_ref()
2408                        == Some(&key)
2409            })
2410            .cloned()
2411    {
2412        let evidence = symptom_evidence_line(&text);
2413        let rewritten = layout.replace_items(|item| {
2414            if item.id == existing.id {
2415                let mut next = item.clone();
2416                next.continuation = append_unique_continuation_line(&next.continuation, &evidence);
2417                Some(next)
2418            } else {
2419                Some(item.clone())
2420            }
2421        });
2422        return Ok(PendingAddOutcome {
2423            body: rewritten.render(),
2424            id: existing.id,
2425            inserted: false,
2426            deduped_key: Some(key),
2427        });
2428    }
2429
2430    // Dedup: reject if an item with identical text already exists.
2431    if items.iter().any(|i| i.text == text) {
2432        bail!("pending add: duplicate item text already exists: {}", text);
2433    }
2434
2435    let mut taken: HashSet<String> = items
2436        .iter()
2437        .filter(|i| !i.id.is_empty())
2438        .map(|i| i.id.clone())
2439        .collect();
2440
2441    let id = if let Some(custom_id) = custom_id {
2442        if taken.contains(&custom_id) {
2443            bail!("pending add: custom id already exists: {}", custom_id);
2444        }
2445        custom_id
2446    } else if let Some(inline_id) = inline_custom_id {
2447        if taken.contains(&inline_id) {
2448            bail!("pending add: inline tag id already exists: {}", inline_id);
2449        }
2450        inline_id
2451    } else {
2452        assign_unique_hash(&text, doc_id, &taken)
2453    };
2454    taken.insert(id.clone());
2455
2456    let new_item = PendingItem {
2457        marker: PendingListMarker::Bullet,
2458        id: id.clone(),
2459        state: if gated {
2460            PendingState::Gated
2461        } else {
2462            PendingState::Open
2463        },
2464        gate_type: None,
2465        text,
2466        continuation: String::new(),
2467    };
2468    match position {
2469        AddPosition::First => layout.insert_first_item(new_item),
2470        AddPosition::Last => {
2471            let index = layout.last_item_index_plus_one();
2472            layout.insert_item_at(index, new_item);
2473        }
2474        AddPosition::After(anchor) => {
2475            let anchor = normalize_pending_id(anchor);
2476            let Some(idx) = layout.item_segment_index(&anchor) else {
2477                bail!("pending add: anchor id not found for --pending-add-after: {anchor}");
2478            };
2479            layout.insert_item_at(idx + 1, new_item);
2480        }
2481        AddPosition::Before(anchor) => {
2482            let anchor = normalize_pending_id(anchor);
2483            let Some(idx) = layout.item_segment_index(&anchor) else {
2484                bail!("pending add: anchor id not found for --pending-add-before: {anchor}");
2485            };
2486            layout.insert_item_at(idx, new_item);
2487        }
2488    }
2489    Ok(PendingAddOutcome {
2490        body: layout.render(),
2491        id,
2492        inserted: true,
2493        deduped_key: None,
2494    })
2495}
2496
2497/// Mark an item `[x]` by id. Phase 1: state-machine validation lives in
2498/// the upcoming `pending_cmd` layer; this primitive forces Done unconditionally.
2499pub fn op_done(body: &str, id: &str) -> Result<String> {
2500    let id = normalize_pending_id(id);
2501    let mut found = false;
2502    let layout = PendingLayout::parse(body).replace_items(|item| {
2503        if item.id == id {
2504            found = true;
2505            let mut next = item.clone();
2506            next.state = PendingState::Done;
2507            next.gate_type = None;
2508            Some(next)
2509        } else {
2510            Some(item.clone())
2511        }
2512    });
2513    if !found {
2514        return Err(anyhow!("pending done: no item with id [#{}]", id));
2515    }
2516    Ok(layout.render())
2517}
2518
2519/// Transition an item to `Gated` (`[/]`) by id.
2520///
2521/// - `Open → Gated`: state mutates.
2522/// - `Gated → Gated`: idempotent no-op (returns body unchanged).
2523/// - `Done → *`: error (cannot re-gate a completed item).
2524pub fn op_gate(body: &str, id: &str) -> Result<String> {
2525    let id = normalize_pending_id(id);
2526    let layout = PendingLayout::parse(body);
2527    let current = layout
2528        .items()
2529        .into_iter()
2530        .find(|item| item.id == id)
2531        .ok_or_else(|| anyhow!("pending gate: no item with id [#{}]", id))?;
2532    let transition = validate_transition(current.state, PendingOp::Gate)?;
2533    let mut found = false;
2534    let mut changed = false;
2535    let rewritten = layout.replace_items(|item| {
2536        if item.id != id {
2537            return Some(item.clone());
2538        }
2539        found = true;
2540        match transition {
2541            TransitionResult::Transition(next) => {
2542                changed = true;
2543                let mut next_item = item.clone();
2544                next_item.state = next;
2545                Some(next_item)
2546            }
2547            TransitionResult::NoOp => Some(item.clone()),
2548        }
2549    });
2550    debug_assert!(found, "validated item must be present during replacement");
2551    if !changed {
2552        Ok(body.to_string())
2553    } else {
2554        Ok(rewritten.render())
2555    }
2556}
2557
2558/// Transition an item to `Open` (`[ ]`) by id.
2559///
2560/// - `Gated → Open`: state mutates.
2561/// - `Open → *`: error (no source `[/]`).
2562/// - `Done → *`: error (cannot ungate a completed item).
2563pub fn op_ungate(body: &str, id: &str) -> Result<String> {
2564    let id = normalize_pending_id(id);
2565    let layout = PendingLayout::parse(body);
2566    let current = layout
2567        .items()
2568        .into_iter()
2569        .find(|item| item.id == id)
2570        .ok_or_else(|| anyhow!("pending ungate: no item with id [#{}]", id))?;
2571    let transition = validate_transition(current.state, PendingOp::Ungate)?;
2572    let mut found = false;
2573    let mut changed = false;
2574    let rewritten = layout.replace_items(|item| {
2575        if item.id != id {
2576            return Some(item.clone());
2577        }
2578        found = true;
2579        match transition {
2580            TransitionResult::Transition(next) => {
2581                changed = true;
2582                let mut next_item = item.clone();
2583                next_item.state = next;
2584                next_item.gate_type = None;
2585                Some(next_item)
2586            }
2587            TransitionResult::NoOp => Some(item.clone()),
2588        }
2589    });
2590    debug_assert!(found, "validated item must be present during replacement");
2591    if !changed {
2592        Ok(body.to_string())
2593    } else {
2594        Ok(rewritten.render())
2595    }
2596}
2597
2598/// Edit an item's text, preserving its hash id.
2599pub fn op_edit(body: &str, id: &str, new_text: &str) -> Result<String> {
2600    let new_text = new_text.trim();
2601    if new_text.is_empty() {
2602        bail!("pending edit: text must be non-empty");
2603    }
2604    let (parsed_text, parsed_continuation) = parse_pending_edit_payload(new_text)?;
2605    let id = normalize_pending_id(id);
2606    let mut found = false;
2607    let layout = PendingLayout::parse(body).replace_items(|item| {
2608        if item.id == id {
2609            found = true;
2610            let mut next = item.clone();
2611            next.text = parsed_text.clone();
2612            next.continuation = parsed_continuation.clone();
2613            Some(next)
2614        } else {
2615            Some(item.clone())
2616        }
2617    });
2618    if !found {
2619        return Err(anyhow!("pending edit: no item with id [#{}]", id));
2620    }
2621    Ok(layout.render())
2622}
2623
2624/// Clear all items from the body. Non-item lines, including headers, are preserved.
2625pub fn op_clear(body: &str) -> Result<String> {
2626    let cleared = PendingLayout::parse(body).replace_items(|_| None);
2627    Ok(cleared.render())
2628}
2629
2630/// Reorder items by id. Listed ids come first (in the given order); unlisted ids
2631/// keep their relative order and follow.
2632pub fn op_reorder(body: &str, ids: &[String]) -> Result<String> {
2633    let layout = PendingLayout::parse(body);
2634    let items = layout.items();
2635    let requested: Vec<String> = ids.iter().map(|s| normalize_pending_id(s)).collect();
2636    for id in &requested {
2637        if !items.iter().any(|i| i.id == *id) {
2638            bail!("pending reorder: no item with id [#{}]", id);
2639        }
2640    }
2641    let mut remaining: Vec<PendingItem> = items.clone();
2642    let mut ordered: Vec<PendingItem> = Vec::new();
2643    for id in &requested {
2644        if let Some(pos) = remaining.iter().position(|i| i.id == *id) {
2645            ordered.push(remaining.remove(pos));
2646        }
2647    }
2648    ordered.extend(remaining);
2649    let mut ordered_iter = ordered.into_iter();
2650    let reordered = layout.replace_items(|_| ordered_iter.next());
2651    Ok(reordered.render())
2652}
2653
2654/// Resolve all gated items matching a typed gate. Finds items with `[/<gate_type>]`
2655/// and flips them to `[x]`. Returns `(new_body, resolved_ids)`.
2656///
2657/// Only matches typed gates — untyped `[/]` items are never resolved by this op.
2658pub fn op_resolve_gate(body: &str, gate_type: &str) -> (String, Vec<String>) {
2659    let gt = gate_type.trim().to_lowercase();
2660    let mut resolved = Vec::new();
2661    let layout = PendingLayout::parse(body).replace_items(|item| {
2662        if item.state == PendingState::Gated && item.gate_type.as_deref() == Some(gt.as_str()) {
2663            let mut next = item.clone();
2664            next.state = PendingState::Done;
2665            next.gate_type = None;
2666            resolved.push(next.id.clone());
2667            Some(next)
2668        } else {
2669            Some(item.clone())
2670        }
2671    });
2672    (layout.render(), resolved)
2673}
2674
2675/// Set a typed gate on a gated item. The item must already be in `[/]` state.
2676/// Transitions `[/] → [/<gate_type>]`. Errors if the item is not gated.
2677pub fn op_set_gate_type(body: &str, id: &str, gate_type: &str) -> Result<String> {
2678    let id = normalize_pending_id(id);
2679    let gt = gate_type.trim().to_lowercase();
2680    if gt.is_empty()
2681        || !gt
2682            .chars()
2683            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
2684    {
2685        bail!("invalid gate type: must be alphanumeric/dash/underscore");
2686    }
2687    let layout = PendingLayout::parse(body);
2688    let current = layout
2689        .items()
2690        .into_iter()
2691        .find(|item| item.id == id)
2692        .ok_or_else(|| anyhow!("pending set-gate-type: no item with id [#{}]", id))?;
2693    if current.state != PendingState::Gated {
2694        bail!(
2695            "pending set-gate-type: item [#{}] must be gated ([/]) to set a typed gate, current state: [{}]",
2696            id,
2697            current.state.box_char()
2698        );
2699    }
2700    let mut found = false;
2701    let rewritten = layout.replace_items(|item| {
2702        if item.id != id {
2703            return Some(item.clone());
2704        }
2705        found = true;
2706        let mut next = item.clone();
2707        next.gate_type = Some(gt.clone());
2708        Some(next)
2709    });
2710    debug_assert!(found, "validated item must be present during replacement");
2711    Ok(rewritten.render())
2712}
2713
2714/// Set (or replace) a typed proof/disproof verify predicate on a gated item
2715/// (`#optverify` / `#optv1`). The item must already be in `[/]` state. The
2716/// predicate is stored as an inline `<!-- gate-verify ... -->` annotation in the
2717/// item text (see [`crate::gate_verify`]); `set_at` stamps the gate-set time so
2718/// the later ops.log scan only counts markers emitted at/after it.
2719///
2720/// Errors if the item is missing, not gated, or the spec carries no matcher.
2721pub fn op_set_gate_verify(body: &str, id: &str, spec: &str, set_at: u64) -> Result<String> {
2722    let id = normalize_pending_id(id);
2723    let mut predicate = crate::gate_verify::parse_predicate_spec(spec);
2724    if !predicate.is_actionable() {
2725        bail!(
2726            "pending set-verify: spec must include verify=... and/or disproof=..., got: {}",
2727            spec
2728        );
2729    }
2730    predicate.set_at = Some(set_at);
2731
2732    let layout = PendingLayout::parse(body);
2733    let current = layout
2734        .items()
2735        .into_iter()
2736        .find(|item| item.id == id)
2737        .ok_or_else(|| anyhow!("pending set-verify: no item with id [#{}]", id))?;
2738    if current.state != PendingState::Gated {
2739        bail!(
2740            "pending set-verify: item [#{}] must be gated ([/]) to set a verify predicate, current state: [{}]",
2741            id,
2742            current.state.box_char()
2743        );
2744    }
2745    let mut found = false;
2746    let rewritten = layout.replace_items(|item| {
2747        if item.id != id {
2748            return Some(item.clone());
2749        }
2750        found = true;
2751        let mut next = item.clone();
2752        next.text = crate::gate_verify::upsert_annotation(&next.text, &predicate);
2753        Some(next)
2754    });
2755    debug_assert!(found, "validated item must be present during replacement");
2756    Ok(rewritten.render())
2757}
2758
2759#[cfg(test)]
2760mod tests {
2761    use super::*;
2762
2763    const DOC_ID: &str = "test-doc";
2764
2765    fn ids() -> HashSet<String> {
2766        HashSet::new()
2767    }
2768
2769    #[test]
2770    fn extract_done_item_own_ids_ignores_prose_citations() {
2771        // #donemirrorreap regression: the #ftstrike done entry cites [#fullboundary]
2772        // in its prose. The own-id extractor must return only ftstrike, never
2773        // fullboundary (which would falsely reap an open #fullboundary mirror).
2774        let archive = concat!(
2775            "# Agent Doc Completed Work\n\n",
2776            "- 2026-06-19 [#733r] reconcile queue heads.\n",
2777            "- 2026-06-19 [#ftstrike] strike free-text heads regardless of position, ",
2778            "e.g. a head behind do [#fullboundary] is now struck. See [#semmerge].\n",
2779        );
2780        let got = extract_done_item_own_ids(archive);
2781        assert!(got.contains("733r"));
2782        assert!(got.contains("ftstrike"));
2783        assert!(
2784            !got.contains("fullboundary"),
2785            "a [#id] cited in prose must NOT be treated as a done item id: {got:?}"
2786        );
2787        assert!(
2788            !got.contains("semmerge"),
2789            "trailing prose citation excluded: {got:?}"
2790        );
2791        // Contrast: the whole-text extractor DOES harvest the prose citations.
2792        assert!(extract_pending_ids_from_text(archive).contains("fullboundary"));
2793    }
2794
2795    #[test]
2796    fn extract_done_item_own_ids_handles_checkbox_and_skips_prose_lines() {
2797        let body = concat!(
2798            "- [x] [#foo] done thing depends on [#bar].\n",
2799            "  continuation prose mentioning [#baz]\n",
2800            "- [/] [#gated] gated item\n",
2801            "plain prose line with [#qux]\n",
2802        );
2803        let got = extract_done_item_own_ids(body);
2804        assert!(got.contains("foo"));
2805        assert!(got.contains("gated"));
2806        assert!(
2807            !got.contains("bar"),
2808            "same-line prose citation excluded: {got:?}"
2809        );
2810        assert!(
2811            !got.contains("baz"),
2812            "continuation-line citation excluded: {got:?}"
2813        );
2814        assert!(
2815            !got.contains("qux"),
2816            "non-list prose line excluded: {got:?}"
2817        );
2818    }
2819
2820    #[test]
2821    fn active_item_ids_returns_open_items_in_order() {
2822        let body = concat!(
2823            "- [ ] [#first] one\n",
2824            "- [/] [#gated] blocked\n",
2825            "- [x] [#done] finished\n",
2826            "- [ ] [#second] two\n",
2827        );
2828        assert_eq!(active_item_ids(body), vec!["first", "second"]);
2829    }
2830
2831    #[test]
2832    fn active_item_ids_empty_for_empty_body() {
2833        assert!(active_item_ids("").is_empty());
2834    }
2835
2836    #[test]
2837    fn active_item_ids_holds_future_not_before_items() {
2838        // #backlog-not-before: a `not-before=` precondition in the future holds
2839        // the item out of the backlog→queue sync; a past threshold is eligible.
2840        // 2020-01-01 is always past and 2999-12-31 always future for any real
2841        // test-run clock, so no clock injection is needed.
2842        let body = concat!(
2843            "- [ ] [#now] ready now\n",
2844            "- [ ] [#past] not-before=2020-01-01 already due\n",
2845            "- [ ] [#future] not-before=2999-12-31 scheduled later\n",
2846        );
2847        assert_eq!(active_item_ids(body), vec!["now", "past"]);
2848        assert_eq!(
2849            active_item_priorities(body)
2850                .into_iter()
2851                .map(|(id, _)| id)
2852                .collect::<Vec<_>>(),
2853            vec!["now", "past"]
2854        );
2855    }
2856
2857    #[test]
2858    fn active_enqueue_item_ids_holds_future_not_before_items() {
2859        // A future `not-before=` precondition overrides an explicit enqueue marker.
2860        let body = concat!(
2861            "- [ ] [#mark] /enqueue not-before=2999-12-31 scheduled later\n",
2862            "- [ ] [#ready] /enqueue not-before=2020-01-01 due\n",
2863        );
2864        assert_eq!(active_enqueue_item_ids(body), vec!["ready"]);
2865    }
2866
2867    #[test]
2868    fn item_not_before_day_parses_and_validates() {
2869        assert_eq!(item_not_before_day("not-before=1970-01-01 x"), Some(0));
2870        assert_eq!(item_not_before_day("text not-before=1970-01-02 x"), Some(1));
2871        // word boundary: prose / hyphenated prefix must not match
2872        assert_eq!(item_not_before_day("xnot-before=2020-01-01"), None);
2873        // malformed → ignored (treated as no precondition)
2874        assert_eq!(item_not_before_day("not-before=2020-13-01"), None);
2875        assert_eq!(item_not_before_day("not-before=garbage"), None);
2876        assert_eq!(item_not_before_day("no token here"), None);
2877    }
2878
2879    #[test]
2880    fn item_precondition_unmet_compares_against_today() {
2881        let today = days_from_civil(2026, 6, 18);
2882        assert!(item_precondition_unmet("not-before=2026-06-19 x", today));
2883        assert!(!item_precondition_unmet("not-before=2026-06-18 x", today)); // due today
2884        assert!(!item_precondition_unmet("not-before=2026-06-17 x", today));
2885        assert!(!item_precondition_unmet("no precondition", today));
2886    }
2887
2888    #[test]
2889    fn active_enqueue_item_ids_returns_marked_open_items() {
2890        let body = concat!(
2891            "- [ ] [#inbox] :inbox_tray: one\n",
2892            "- [/] [#gated] :inbox_tray: blocked\n",
2893            "- [x] [#done] **enqueue** finished\n",
2894            "- [ ] [#bold] **enqueue** two\n",
2895            "- [ ] [#slash] /enqueue three\n",
2896            "- [ ] [#plain] enqueue should not be a marker\n",
2897            "- [ ] untracked :inbox_tray: no id\n",
2898        );
2899        assert_eq!(
2900            active_enqueue_item_ids(body),
2901            vec!["inbox", "bold", "slash"]
2902        );
2903    }
2904
2905    #[test]
2906    fn execution_context_tags_parse_booleans() {
2907        // #goqueuestall: `[clean-session]` / `[operator-verify]` flip booleans.
2908        let clean = item_execution_context("[clean-session] needs a quiet session");
2909        assert!(clean.clean_session_required);
2910        assert!(!clean.operator_verify_required);
2911        assert!(clean.is_deferred());
2912
2913        let oper = item_execution_context("ship it [operator-verify] live drive");
2914        assert!(!oper.clean_session_required);
2915        assert!(oper.operator_verify_required);
2916        assert!(oper.is_deferred());
2917
2918        // Plain items set neither and are not deferred.
2919        let plain = item_execution_context("just do the thing");
2920        assert!(!plain.clean_session_required);
2921        assert!(!plain.operator_verify_required);
2922        assert!(!plain.is_deferred());
2923
2924        // Both tags combine, and they coexist with `[recommended]` / `[TOP]`.
2925        let both =
2926            item_execution_context("[recommended] [TOP] [clean-session] [operator-verify] go");
2927        assert!(both.clean_session_required);
2928        assert!(both.operator_verify_required);
2929
2930        // Prose mention of the word (not a bracketed token) does not trip it.
2931        let prose = item_execution_context("run from a clean session please");
2932        assert!(!prose.clean_session_required);
2933    }
2934
2935    #[test]
2936    fn focused_cycle_tag_is_loop_undrainable_but_clean_session_is_not() {
2937        // `#qstallguard` Layer A: `[focused-cycle]` is the operator's binary-read
2938        // knob for "agent-doable but needs its own dedicated cycle, do not
2939        // auto-drain in the loop."
2940        let focused = item_execution_context("[focused-cycle] supervisor-core merge change");
2941        assert!(focused.focused_cycle_required);
2942        assert!(focused.is_deferred());
2943        assert!(
2944            focused.loop_undrainable(),
2945            "[focused-cycle] must be undrainable by the queue loop"
2946        );
2947
2948        // `[clean-session]` is deferred-flavored but DRAINS IN PLACE — it must NOT
2949        // be loop_undrainable (`#qcontdrain`).
2950        let clean = item_execution_context("[clean-session] needs a quiet session");
2951        assert!(clean.is_deferred());
2952        assert!(
2953            !clean.loop_undrainable(),
2954            "[clean-session] drains in place — never loop_undrainable"
2955        );
2956
2957        // `[operator-verify]` needs a human and is loop_undrainable.
2958        let oper = item_execution_context("[operator-verify] live drive");
2959        assert!(oper.loop_undrainable());
2960
2961        // `#qfocsup`: `[focused-cycle]` is loop_undrainable but SUPERVISOR-drainable
2962        // — the supervisor force-`/clear`s and re-dispatches it to a fresh context,
2963        // so it must NOT be supervisor_undrainable. Only `[operator-verify]` is.
2964        assert!(
2965            !focused.supervisor_undrainable(),
2966            "[focused-cycle] is drained by the supervisor clear-and-continue path"
2967        );
2968        assert!(
2969            oper.supervisor_undrainable(),
2970            "[operator-verify] needs a human — undrainable by any agent scope"
2971        );
2972        assert!(
2973            !clean.supervisor_undrainable(),
2974            "[clean-session] drains everywhere"
2975        );
2976
2977        // Plain items are fully drainable: the agent may NOT invent a stall.
2978        let plain = item_execution_context("just implement #6b5h");
2979        assert!(!plain.loop_undrainable());
2980        assert!(!plain.supervisor_undrainable());
2981        assert!(!plain.is_deferred());
2982
2983        // The tag strips out of display text and coexists with other markers.
2984        assert_eq!(
2985            strip_execution_context_tags("[recommended] [focused-cycle] rework the merge seam"),
2986            "[recommended] rework the merge seam"
2987        );
2988    }
2989
2990    #[test]
2991    fn execution_context_tags_stripped_from_display_text() {
2992        // #goqueuestall: tags are kept out of rendered display text.
2993        assert_eq!(
2994            strip_execution_context_tags("[clean-session] fix the converge path"),
2995            "fix the converge path"
2996        );
2997        assert_eq!(
2998            strip_execution_context_tags("[recommended] [operator-verify] live verify the flow"),
2999            "[recommended] live verify the flow"
3000        );
3001        assert_eq!(
3002            strip_execution_context_tags("[clean-session] [operator-verify] both"),
3003            "both"
3004        );
3005        // No tags → unchanged words (whitespace normalized on rejoin).
3006        assert_eq!(
3007            strip_execution_context_tags("plain item text"),
3008            "plain item text"
3009        );
3010    }
3011
3012    #[test]
3013    fn active_item_execution_contexts_skips_closed_and_idless() {
3014        // #goqueuestall: only open, id-bearing items surface a context.
3015        let body = concat!(
3016            "- [ ] [#a] [clean-session] needs quiet\n",
3017            "- [x] [#b] [operator-verify] already done\n",
3018            "- [ ] [#c] plain drainable\n",
3019        );
3020        let ctxs = active_item_execution_contexts(body);
3021        assert_eq!(ctxs.len(), 2);
3022        assert_eq!(ctxs[0].0, "a");
3023        assert!(ctxs[0].1.clean_session_required);
3024        assert_eq!(ctxs[1].0, "c");
3025        assert!(!ctxs[1].1.is_deferred());
3026    }
3027
3028    #[test]
3029    fn item_priority_rank_parses_token() {
3030        assert_eq!(item_priority_rank("priority=1 do the thing"), 1);
3031        assert_eq!(item_priority_rank("text priority=5 more"), 5);
3032        assert_eq!(item_priority_rank("no token here"), PRIORITY_RANK_UNSET);
3033        assert_eq!(
3034            item_priority_rank("priority=0 out of range"),
3035            PRIORITY_RANK_UNSET
3036        );
3037        assert_eq!(
3038            item_priority_rank("priority=12 out of range"),
3039            PRIORITY_RANK_UNSET
3040        );
3041    }
3042
3043    #[test]
3044    fn sort_by_priority_orders_ascending_stable() {
3045        let body = concat!(
3046            "- [ ] [#a] priority=3 third\n",
3047            "- [ ] [#b] no priority\n",
3048            "- [ ] [#c] priority=1 first\n",
3049            "- [ ] [#d] priority=1 also first\n",
3050        );
3051        let sorted = sort_by_priority(body).expect("order should change");
3052        // priority=1 items first (stable: c before d), then priority=3, then unset.
3053        let ids: Vec<&str> = sorted
3054            .lines()
3055            .filter_map(|l| l.split("[#").nth(1))
3056            .filter_map(|s| s.split(']').next())
3057            .collect();
3058        assert_eq!(ids, vec!["c", "d", "a", "b"]);
3059    }
3060
3061    #[test]
3062    fn sort_by_priority_idempotent_when_ordered() {
3063        let body = "- [ ] [#a] priority=1 x\n- [ ] [#b] priority=2 y\n";
3064        assert!(sort_by_priority(body).is_none());
3065    }
3066
3067    #[test]
3068    fn active_item_priorities_pairs_open_ids_with_rank() {
3069        let body = concat!(
3070            "- [ ] [#a] priority=2 one\n",
3071            "- [/] [#g] priority=1 gated\n",
3072            "- [ ] [#b] two\n",
3073        );
3074        assert_eq!(
3075            active_item_priorities(body),
3076            vec![
3077                ("a".to_string(), 2u8),
3078                ("b".to_string(), PRIORITY_RANK_UNSET)
3079            ]
3080        );
3081    }
3082
3083    #[test]
3084    fn op_take_active_items_by_ids_removes_open_and_gated_matches_only() {
3085        let body = concat!(
3086            "intro\n",
3087            "- [ ] [#open] remove open\n",
3088            "  child line\n",
3089            "- [/] [#gated] remove gated\n",
3090            "- [x] [#done] keep explicit done\n",
3091            "- [ ] [#keep] keep open\n",
3092        );
3093        let ids: HashSet<String> = ["#open".to_string(), "gated".to_string(), "done".to_string()]
3094            .into_iter()
3095            .collect();
3096
3097        let (new_body, removed) = op_take_active_items_by_ids(body, &ids);
3098
3099        assert_eq!(
3100            removed
3101                .iter()
3102                .map(|item| item.id.as_str())
3103                .collect::<Vec<_>>(),
3104            vec!["open", "gated"]
3105        );
3106        assert!(!new_body.contains("[#open]"));
3107        assert!(!new_body.contains("child line"));
3108        assert!(!new_body.contains("[#gated]"));
3109        assert!(new_body.contains("[#done] keep explicit done"));
3110        assert!(new_body.contains("[#keep] keep open"));
3111        assert!(new_body.starts_with("intro\n"));
3112    }
3113
3114    #[test]
3115    fn parse_empty_body() {
3116        let (p, items, post) = parse_items("");
3117        assert_eq!(p, "");
3118        assert!(items.is_empty());
3119        assert_eq!(post, "");
3120    }
3121
3122    #[test]
3123    fn parse_fully_migrated() {
3124        let body = "- [ ] [#a3f2] first\n- [x] [#b1c4] second\n";
3125        let (_, items, _) = parse_items(body);
3126        assert_eq!(items.len(), 2);
3127        assert_eq!(items[0].marker, PendingListMarker::Bullet);
3128        assert_eq!(items[0].id, "a3f2");
3129        assert_eq!(items[0].state, PendingState::Open);
3130        assert_eq!(items[0].text, "first");
3131        assert_eq!(items[1].id, "b1c4");
3132        assert_eq!(items[1].state, PendingState::Done);
3133    }
3134
3135    #[test]
3136    fn detects_malformed_tracked_checklist_lines() {
3137        let body = concat!(
3138            "_- [ ] [#pcops] damaged prefix\n",
3139            "- [ ] [#keep1] valid item\n",
3140            "plain note mentioning [#note1]\n",
3141        );
3142
3143        let malformed = detect_malformed_item_lines(body);
3144
3145        assert_eq!(malformed.len(), 1);
3146        assert_eq!(malformed[0].id, "pcops");
3147        assert_eq!(malformed[0].line, 1);
3148        assert!(malformed[0].text.contains("damaged prefix"));
3149    }
3150
3151    #[test]
3152    fn parse_hyphenated_id() {
3153        let body = "- [ ] [#tmuxcrash-abcd] child task\n";
3154        let (_, items, _) = parse_items(body);
3155        assert_eq!(items.len(), 1);
3156        assert_eq!(items[0].id, "tmuxcrash-abcd");
3157        assert_eq!(items[0].text, "child task");
3158    }
3159
3160    #[test]
3161    fn parse_ordered_items() {
3162        let body = "1. [ ] [#a3f2] first\n2. [x] [#b1c4] second\n";
3163        let (_, items, _) = parse_items(body);
3164        assert_eq!(items.len(), 2);
3165        assert_eq!(items[0].marker, PendingListMarker::Ordered(1));
3166        assert_eq!(items[0].id, "a3f2");
3167        assert_eq!(items[1].marker, PendingListMarker::Ordered(2));
3168        assert_eq!(items[1].state, PendingState::Done);
3169    }
3170
3171    #[test]
3172    fn strips_redundant_self_id_tag_from_text() {
3173        // #pending-redundant-self-id-strip: a self-id repeated after a tag is dropped.
3174        let body = "- [ ] [#stale-x] [recommended] [#stale-x] Evaluate the retire path.\n";
3175        let (_, items, _) = parse_items(body);
3176        assert_eq!(items.len(), 1);
3177        assert_eq!(items[0].id, "stale-x");
3178        assert_eq!(items[0].text, "[recommended] Evaluate the retire path.");
3179    }
3180
3181    #[test]
3182    fn preserves_cross_reference_ids_in_text() {
3183        // Only the item's OWN id is stripped; references to other ids stay.
3184        let body = "- [ ] [#a] depends on [#b] and [#c] downstream.\n";
3185        let (_, items, _) = parse_items(body);
3186        assert_eq!(items.len(), 1);
3187        assert_eq!(items[0].id, "a");
3188        assert_eq!(items[0].text, "depends on [#b] and [#c] downstream.");
3189    }
3190
3191    #[test]
3192    fn parse_gated_state() {
3193        let body = "- [/] [#eg0w] CommitLock — gate: v0.32.5\n";
3194        let (_, items, _) = parse_items(body);
3195        assert_eq!(items.len(), 1);
3196        assert_eq!(items[0].state, PendingState::Gated);
3197        assert_eq!(items[0].id, "eg0w");
3198        assert_eq!(items[0].text, "CommitLock — gate: v0.32.5");
3199    }
3200
3201    #[test]
3202    fn parse_all_three_states() {
3203        let body = "- [ ] [#a3f2] open\n- [/] [#b1c4] gated\n- [x] [#c9e0] done\n";
3204        let (_, items, _) = parse_items(body);
3205        assert_eq!(items[0].state, PendingState::Open);
3206        assert_eq!(items[1].state, PendingState::Gated);
3207        assert_eq!(items[2].state, PendingState::Done);
3208    }
3209
3210    #[test]
3211    fn parse_legacy_tilde_checkbox_preserves_hash_id() {
3212        let body = "- [~] [#q6js] in-progress legacy item\n";
3213        let (_, items, _) = parse_items(body);
3214        assert_eq!(items.len(), 1);
3215        assert_eq!(items[0].state, PendingState::Open);
3216        assert_eq!(items[0].id, "q6js");
3217        assert_eq!(items[0].text, "in-progress legacy item");
3218    }
3219
3220    #[test]
3221    fn parse_checkbox_only_no_id() {
3222        let body = "- [ ] just text\n- [x] done item\n";
3223        let (_, items, _) = parse_items(body);
3224        assert_eq!(items.len(), 2);
3225        assert_eq!(items[0].id, "");
3226        assert_eq!(items[0].text, "just text");
3227        assert_eq!(items[1].state, PendingState::Done);
3228    }
3229
3230    #[test]
3231    fn parse_legacy_no_checkbox() {
3232        let body = "- legacy one\n- legacy two\n";
3233        let (_, items, _) = parse_items(body);
3234        assert_eq!(items.len(), 2);
3235        assert_eq!(items[0].text, "legacy one");
3236        assert_eq!(items[0].state, PendingState::Open);
3237        assert_eq!(items[0].id, "");
3238    }
3239
3240    #[test]
3241    fn parse_mixed() {
3242        let body = "- [ ] [#a3f2] migrated\n- [ ] partial\n- legacy\n";
3243        let (_, items, _) = parse_items(body);
3244        assert_eq!(items.len(), 3);
3245        assert_eq!(items[0].id, "a3f2");
3246        assert_eq!(items[1].id, "");
3247        assert_eq!(items[1].text, "partial");
3248        assert_eq!(items[2].text, "legacy");
3249    }
3250
3251    #[test]
3252    fn parse_nested_lines_attach_to_parent_item() {
3253        let body = concat!(
3254            "- [ ] [#a3f2] parent task\n",
3255            "  - dependency one\n",
3256            "  - dependency two\n",
3257            "- [ ] [#b1c4] sibling task\n"
3258        );
3259        let (_, items, _) = parse_items(body);
3260        assert_eq!(items.len(), 2);
3261        assert_eq!(items[0].id, "a3f2");
3262        assert_eq!(items[0].text, "parent task");
3263        assert_eq!(
3264            items[0].continuation,
3265            "  - dependency one\n  - dependency two\n"
3266        );
3267        assert_eq!(items[1].id, "b1c4");
3268        assert!(items[1].continuation.is_empty());
3269    }
3270
3271    #[test]
3272    fn parse_nested_lines_attach_to_ordered_parent_item() {
3273        let body = concat!(
3274            "1. [ ] [#a3f2] parent task\n",
3275            "   1. dependency one\n",
3276            "   2. dependency two\n",
3277            "2. [ ] [#b1c4] sibling task\n"
3278        );
3279        let (_, items, _) = parse_items(body);
3280        assert_eq!(items.len(), 2);
3281        assert_eq!(items[0].marker, PendingListMarker::Ordered(1));
3282        assert_eq!(
3283            items[0].continuation,
3284            "   1. dependency one\n   2. dependency two\n"
3285        );
3286        assert_eq!(items[1].marker, PendingListMarker::Ordered(2));
3287    }
3288
3289    #[test]
3290    fn render_roundtrip_canonical() {
3291        let body = "- [ ] [#a3f2] first\n- [x] [#b1c4] second\n";
3292        let (p, items, post) = parse_items(body);
3293        let out = render_items(&p, &items, &post);
3294        assert_eq!(out, body);
3295    }
3296
3297    #[test]
3298    fn render_roundtrip_all_three_states() {
3299        let body = "- [ ] [#a3f2] open\n- [/] [#b1c4] gated — gate: v0.32.5\n- [x] [#c9e0] done\n";
3300        let (p, items, post) = parse_items(body);
3301        let out = render_items(&p, &items, &post);
3302        assert_eq!(out, body);
3303    }
3304
3305    #[test]
3306    fn render_roundtrip_ordered_list() {
3307        let body = "1. [ ] [#a3f2] open\n2. [/] [#b1c4] gated\n3. [x] [#c9e0] done\n";
3308        let (p, items, post) = parse_items(body);
3309        let out = render_items(&p, &items, &post);
3310        assert_eq!(out, body);
3311    }
3312
3313    #[test]
3314    fn render_emits_slash_for_gated() {
3315        let item = PendingItem {
3316            marker: PendingListMarker::Bullet,
3317            id: "eg0w".to_string(),
3318            state: PendingState::Gated,
3319            gate_type: None,
3320            text: "CommitLock".to_string(),
3321            continuation: String::new(),
3322        };
3323        assert_eq!(item.render(), "- [/] [#eg0w] CommitLock");
3324    }
3325
3326    #[test]
3327    fn backfill_adds_hashes() {
3328        let body = "- legacy one\n- legacy two\n";
3329        let (new_body, changed) = backfill(body, DOC_ID, &ids());
3330        assert!(changed);
3331        let (_, items, _) = parse_items(&new_body);
3332        assert_eq!(items.len(), 2);
3333        assert!(!items[0].id.is_empty());
3334        assert!(!items[1].id.is_empty());
3335        assert_ne!(items[0].id, items[1].id);
3336        assert!(new_body.contains("- [ ] [#"));
3337    }
3338
3339    #[test]
3340    fn backfill_idempotent() {
3341        let body = "- [ ] [#a3f2] first\n";
3342        let (new_body, changed) = backfill(body, DOC_ID, &ids());
3343        assert!(!changed, "fully-migrated body should not change");
3344        assert_eq!(new_body, body);
3345    }
3346
3347    #[test]
3348    fn backfill_normalizes_checkbox_only() {
3349        let body = "- [ ] no id here\n";
3350        let (new_body, changed) = backfill(body, DOC_ID, &ids());
3351        assert!(changed);
3352        assert!(new_body.contains("[#"));
3353    }
3354
3355    #[test]
3356    fn backfill_drops_content_less_empty_bullet() {
3357        // #icebox-empty-item-phantom-id: a stray empty `- [ ]` must NOT be
3358        // assigned a phantom hash id; it carries no description and is dropped.
3359        let body = "- [ ]\n";
3360        let (new_body, changed) = backfill(body, DOC_ID, &ids());
3361        assert!(changed);
3362        assert!(
3363            !new_body.contains("[#"),
3364            "empty bullet must not get a phantom id: {new_body:?}"
3365        );
3366        let (_, items, _) = parse_items(&new_body);
3367        assert!(
3368            items.is_empty(),
3369            "empty bullet should be dropped: {items:?}"
3370        );
3371    }
3372
3373    #[test]
3374    fn backfill_drops_id_only_empty_item_self_heal() {
3375        // Already-cemented phantom (`- [ ] [#1k5y]` with no description) is
3376        // removed on the next backfill so the bug self-heals.
3377        let body = "- [ ] [#existing] real item\n- [ ] [#1k5y]\n";
3378        let (new_body, changed) = backfill(body, DOC_ID, &ids());
3379        assert!(changed);
3380        assert!(new_body.contains("[#existing] real item"));
3381        assert!(
3382            !new_body.contains("[#1k5y]"),
3383            "phantom id-only item must be dropped: {new_body:?}"
3384        );
3385        let (_, items, _) = parse_items(&new_body);
3386        assert_eq!(items.len(), 1);
3387    }
3388
3389    #[test]
3390    fn backfill_keeps_empty_text_with_continuation() {
3391        // Guard against over-dropping: an item with empty header text but a real
3392        // indented continuation still carries content and must be preserved.
3393        let body = "- [ ] [#p1]\n  - detail line\n";
3394        let (new_body, _changed) = backfill(body, DOC_ID, &ids());
3395        assert!(
3396            new_body.contains("[#p1]"),
3397            "item with continuation must survive: {new_body:?}"
3398        );
3399        assert!(new_body.contains("detail line"));
3400    }
3401
3402    #[test]
3403    fn backfill_never_inserts_gated() {
3404        // Legacy items with no checkbox must default to Open `[ ]`,
3405        // never Gated `[/]`. Gated state is always operator-explicit.
3406        let body = "- legacy item awaiting v0.32.5\n";
3407        let (new_body, _) = backfill(body, DOC_ID, &ids());
3408        assert!(new_body.contains("- [ ] "));
3409        assert!(!new_body.contains("- [/] "));
3410    }
3411
3412    #[test]
3413    fn backfill_preserves_existing_gated() {
3414        let body = "- [/] [#eg0w] CommitLock — gate: v0.32.5\n";
3415        let (new_body, changed) = backfill(body, DOC_ID, &ids());
3416        assert!(!changed);
3417        assert_eq!(new_body, body);
3418    }
3419
3420    #[test]
3421    fn backfill_preserves_id_behind_legacy_tilde_checkbox() {
3422        let body = "- [~] [#q6js] pcpc5e3 08b cut-over proof\n";
3423        let (new_body, changed) = backfill(body, DOC_ID, &ids());
3424        assert!(changed);
3425        assert_eq!(new_body, "- [ ] [#q6js] pcpc5e3 08b cut-over proof\n");
3426        assert_eq!(new_body.matches("[#").count(), 1);
3427
3428        let (_, items, _) = parse_items(&new_body);
3429        assert_eq!(items.len(), 1);
3430        assert_eq!(items[0].state, PendingState::Open);
3431        assert_eq!(items[0].id, "q6js");
3432        assert_eq!(items[0].text, "pcpc5e3 08b cut-over proof");
3433    }
3434
3435    #[test]
3436    fn backfill_preserves_interleaved_headers_and_blank_lines() {
3437        let body = concat!(
3438            "### Active\n",
3439            "- legacy one\n",
3440            "\n",
3441            "### Later\n",
3442            "- [ ] [#keep1] keep section\n"
3443        );
3444        let (new_body, changed) = backfill(body, DOC_ID, &ids());
3445        assert!(changed);
3446        assert!(new_body.contains("### Active"));
3447        assert!(new_body.contains("\n\n### Later\n"));
3448        assert!(new_body.contains("[#keep1] keep section"));
3449        let lines: Vec<&str> = new_body.lines().collect();
3450        assert_eq!(lines[0], "### Active");
3451        assert!(lines[1].starts_with("- [ ] [#"));
3452        assert_eq!(lines[3], "### Later");
3453    }
3454
3455    #[test]
3456    fn backfill_assigns_nested_subtask_ids_prefixed_by_parent() {
3457        let body = concat!(
3458            "- parent task\n",
3459            "  - child dependency\n",
3460            "  - child subtask\n",
3461            "- sibling task\n"
3462        );
3463        let (new_body, changed) = backfill(body, DOC_ID, &ids());
3464        assert!(changed);
3465        assert_eq!(new_body.matches("[#").count(), 4, "got: {new_body}");
3466        assert!(new_body.contains("  - [ ] [#"));
3467        let lines: Vec<&str> = new_body.lines().collect();
3468        let parent_line = lines[0];
3469        let parent_id = parent_line
3470            .split("[#")
3471            .nth(1)
3472            .and_then(|rest| rest.split(']').next())
3473            .expect("parent id");
3474        assert!(
3475            lines[1].contains(&format!("[#{}-", parent_id)),
3476            "expected first child id prefixed by parent id, got: {}",
3477            lines[1]
3478        );
3479        assert!(
3480            lines[2].contains(&format!("[#{}-", parent_id)),
3481            "expected second child id prefixed by parent id, got: {}",
3482            lines[2]
3483        );
3484    }
3485
3486    #[test]
3487    fn backfill_preserves_existing_nested_subtask_ids() {
3488        let body = concat!(
3489            "- [ ] [#tmuxcrash] parent task\n",
3490            "  - [ ] [#tmuxcrash-abcd] child dependency\n"
3491        );
3492        let (new_body, changed) = backfill(body, DOC_ID, &ids());
3493        assert!(!changed);
3494        assert_eq!(new_body, body);
3495    }
3496
3497    #[test]
3498    fn backfill_reassigns_duplicate_existing_nested_subtask_ids() {
3499        let body = concat!(
3500            "- [ ] [#tmuxcrash] parent task\n",
3501            "  - [ ] [#tmuxcrash-abcd] child dependency\n",
3502            "  - [ ] [#tmuxcrash-abcd] child subtask\n"
3503        );
3504        let (new_body, changed) = backfill(body, DOC_ID, &ids());
3505        assert!(changed);
3506        let lines: Vec<&str> = new_body.lines().collect();
3507        assert_eq!(lines.len(), 3, "got: {new_body}");
3508        assert!(lines[1].contains("[#tmuxcrash-abcd]"));
3509        let second_child_id = lines[2]
3510            .split("[#")
3511            .nth(1)
3512            .and_then(|rest| rest.split(']').next())
3513            .expect("second child id");
3514        assert_ne!(second_child_id, "tmuxcrash-abcd");
3515        assert!(second_child_id.starts_with("tmuxcrash-"));
3516    }
3517
3518    #[test]
3519    fn backfill_preserves_ordered_parent_items() {
3520        let body = concat!("1. legacy one\n", "2. [ ] [#keep1] keep section\n");
3521        let (new_body, changed) = backfill(body, DOC_ID, &ids());
3522        assert!(changed);
3523        assert!(new_body.starts_with("1. [ ] [#"));
3524        assert!(new_body.contains("2. [ ] [#keep1] keep section"));
3525    }
3526
3527    #[test]
3528    fn reap_skips_gated() {
3529        let body = "- [/] [#eg0w] gated\n- [x] [#c9e0] done\n";
3530        let (new_body, removed) = reap(body).unwrap();
3531        assert_eq!(removed, vec!["c9e0"]);
3532        assert!(new_body.contains("[#eg0w]"));
3533        assert!(!new_body.contains("[#c9e0]"));
3534    }
3535
3536    #[test]
3537    fn reap_removes_checked() {
3538        let body = "- [ ] [#a3f2] keep\n- [x] [#b1c4] drop\n- [ ] [#c5d6] keep2\n";
3539        let (new_body, removed) = reap(body).unwrap();
3540        assert_eq!(removed, vec!["b1c4"]);
3541        assert!(new_body.contains("a3f2"));
3542        assert!(!new_body.contains("b1c4"));
3543        assert!(new_body.contains("c5d6"));
3544    }
3545
3546    #[test]
3547    fn reap_removes_flush_left_spill_with_completed_parent() {
3548        let body = concat!(
3549            "- [x] [#b1c4] drop\n",
3550            "Commands:\n",
3551            "  cargo test -p agent-doc pending::\n",
3552            "Diff:\n",
3553            "@@ -1 +1 @@\n",
3554            "- [ ] [#c5d6] keep\n"
3555        );
3556        let (new_body, removed) = reap(body).unwrap();
3557        assert_eq!(removed, vec!["b1c4"]);
3558        assert!(!new_body.contains("[#b1c4]"));
3559        assert!(!new_body.contains("Commands:"));
3560        assert!(!new_body.contains("@@ -1 +1 @@"));
3561        assert!(new_body.contains("- [ ] [#c5d6] keep"));
3562    }
3563
3564    #[test]
3565    fn reap_preserves_following_heading_text() {
3566        let body = concat!(
3567            "- [x] [#b1c4] drop\n",
3568            "\n",
3569            "## Next Group\n",
3570            "- [ ] [#c5d6] keep\n"
3571        );
3572        let (new_body, removed) = reap(body).unwrap();
3573        assert_eq!(removed, vec!["b1c4"]);
3574        assert!(!new_body.contains("[#b1c4]"));
3575        assert!(new_body.contains("## Next Group"));
3576        assert!(new_body.contains("- [ ] [#c5d6] keep"));
3577    }
3578
3579    #[test]
3580    fn reap_noop_when_none_checked() {
3581        let body = "- [ ] [#a3f2] keep\n";
3582        let (new_body, removed) = reap(body).unwrap();
3583        assert!(removed.is_empty());
3584        assert_eq!(new_body, body);
3585    }
3586
3587    #[test]
3588    fn reap_errors_when_completed_item_is_missing_id() {
3589        let body = "- [x] legacy done without id\n- [ ] [#keep1] keep\n";
3590        let err = reap(body).unwrap_err();
3591        assert!(
3592            err.to_string()
3593                .contains("pending reap requires ids for completed items"),
3594            "unexpected error: {err}"
3595        );
3596    }
3597
3598    #[test]
3599    fn reap_with_items_archives_malformed_flush_left_spill_with_parent() {
3600        let body = concat!(
3601            "- [x] [#b1c4] drop\n",
3602            "Commands:\n",
3603            "  cargo test -p agent-doc pending::\n"
3604        );
3605        let (_new_body, removed) = reap_with_items(body).unwrap();
3606        assert_eq!(removed.len(), 1);
3607        assert_eq!(removed[0].id, "b1c4");
3608        assert_eq!(
3609            removed[0].continuation,
3610            "Commands:\n  cargo test -p agent-doc pending::\n"
3611        );
3612    }
3613
3614    #[test]
3615    fn detect_reorder_same_set_different_order() {
3616        let snap = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
3617        let cur = "- [ ] [#c3d4] two\n- [ ] [#a1b2] one\n";
3618        let result = detect_reorder(snap, cur);
3619        assert_eq!(result, Some(vec!["c3d4".to_string(), "a1b2".to_string()]));
3620    }
3621
3622    #[test]
3623    fn detect_reorder_none_when_sets_differ() {
3624        let snap = "- [ ] [#a1b2] one\n";
3625        let cur = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
3626        assert_eq!(detect_reorder(snap, cur), None);
3627    }
3628
3629    #[test]
3630    fn detect_reorder_none_when_same_order() {
3631        let snap = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
3632        assert_eq!(detect_reorder(snap, snap), None);
3633    }
3634
3635    #[test]
3636    fn op_add_inserts_new_item_with_hash() {
3637        let body = "";
3638        let (new_body, id) = op_add(body, "first task", DOC_ID, false).unwrap();
3639        assert!(new_body.contains("- [ ] [#"));
3640        assert!(new_body.contains("first task"));
3641        assert!(!id.is_empty());
3642    }
3643
3644    #[test]
3645    fn op_add_prepends_before_existing_items() {
3646        let body = "- [ ] [#a1b2] existing task\n- [ ] [#c3d4] later task\n";
3647        let (new_body, _id) = op_add(body, "new first task", DOC_ID, false).unwrap();
3648        let lines: Vec<&str> = new_body.lines().collect();
3649        assert!(
3650            lines[0].contains("new first task"),
3651            "expected new item first, got: {}",
3652            new_body
3653        );
3654        assert!(
3655            lines[1].contains("existing task"),
3656            "expected previous first item second, got: {}",
3657            new_body
3658        );
3659    }
3660
3661    #[test]
3662    fn op_add_at_after_inserts_immediately_after_anchor() {
3663        // #ah0s: --pending-add-after lands directly below the anchor.
3664        let body = "- [ ] [#a1b2] first\n- [ ] [#c3d4] third\n";
3665        let (new_body, _id) =
3666            op_add_at(body, "second", DOC_ID, false, AddPosition::After("a1b2")).unwrap();
3667        let lines: Vec<&str> = new_body.lines().collect();
3668        assert!(lines[0].contains("first"), "{new_body}");
3669        assert!(lines[1].contains("second"), "{new_body}");
3670        assert!(lines[2].contains("third"), "{new_body}");
3671    }
3672
3673    #[test]
3674    fn op_add_at_before_inserts_immediately_before_anchor() {
3675        let body = "- [ ] [#a1b2] first\n- [ ] [#c3d4] third\n";
3676        let (new_body, _id) =
3677            op_add_at(body, "zeroth", DOC_ID, false, AddPosition::Before("a1b2")).unwrap();
3678        let lines: Vec<&str> = new_body.lines().collect();
3679        assert!(lines[0].contains("zeroth"), "{new_body}");
3680        assert!(lines[1].contains("first"), "{new_body}");
3681    }
3682
3683    #[test]
3684    fn op_add_at_last_appends_at_tail() {
3685        // #ah0s: --pending-add-back lands at the tail without disturbing the head.
3686        let body = "- [ ] [#a1b2] first\n- [ ] [#c3d4] second\n";
3687        let (new_body, _id) =
3688            op_add_at(body, "tail item", DOC_ID, false, AddPosition::Last).unwrap();
3689        let lines: Vec<&str> = new_body.lines().collect();
3690        assert!(lines[0].contains("first"), "{new_body}");
3691        assert!(lines[2].contains("tail item"), "{new_body}");
3692    }
3693
3694    #[test]
3695    fn op_add_at_after_chains_build_deterministic_order() {
3696        // #ah0s: chaining after A then after B builds A→B→C with no reorder pass.
3697        let body = "- [ ] [#a1b2] A\n";
3698        let (b1, _) =
3699            op_add_at(body, "id=bbbb B", DOC_ID, false, AddPosition::After("a1b2")).unwrap();
3700        let (b2, _) = op_add_at(&b1, "C", DOC_ID, false, AddPosition::After("bbbb")).unwrap();
3701        let lines: Vec<&str> = b2.lines().collect();
3702        assert!(lines[0].contains(" A"), "{b2}");
3703        assert!(lines[1].contains(" B"), "{b2}");
3704        assert!(lines[2].contains(" C"), "{b2}");
3705    }
3706
3707    #[test]
3708    fn op_add_at_unknown_anchor_errors() {
3709        let body = "- [ ] [#a1b2] first\n";
3710        let err = op_add_at(body, "x", DOC_ID, false, AddPosition::After("nope")).unwrap_err();
3711        assert!(err.to_string().contains("anchor id not found"), "{err}");
3712    }
3713
3714    #[test]
3715    fn op_add_preserves_section_headers() {
3716        let body = concat!(
3717            "### Active\n",
3718            "- [ ] [#a1b2] existing task\n",
3719            "### Later\n",
3720            "- [ ] [#c3d4] later task\n"
3721        );
3722        let (new_body, _id) = op_add(body, "new first task", DOC_ID, false).unwrap();
3723        let lines: Vec<&str> = new_body.lines().collect();
3724        assert_eq!(lines[0], "### Active");
3725        assert!(lines[1].contains("new first task"), "got: {}", new_body);
3726        assert!(lines[2].contains("existing task"), "got: {}", new_body);
3727        assert_eq!(lines[3], "### Later");
3728        assert!(lines[4].contains("later task"), "got: {}", new_body);
3729    }
3730
3731    #[test]
3732    fn op_append_items_preserves_order_after_existing_items() {
3733        let body = concat!(
3734            "### Active\n",
3735            "- [ ] [#a1b2] existing task\n",
3736            "\n",
3737            "### Notes\n",
3738            "Keep this note.\n",
3739        );
3740        let appended = vec![
3741            PendingItem {
3742                marker: PendingListMarker::Bullet,
3743                id: "b2c3".to_string(),
3744                state: PendingState::Open,
3745                gate_type: None,
3746                text: "first appended task".to_string(),
3747                continuation: String::new(),
3748            },
3749            PendingItem {
3750                marker: PendingListMarker::Bullet,
3751                id: "d4e5".to_string(),
3752                state: PendingState::Gated,
3753                gate_type: Some("release".to_string()),
3754                text: "second appended task".to_string(),
3755                continuation: String::new(),
3756            },
3757        ];
3758
3759        let new_body = op_append_items(body, &appended);
3760
3761        assert_eq!(
3762            new_body,
3763            concat!(
3764                "### Active\n",
3765                "- [ ] [#a1b2] existing task\n",
3766                "- [ ] [#b2c3] first appended task\n",
3767                "- [/release] [#d4e5] second appended task\n",
3768                "\n",
3769                "### Notes\n",
3770                "Keep this note.\n",
3771            )
3772        );
3773    }
3774
3775    #[test]
3776    fn op_add_renumbers_ordered_lists() {
3777        let body = "1. [ ] [#a1b2] existing task\n2. [ ] [#c3d4] later task\n";
3778        let (new_body, _id) = op_add(body, "new first task", DOC_ID, false).unwrap();
3779        let lines: Vec<&str> = new_body.lines().collect();
3780        assert!(lines[0].starts_with("1. "), "got: {}", new_body);
3781        assert!(lines[0].contains("new first task"), "got: {}", new_body);
3782        assert!(lines[1].starts_with("2. "), "got: {}", new_body);
3783        assert!(lines[1].contains("existing task"), "got: {}", new_body);
3784        assert!(lines[2].starts_with("3. "), "got: {}", new_body);
3785        assert!(lines[2].contains("later task"), "got: {}", new_body);
3786    }
3787
3788    #[test]
3789    fn op_add_accepts_custom_id_prefix() {
3790        let (new_body, id) = op_add("", "id=ship1 release checklist", DOC_ID, false).unwrap();
3791        assert_eq!(id, "ship1");
3792        assert!(new_body.contains("- [ ] [#ship1] release checklist"));
3793    }
3794
3795    #[test]
3796    fn op_add_accepts_custom_id_prefix_with_hash_marker() {
3797        let (new_body, id) = op_add("", "id=#ship1 release checklist", DOC_ID, false).unwrap();
3798        assert_eq!(id, "ship1");
3799        assert!(new_body.contains("- [ ] [#ship1] release checklist"));
3800    }
3801
3802    #[test]
3803    fn op_add_accepts_bracketed_custom_id_prefix() {
3804        let (new_body, id) = op_add("", "[#ship1] release checklist", DOC_ID, false).unwrap();
3805        assert_eq!(id, "ship1");
3806        assert!(new_body.contains("- [ ] [#ship1] release checklist"));
3807    }
3808
3809    #[test]
3810    fn op_add_accepts_long_bracketed_custom_id_prefix() {
3811        let (new_body, id) = op_add("", "[#sdig2matrix] release checklist", DOC_ID, false).unwrap();
3812        assert_eq!(id, "sdig2matrix");
3813        assert!(new_body.contains("- [ ] [#sdig2matrix] release checklist"));
3814    }
3815
3816    #[test]
3817    fn op_add_accepts_hyphenated_custom_id_prefix() {
3818        let (new_body, id) =
3819            op_add("", "id=tmuxcrash-abcd release checklist", DOC_ID, false).unwrap();
3820        assert_eq!(id, "tmuxcrash-abcd");
3821        assert!(new_body.contains("- [ ] [#tmuxcrash-abcd] release checklist"));
3822    }
3823
3824    #[test]
3825    fn op_add_accepts_hyphenated_bracketed_custom_id_prefix() {
3826        let (new_body, id) =
3827            op_add("", "[#tmuxcrash-abcd] release checklist", DOC_ID, false).unwrap();
3828        assert_eq!(id, "tmuxcrash-abcd");
3829        assert!(new_body.contains("- [ ] [#tmuxcrash-abcd] release checklist"));
3830    }
3831
3832    #[test]
3833    fn op_add_rejects_bare_bracket_placeholder_prefix() {
3834        let err = op_add("", "[#] release checklist", DOC_ID, false).unwrap_err();
3835        assert!(format!("{}", err).contains("bare `[#]` placeholder"));
3836    }
3837
3838    #[test]
3839    fn op_add_rejects_empty_explicit_custom_id_prefix() {
3840        let err = op_add("", "id=  release checklist", DOC_ID, false).unwrap_err();
3841        assert!(format!("{}", err).contains("empty custom id"));
3842    }
3843
3844    #[test]
3845    fn op_add_rejects_duplicate_custom_id_prefix() {
3846        let body = "- [ ] [#ship1] existing task\n";
3847        let err = op_add(body, "id=ship1 new task", DOC_ID, false).unwrap_err();
3848        assert!(format!("{}", err).contains("custom id already exists"));
3849    }
3850
3851    #[test]
3852    fn op_add_rejects_missing_text_after_custom_id_prefix() {
3853        let err = op_add("", "id=ship1", DOC_ID, false).unwrap_err();
3854        assert!(format!("{}", err).contains("custom id prefix must be followed by item text"));
3855    }
3856
3857    #[test]
3858    fn op_add_rejects_missing_text_after_bracketed_custom_id_prefix() {
3859        let err = op_add("", "[#ship1]", DOC_ID, false).unwrap_err();
3860        assert!(
3861            format!("{}", err).contains("bracketed custom id prefix must be followed by item text")
3862        );
3863    }
3864
3865    #[test]
3866    fn op_add_rejects_stacked_bracketed_custom_id_prefixes() {
3867        let err = op_add("", "[#ship1] [#ship2] release checklist", DOC_ID, false).unwrap_err();
3868        assert!(
3869            format!("{}", err).contains("duplicate leading custom id prefix"),
3870            "unexpected error: {}",
3871            err
3872        );
3873    }
3874
3875    #[test]
3876    fn op_add_rejects_stacked_mixed_custom_id_prefixes() {
3877        let err = op_add("", "id=ship1 [#ship2] release checklist", DOC_ID, false).unwrap_err();
3878        assert!(
3879            format!("{}", err).contains("duplicate leading custom id prefix"),
3880            "unexpected error: {}",
3881            err
3882        );
3883    }
3884
3885    #[test]
3886    fn op_add_rejects_empty() {
3887        assert!(op_add("", "   ", DOC_ID, false).is_err());
3888    }
3889
3890    #[test]
3891    fn op_add_rejects_state_marker_prefix() {
3892        for marker in &["[ ] task", "[/] task", "[x] task", "[X] task"] {
3893            let err = op_add("", marker, DOC_ID, false).unwrap_err();
3894            let msg = format!("{}", err);
3895            assert!(
3896                msg.contains("state marker"),
3897                "expected state marker error for '{}', got: {}",
3898                marker,
3899                msg
3900            );
3901        }
3902    }
3903
3904    #[test]
3905    fn op_add_rejects_duplicate_text() {
3906        let (body, _id1) = op_add("", "Wire Sift into corky", DOC_ID, false).unwrap();
3907        let err = op_add(&body, "Wire Sift into corky", DOC_ID, false).unwrap_err();
3908        let msg = format!("{}", err);
3909        assert!(
3910            msg.contains("duplicate"),
3911            "expected duplicate error, got: {}",
3912            msg
3913        );
3914    }
3915
3916    #[test]
3917    fn op_add_dedupes_symptom_key_by_attaching_evidence() {
3918        let key = SymptomDedupeKey::new("stale_queue_pause", "doc-abc", "queue", "sha256:feedface")
3919            .unwrap();
3920        let first = format!("stale queue pause {}", key.marker());
3921        let first_outcome = op_add_with_outcome("", &first, DOC_ID, false).unwrap();
3922        assert!(first_outcome.inserted);
3923
3924        let repeat = format!("stale queue pause observed again {}", key.marker());
3925        let repeat_outcome =
3926            op_add_with_outcome(&first_outcome.body, &repeat, DOC_ID, false).unwrap();
3927
3928        assert!(!repeat_outcome.inserted);
3929        assert_eq!(repeat_outcome.id, first_outcome.id);
3930        assert_eq!(repeat_outcome.deduped_key.as_ref(), Some(&key));
3931        assert_eq!(repeat_outcome.body.matches("[#").count(), 1);
3932        assert!(repeat_outcome.body.contains("stale queue pause "));
3933        assert!(
3934            repeat_outcome
3935                .body
3936                .contains("  evidence: stale queue pause observed again")
3937        );
3938    }
3939
3940    #[test]
3941    fn parse_bare_hash_placeholder_strips_marker() {
3942        let item = parse_item_line("- [ ] [#] Wire Sift into corky").unwrap();
3943        assert_eq!(item.id, "");
3944        assert_eq!(item.text, "Wire Sift into corky");
3945    }
3946
3947    #[test]
3948    fn backfill_strips_bare_hash_placeholder() {
3949        let body = "- [ ] [#] task with placeholder\n";
3950        let (new_body, changed) = backfill(body, DOC_ID, &ids());
3951        assert!(changed);
3952        assert!(new_body.contains("[#"), "should have a hash id");
3953        // The bare [#] should be consumed — only one [# in the output
3954        let hash_count = new_body.matches("[#").count();
3955        assert_eq!(hash_count, 1, "expected exactly one [# in: {}", new_body);
3956        assert!(new_body.contains("task with placeholder"));
3957        assert!(
3958            !new_body.contains("[#] task"),
3959            "bare [#] should not survive in text"
3960        );
3961    }
3962
3963    #[test]
3964    fn parse_bare_hash_placeholder_no_checkbox() {
3965        // `- [#] text` — no checkbox, bare placeholder
3966        let item = parse_item_line("- [#] no checkbox").unwrap();
3967        assert_eq!(item.id, "");
3968        assert_eq!(item.state, PendingState::Open);
3969        assert_eq!(item.text, "no checkbox");
3970    }
3971
3972    #[test]
3973    fn parse_bare_hash_placeholder_gated() {
3974        // `- [/] [#] gated task` — gated with bare placeholder
3975        let item = parse_item_line("- [/] [#] gated task").unwrap();
3976        assert_eq!(item.id, "");
3977        assert_eq!(item.state, PendingState::Gated);
3978        assert_eq!(item.text, "gated task");
3979    }
3980
3981    #[test]
3982    fn backfill_strips_multiple_bare_placeholders() {
3983        // Multiple items each with bare [#] — all should get real IDs
3984        let body = "- [ ] [#] first task\n- [ ] [#] second task\n";
3985        let (new_body, changed) = backfill(body, DOC_ID, &ids());
3986        assert!(changed);
3987        let (_, items, _) = parse_items(&new_body);
3988        assert_eq!(items.len(), 2);
3989        assert!(!items[0].id.is_empty(), "first should have id");
3990        assert!(!items[1].id.is_empty(), "second should have id");
3991        assert_ne!(items[0].id, items[1].id, "ids should be unique");
3992        // No residual [#] in text
3993        assert!(
3994            !items[0].text.contains("[#]"),
3995            "first text has residual [#]: {}",
3996            items[0].text
3997        );
3998        assert!(
3999            !items[1].text.contains("[#]"),
4000            "second text has residual [#]: {}",
4001            items[1].text
4002        );
4003    }
4004
4005    #[test]
4006    fn backfill_preserves_long_custom_id() {
4007        let body = "- [ ] [#sdig2matrix] Fixture evidence matrix\n";
4008        let (new_body, changed) = backfill(body, DOC_ID, &ids());
4009        assert!(!changed);
4010        assert_eq!(new_body, body);
4011    }
4012
4013    #[test]
4014    fn backfill_idempotent_after_placeholder_strip() {
4015        // After stripping [#] and assigning ID, second backfill should be a no-op
4016        let body = "- [ ] [#] task\n";
4017        let (first_pass, _) = backfill(body, DOC_ID, &ids());
4018        let (second_pass, changed) = backfill(&first_pass, DOC_ID, &ids());
4019        assert!(
4020            !changed,
4021            "second backfill should be no-op, got: {}",
4022            second_pass
4023        );
4024        assert_eq!(first_pass, second_pass);
4025    }
4026
4027    #[test]
4028    fn op_add_dedup_case_sensitive() {
4029        // Different casing should NOT be considered duplicate
4030        let (body, _) = op_add("", "Wire Sift", DOC_ID, false).unwrap();
4031        let result = op_add(&body, "wire sift", DOC_ID, false);
4032        assert!(result.is_ok(), "different case should not be duplicate");
4033    }
4034
4035    #[test]
4036    fn op_add_dedup_across_states() {
4037        // Item exists as gated — adding same text as open should still dedup
4038        let (body, _) = op_add("", "deploy to prod", DOC_ID, true).unwrap();
4039        let err = op_add(&body, "deploy to prod", DOC_ID, false).unwrap_err();
4040        assert!(format!("{}", err).contains("duplicate"));
4041    }
4042
4043    #[test]
4044    fn op_add_gated_produces_gated_item() {
4045        let (new_body, id) = op_add("", "gated task", DOC_ID, true).unwrap();
4046        assert!(new_body.contains("[/]"), "expected [/] in: {}", new_body);
4047        assert!(new_body.contains(&format!("[#{}]", id)));
4048        assert!(new_body.contains("gated task"));
4049    }
4050
4051    #[test]
4052    fn op_add_returns_assigned_id() {
4053        let (body, id1) = op_add("", "task one", DOC_ID, false).unwrap();
4054        assert!(!id1.is_empty());
4055        assert!(body.contains(&format!("[#{}]", id1)));
4056        let (body2, id2) = op_add(&body, "task two", DOC_ID, false).unwrap();
4057        assert_ne!(id1, id2);
4058        assert!(body2.contains(&format!("[#{}]", id2)));
4059    }
4060
4061    #[test]
4062    fn op_add_extracts_inline_tag_as_id() {
4063        let (new_body, id) = op_add(
4064            "",
4065            "2026-05-11 [#nopatchbackopencode] OpenCode agent-doc turn completed",
4066            DOC_ID,
4067            false,
4068        )
4069        .unwrap();
4070        assert_eq!(id, "nopatchbackopencode");
4071        assert!(
4072            new_body.contains(
4073                "- [ ] [#nopatchbackopencode] 2026-05-11 OpenCode agent-doc turn completed"
4074            ),
4075            "got: {}",
4076            new_body
4077        );
4078    }
4079
4080    #[test]
4081    fn op_add_inline_tag_strips_from_text() {
4082        let (new_body, id) = op_add("", "fix [#mybug] the thing", DOC_ID, false).unwrap();
4083        assert_eq!(id, "mybug");
4084        assert!(
4085            new_body.contains("- [ ] [#mybug] fix the thing"),
4086            "got: {}",
4087            new_body
4088        );
4089    }
4090
4091    #[test]
4092    fn op_add_inline_tag_at_end() {
4093        let (new_body, id) = op_add("", "deploy the thing [#deploy1]", DOC_ID, false).unwrap();
4094        assert_eq!(id, "deploy1");
4095        assert!(
4096            new_body.contains("- [ ] [#deploy1] deploy the thing"),
4097            "got: {}",
4098            new_body
4099        );
4100    }
4101
4102    #[test]
4103    fn op_add_inline_tag_dedup_uses_cleaned_text() {
4104        let (body, _) = op_add("", "fix [#mybug] the thing", DOC_ID, false).unwrap();
4105        let err = op_add(&body, "fix [#mybug] the thing", DOC_ID, false).unwrap_err();
4106        assert!(
4107            format!("{}", err).contains("duplicate"),
4108            "expected duplicate error, got: {}",
4109            err
4110        );
4111    }
4112
4113    #[test]
4114    fn op_add_inline_tag_rejects_existing_id() {
4115        let body = "- [ ] [#mybug] existing task\n";
4116        let err = op_add(body, "fix [#mybug] new task", DOC_ID, false).unwrap_err();
4117        assert!(
4118            format!("{}", err).contains("inline tag id already exists"),
4119            "expected inline tag id already exists, got: {}",
4120            err
4121        );
4122    }
4123
4124    #[test]
4125    fn op_add_inline_tag_ignores_invalid_tag() {
4126        let (new_body, id) =
4127            op_add("", "see [#not-a-valid tag] because spaces", DOC_ID, false).unwrap();
4128        assert_ne!(id, "not-a-valid");
4129        assert!(
4130            new_body.contains("see [#not-a-valid tag] because spaces"),
4131            "got: {}",
4132            new_body
4133        );
4134    }
4135
4136    #[test]
4137    fn op_add_leading_prefix_takes_precedence_over_inline() {
4138        let (new_body, id) =
4139            op_add("", "[#myid] text with [#other] inline", DOC_ID, false).unwrap();
4140        assert_eq!(id, "myid");
4141        assert!(
4142            new_body.contains("- [ ] [#myid] text with [#other] inline"),
4143            "got: {}",
4144            new_body
4145        );
4146    }
4147
4148    #[test]
4149    fn op_done_marks_checked() {
4150        let body = "- [ ] [#a1b2] task\n";
4151        let new_body = op_done(body, "a1b2").unwrap();
4152        assert!(new_body.contains("[x]"));
4153    }
4154
4155    #[test]
4156    fn op_done_accepts_hash_prefixed_id() {
4157        let body = "- [ ] [#a1b2] task\n";
4158        let new_body = op_done(body, "#a1b2").unwrap();
4159        assert!(new_body.contains("- [x] [#a1b2] task"));
4160    }
4161
4162    #[test]
4163    fn op_done_unknown_id_errors() {
4164        let body = "- [ ] [#a1b2] task\n";
4165        assert!(op_done(body, "zzzz").is_err());
4166    }
4167
4168    #[test]
4169    fn op_edit_preserves_hash() {
4170        let body = "- [ ] [#a1b2] original\n";
4171        let new_body = op_edit(body, "a1b2", "updated").unwrap();
4172        assert!(new_body.contains("[#a1b2]"));
4173        assert!(new_body.contains("updated"));
4174        assert!(!new_body.contains("original"));
4175    }
4176
4177    #[test]
4178    fn op_edit_multiline_replaces_existing_continuation() {
4179        let body = concat!(
4180            "- [ ] [#tmuxcrash] parent task\n",
4181            "  - [ ] [#tmuxcrash-old1] stale child\n",
4182            "  - [ ] [#tmuxcrash-old2] stale child two\n",
4183            "- [ ] [#keep1] sibling task\n"
4184        );
4185        let new_body = op_edit(
4186            body,
4187            "tmuxcrash",
4188            "parent task revised\n  - fresh child\n  - fresh child two",
4189        )
4190        .unwrap();
4191        assert!(new_body.contains("[#tmuxcrash] parent task revised"));
4192        assert!(new_body.contains("  - fresh child\n"));
4193        assert!(new_body.contains("  - fresh child two"));
4194        assert!(!new_body.contains("stale child"));
4195        assert!(!new_body.contains("stale child two"));
4196        assert!(new_body.contains("  - fresh child two\n- [ ] [#keep1] sibling task"));
4197    }
4198
4199    #[test]
4200    fn op_edit_rejects_unindented_multiline_follow_up() {
4201        let body = "- [ ] [#a1b2] original\n";
4202        let err = op_edit(body, "a1b2", "updated\nsecond parent").unwrap_err();
4203        assert!(
4204            format!("{}", err)
4205                .contains("multiline text may only contain indented continuation lines"),
4206            "got: {err}"
4207        );
4208    }
4209
4210    #[test]
4211    fn op_clear_empties_items() {
4212        let body = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
4213        let new_body = op_clear(body).unwrap();
4214        assert!(!new_body.contains("[#"));
4215    }
4216
4217    #[test]
4218    fn op_clear_preserves_headers_and_spacing() {
4219        let body = concat!(
4220            "### Active\n",
4221            "- [ ] [#a1b2] one\n",
4222            "\n",
4223            "### Later\n",
4224            "- [ ] [#c3d4] two\n"
4225        );
4226        let new_body = op_clear(body).unwrap();
4227        assert_eq!(new_body, "### Active\n\n### Later\n");
4228    }
4229
4230    #[test]
4231    fn op_reorder_reorders_by_id() {
4232        let body = "- [ ] [#a1b2] first\n- [ ] [#c3d4] second\n- [ ] [#e5f6] third\n";
4233        let new_body = op_reorder(body, &["e5f6".to_string(), "a1b2".to_string()]).unwrap();
4234        let (_, items, _) = parse_items(&new_body);
4235        assert_eq!(items[0].id, "e5f6");
4236        assert_eq!(items[1].id, "a1b2");
4237        assert_eq!(items[2].id, "c3d4");
4238    }
4239
4240    #[test]
4241    fn op_reorder_keeps_headers_in_place() {
4242        let body = concat!(
4243            "### Active\n",
4244            "- [ ] [#a1b2] first\n",
4245            "### Later\n",
4246            "- [ ] [#c3d4] second\n",
4247            "- [ ] [#e5f6] third\n"
4248        );
4249        let new_body = op_reorder(body, &["e5f6".to_string(), "a1b2".to_string()]).unwrap();
4250        let lines: Vec<&str> = new_body.lines().collect();
4251        assert_eq!(lines[0], "### Active");
4252        assert!(lines[1].contains("[#e5f6] third"), "got: {}", new_body);
4253        assert_eq!(lines[2], "### Later");
4254        assert!(lines[3].contains("[#a1b2] first"), "got: {}", new_body);
4255        assert!(lines[4].contains("[#c3d4] second"), "got: {}", new_body);
4256    }
4257
4258    #[test]
4259    fn op_reorder_moves_nested_subtasks_with_parent_item() {
4260        let body = concat!(
4261            "- [ ] [#a1b2] first\n",
4262            "  - child a\n",
4263            "- [ ] [#c3d4] second\n",
4264            "  - child b\n"
4265        );
4266        let new_body = op_reorder(body, &["c3d4".to_string()]).unwrap();
4267        assert_eq!(
4268            new_body,
4269            concat!(
4270                "- [ ] [#c3d4] second\n",
4271                "  - child b\n",
4272                "- [ ] [#a1b2] first\n",
4273                "  - child a\n"
4274            )
4275        );
4276    }
4277
4278    #[test]
4279    fn op_reorder_renumbers_ordered_lists() {
4280        let body = "1. [ ] [#a1b2] first\n2. [ ] [#c3d4] second\n3. [ ] [#e5f6] third\n";
4281        let new_body = op_reorder(body, &["e5f6".to_string(), "a1b2".to_string()]).unwrap();
4282        let lines: Vec<&str> = new_body.lines().collect();
4283        assert_eq!(lines[0], "1. [ ] [#e5f6] third");
4284        assert_eq!(lines[1], "2. [ ] [#a1b2] first");
4285        assert_eq!(lines[2], "3. [ ] [#c3d4] second");
4286    }
4287
4288    #[test]
4289    fn op_reorder_unknown_id_errors() {
4290        let body = "- [ ] [#a1b2] one\n";
4291        assert!(op_reorder(body, &["zzzz".to_string()]).is_err());
4292    }
4293
4294    // ---- Phase 2: state matrix + gate/ungate ----
4295
4296    #[test]
4297    fn validate_transition_full_matrix() {
4298        use PendingOp::*;
4299        use PendingState::*;
4300        use TransitionResult::*;
4301
4302        // Open
4303        assert_eq!(validate_transition(Open, Gate).unwrap(), Transition(Gated));
4304        assert!(validate_transition(Open, Ungate).is_err());
4305        assert_eq!(
4306            validate_transition(Open, MarkDone).unwrap(),
4307            Transition(Done)
4308        );
4309
4310        // Gated
4311        assert_eq!(validate_transition(Gated, Gate).unwrap(), NoOp);
4312        assert_eq!(
4313            validate_transition(Gated, Ungate).unwrap(),
4314            Transition(Open)
4315        );
4316        assert_eq!(
4317            validate_transition(Gated, MarkDone).unwrap(),
4318            Transition(Done)
4319        );
4320
4321        // Done
4322        assert!(validate_transition(Done, Gate).is_err());
4323        assert!(validate_transition(Done, Ungate).is_err());
4324        assert_eq!(validate_transition(Done, MarkDone).unwrap(), NoOp);
4325    }
4326
4327    #[test]
4328    fn op_gate_open_to_gated() {
4329        let body = "- [ ] [#a1b2] task\n";
4330        let new_body = op_gate(body, "a1b2").unwrap();
4331        assert!(new_body.contains("- [/] [#a1b2]"));
4332    }
4333
4334    #[test]
4335    fn op_gate_gated_is_noop() {
4336        let body = "- [/] [#a1b2] task\n";
4337        let new_body = op_gate(body, "a1b2").unwrap();
4338        // No-op: body unchanged byte-for-byte.
4339        assert_eq!(new_body, body);
4340    }
4341
4342    #[test]
4343    fn op_gate_done_errors() {
4344        let body = "- [x] [#a1b2] task\n";
4345        let err = op_gate(body, "a1b2").unwrap_err();
4346        let msg = format!("{}", err);
4347        assert!(msg.contains("cannot gate Done item"), "got: {}", msg);
4348    }
4349
4350    #[test]
4351    fn op_gate_unknown_id_errors() {
4352        let body = "- [ ] [#a1b2] task\n";
4353        assert!(op_gate(body, "zzzz").is_err());
4354    }
4355
4356    #[test]
4357    fn op_ungate_gated_to_open() {
4358        let body = "- [/] [#a1b2] task\n";
4359        let new_body = op_ungate(body, "a1b2").unwrap();
4360        assert!(new_body.contains("- [ ] [#a1b2]"));
4361    }
4362
4363    #[test]
4364    fn op_ungate_open_errors() {
4365        let body = "- [ ] [#a1b2] task\n";
4366        let err = op_ungate(body, "a1b2").unwrap_err();
4367        let msg = format!("{}", err);
4368        assert!(msg.contains("cannot ungate Open"), "got: {}", msg);
4369    }
4370
4371    #[test]
4372    fn op_ungate_done_errors() {
4373        let body = "- [x] [#a1b2] task\n";
4374        let err = op_ungate(body, "a1b2").unwrap_err();
4375        let msg = format!("{}", err);
4376        assert!(msg.contains("cannot ungate Done"), "got: {}", msg);
4377    }
4378
4379    #[test]
4380    fn op_ungate_unknown_id_errors() {
4381        let body = "- [/] [#a1b2] task\n";
4382        assert!(op_ungate(body, "zzzz").is_err());
4383    }
4384
4385    #[test]
4386    fn op_gate_preserves_other_items_and_text() {
4387        let body = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two — gate: v0.32.6\n- [x] [#e5f6] three\n";
4388        let new_body = op_gate(body, "c3d4").unwrap();
4389        let (_, items, _) = parse_items(&new_body);
4390        assert_eq!(items[0].state, PendingState::Open);
4391        assert_eq!(items[1].state, PendingState::Gated);
4392        assert_eq!(items[1].text, "two — gate: v0.32.6");
4393        assert_eq!(items[2].state, PendingState::Done);
4394    }
4395
4396    #[test]
4397    fn generate_hash_deterministic_and_short() {
4398        let h = generate_hash("text", "doc", 0);
4399        assert_eq!(h.len(), 4);
4400        assert_eq!(h, generate_hash("text", "doc", 0));
4401        assert_ne!(h, generate_hash("text", "doc", 1));
4402    }
4403
4404    #[test]
4405    fn generate_hash_n_width4_matches_generate_hash() {
4406        // Width-4 output must be bit-identical to the pre-#14z4 formula so
4407        // existing docs don't churn their IDs on re-backfill.
4408        let cases = [
4409            ("text", "doc", 0u64),
4410            ("refactor preflight", "abc123", 7),
4411            ("", "", 42),
4412            ("long text with spaces", "doc_id_long", 99),
4413        ];
4414        for (t, d, c) in cases {
4415            assert_eq!(generate_hash(t, d, c), generate_hash_n(t, d, c, 4));
4416        }
4417    }
4418
4419    #[test]
4420    fn generate_hash_n_widths_have_correct_length() {
4421        for w in 4..=8 {
4422            let h = generate_hash_n("text", "doc", 0, w);
4423            assert_eq!(h.len(), w, "width {} produced len {}", w, h.len());
4424        }
4425        // Out-of-range widths clamp to [4, 8].
4426        assert_eq!(generate_hash_n("x", "y", 0, 1).len(), 4);
4427        assert_eq!(generate_hash_n("x", "y", 0, 20).len(), 8);
4428    }
4429
4430    #[test]
4431    fn generate_hash_n_wider_extends_shorter() {
4432        // A wider hash must start with the shorter hash as a prefix so
4433        // visible widening is explainable to humans.
4434        let h4 = generate_hash_n("text", "doc", 0, 4);
4435        let h5 = generate_hash_n("text", "doc", 0, 5);
4436        let h8 = generate_hash_n("text", "doc", 0, 8);
4437        assert!(h5.starts_with(&h4), "h5={} h4={}", h5, h4);
4438        assert!(h8.starts_with(&h4), "h8={} h4={}", h8, h4);
4439        assert!(h8.starts_with(&h5), "h8={} h5={}", h8, h5);
4440    }
4441
4442    #[test]
4443    fn assign_unique_hash_extends_on_collision() {
4444        // Pre-populate `taken` with the width-4 hash of "item". The next
4445        // assignment for the same text must either reuse the width-4 slot
4446        // with a different counter OR widen. Either way the result must
4447        // differ from the pre-populated value and be valid.
4448        let h4 = generate_hash_n("item", "doc", 0, 4);
4449        let mut taken = HashSet::new();
4450        taken.insert(h4.clone());
4451        let id = assign_unique_hash("item", "doc", &taken);
4452        assert_ne!(id, h4);
4453        assert!((4..=8).contains(&id.len()));
4454    }
4455
4456    #[test]
4457    fn assign_unique_hash_widens_when_counter_exhausted_at_width4() {
4458        // Pre-populate `taken` with EVERY width-4 hash the retry loop would
4459        // try at counters 0..=3. Assignment must widen to 5 chars.
4460        let mut taken = HashSet::new();
4461        for c in 0..=3u64 {
4462            taken.insert(generate_hash_n("x", "d", c, 4));
4463        }
4464        let id = assign_unique_hash("x", "d", &taken);
4465        assert!(!taken.contains(&id));
4466        // Either width-4 (an untried counter) or width-5+. Accept both —
4467        // the important invariant is uniqueness, not forced widening.
4468        assert!((4..=8).contains(&id.len()));
4469    }
4470
4471    #[test]
4472    fn backfill_assigns_collision_free_ids_under_pressure() {
4473        // Stress test: backfill 50 items. All must get unique 4..=8-char ids.
4474        let mut body = String::new();
4475        for i in 0..50 {
4476            body.push_str(&format!("- item {}\n", i));
4477        }
4478        let (out, changed) = backfill(&body, "doc", &HashSet::new());
4479        assert!(changed);
4480        let (_, items, _) = parse_items(&out);
4481        assert_eq!(items.len(), 50);
4482        let ids: HashSet<String> = items.iter().map(|i| i.id.clone()).collect();
4483        assert_eq!(ids.len(), 50, "ids must be unique");
4484        for id in &ids {
4485            assert!(
4486                (4..=8).contains(&id.len()),
4487                "id {} has width {}",
4488                id,
4489                id.len()
4490            );
4491        }
4492    }
4493
4494    // ---- Typed gates ----
4495
4496    #[test]
4497    fn parse_typed_gate_release() {
4498        let body = "- [/release] [#a1b2] Release v0.32.4\n";
4499        let (_, items, _) = parse_items(body);
4500        assert_eq!(items.len(), 1);
4501        assert_eq!(items[0].state, PendingState::Gated);
4502        assert_eq!(items[0].gate_type, Some("release".to_string()));
4503        assert_eq!(items[0].text, "Release v0.32.4");
4504    }
4505
4506    #[test]
4507    fn parse_typed_gate_deploy() {
4508        let body = "- [/deploy] [#c3d4] Push CDN config\n";
4509        let (_, items, _) = parse_items(body);
4510        assert_eq!(items[0].state, PendingState::Gated);
4511        assert_eq!(items[0].gate_type, Some("deploy".to_string()));
4512    }
4513
4514    #[test]
4515    fn parse_untyped_gate_has_no_gate_type() {
4516        let body = "- [/] [#a1b2] waiting\n";
4517        let (_, items, _) = parse_items(body);
4518        assert_eq!(items[0].state, PendingState::Gated);
4519        assert_eq!(items[0].gate_type, None);
4520    }
4521
4522    #[test]
4523    fn parse_open_has_no_gate_type() {
4524        let body = "- [ ] [#a1b2] task\n";
4525        let (_, items, _) = parse_items(body);
4526        assert_eq!(items[0].gate_type, None);
4527    }
4528
4529    #[test]
4530    fn render_typed_gate() {
4531        let item = PendingItem {
4532            marker: PendingListMarker::Bullet,
4533            id: "a1b2".to_string(),
4534            state: PendingState::Gated,
4535            gate_type: Some("release".to_string()),
4536            text: "Release v0.32.4".to_string(),
4537            continuation: String::new(),
4538        };
4539        assert_eq!(item.render(), "- [/release] [#a1b2] Release v0.32.4");
4540    }
4541
4542    #[test]
4543    fn render_roundtrip_typed_gate() {
4544        let body = "- [/release] [#a1b2] Release v0.32.4\n- [/deploy] [#c3d4] Push\n- [/] [#e5f6] Generic\n";
4545        let (p, items, post) = parse_items(body);
4546        let out = render_items(&p, &items, &post);
4547        assert_eq!(out, body);
4548    }
4549
4550    #[test]
4551    fn op_resolve_gate_flips_matching() {
4552        let body = "- [/release] [#a1b2] Release v0.32.4\n- [/deploy] [#c3d4] Deploy\n- [/] [#e5f6] Generic gate\n";
4553        let (new_body, resolved) = op_resolve_gate(body, "release");
4554        assert_eq!(resolved, vec!["a1b2"]);
4555        let (_, items, _) = parse_items(&new_body);
4556        assert_eq!(items[0].state, PendingState::Done); // was [/release]
4557        assert_eq!(items[0].gate_type, None); // cleared
4558        assert_eq!(items[1].state, PendingState::Gated); // [/deploy] untouched
4559        assert_eq!(items[1].gate_type, Some("deploy".to_string()));
4560        assert_eq!(items[2].state, PendingState::Gated); // [/] untouched
4561        assert_eq!(items[2].gate_type, None);
4562    }
4563
4564    #[test]
4565    fn op_resolve_gate_no_match() {
4566        let body = "- [/release] [#a1b2] Release\n- [/] [#c3d4] Generic\n";
4567        let (new_body, resolved) = op_resolve_gate(body, "deploy");
4568        assert!(resolved.is_empty());
4569        assert_eq!(new_body, body);
4570    }
4571
4572    #[test]
4573    fn op_resolve_gate_ignores_untyped() {
4574        let body = "- [/] [#a1b2] Generic gate\n";
4575        let (_, resolved) = op_resolve_gate(body, "release");
4576        assert!(resolved.is_empty());
4577    }
4578
4579    #[test]
4580    fn op_resolve_gate_multiple_same_type() {
4581        let body = "- [/release] [#a1b2] First\n- [/release] [#c3d4] Second\n";
4582        let (_, resolved) = op_resolve_gate(body, "release");
4583        assert_eq!(resolved, vec!["a1b2", "c3d4"]);
4584    }
4585
4586    #[test]
4587    fn op_set_gate_type_on_gated() {
4588        let body = "- [/] [#a1b2] Release v0.32.4\n";
4589        let new_body = op_set_gate_type(body, "a1b2", "release").unwrap();
4590        assert!(new_body.contains("[/release]"));
4591        let (_, items, _) = parse_items(&new_body);
4592        assert_eq!(items[0].gate_type, Some("release".to_string()));
4593    }
4594
4595    #[test]
4596    fn op_set_gate_type_errors_on_open() {
4597        let body = "- [ ] [#a1b2] task\n";
4598        assert!(op_set_gate_type(body, "a1b2", "release").is_err());
4599    }
4600
4601    #[test]
4602    fn op_set_gate_verify_round_trips_predicate() {
4603        let body = "- [/] [#saev] early-ack live verify\n";
4604        let new_body = op_set_gate_verify(
4605            body,
4606            "saev",
4607            "verify=ops_log:early_ack_pending;disproof=false ack-timeout",
4608            1749526200,
4609        )
4610        .unwrap();
4611        let (_, items, _) = parse_items(&new_body);
4612        // Still gated, untyped checkbox preserved.
4613        assert_eq!(items[0].state, PendingState::Gated);
4614        assert_eq!(items[0].gate_type, None);
4615        let pred = crate::gate_verify::parse_gate_predicate(&items[0].text).unwrap();
4616        assert_eq!(pred.verify.as_deref(), Some("early_ack_pending"));
4617        assert_eq!(pred.disproof.as_deref(), Some("false ack-timeout"));
4618        assert_eq!(pred.set_at, Some(1749526200));
4619    }
4620
4621    #[test]
4622    fn op_set_gate_verify_errors_on_open() {
4623        let body = "- [ ] [#a1b2] task\n";
4624        assert!(op_set_gate_verify(body, "a1b2", "verify=ops_log:m", 1).is_err());
4625    }
4626
4627    #[test]
4628    fn op_set_gate_verify_errors_on_empty_spec() {
4629        let body = "- [/] [#a1b2] task\n";
4630        assert!(op_set_gate_verify(body, "a1b2", "noop=1", 1).is_err());
4631    }
4632
4633    #[test]
4634    fn op_set_gate_type_errors_on_done() {
4635        let body = "- [x] [#a1b2] task\n";
4636        assert!(op_set_gate_type(body, "a1b2", "release").is_err());
4637    }
4638
4639    #[test]
4640    fn op_set_gate_type_replaces_existing() {
4641        let body = "- [/release] [#a1b2] task\n";
4642        let new_body = op_set_gate_type(body, "a1b2", "deploy").unwrap();
4643        assert!(new_body.contains("[/deploy]"));
4644        assert!(!new_body.contains("[/release]"));
4645    }
4646
4647    #[test]
4648    fn parse_typed_gate_case_insensitive() {
4649        let body = "- [/Release] [#a1b2] task\n";
4650        let (_, items, _) = parse_items(body);
4651        assert_eq!(items[0].gate_type, Some("release".to_string()));
4652    }
4653
4654    #[test]
4655    fn parse_typed_gate_with_hyphens_underscores() {
4656        let body =
4657            "- [/code-review] [#a1b2] Review PR\n- [/pre_release] [#c3d4] Pre-release check\n";
4658        let (_, items, _) = parse_items(body);
4659        assert_eq!(items[0].gate_type, Some("code-review".to_string()));
4660        assert_eq!(items[1].gate_type, Some("pre_release".to_string()));
4661    }
4662
4663    #[test]
4664    fn detect_shadow_open_items_classifies_duplicate_and_shadow_only_ids() {
4665        let doc = concat!(
4666            "## Pending / Not Built\n\n",
4667            "<!-- agent:backlog -->\n",
4668            "- [ ] [#live1] Keep in backlog\n",
4669            "- [/] [#gate1] Gated live item\n",
4670            "<!-- /agent:backlog -->\n\n",
4671            "<!-- parked copy\n",
4672            "- [ ] [#live1] Keep in backlog\n",
4673            "- [ ] [#lost1] Drifted out of backlog\n",
4674            "- [x] [#done1] Already done\n",
4675            "-->\n"
4676        );
4677
4678        let report = detect_shadow_open_items(doc).unwrap();
4679        assert_eq!(
4680            report
4681                .duplicated_in_live_backlog
4682                .iter()
4683                .map(|item| item.id.as_str())
4684                .collect::<Vec<_>>(),
4685            vec!["live1"]
4686        );
4687        assert_eq!(
4688            report
4689                .shadow_only
4690                .iter()
4691                .map(|item| item.id.as_str())
4692                .collect::<Vec<_>>(),
4693            vec!["lost1"]
4694        );
4695    }
4696
4697    #[test]
4698    fn detect_shadow_open_items_ignores_agent_queue_id_heads() {
4699        // #qheadsync orphan-shadow: a reaped id's lingering queue head (`[#id]: note`
4700        // form) or any #mirrorall-mirrored `do [#id]` head must NOT be classified as
4701        // a shadow "open backlog item outside the live backlog" — the queue is a
4702        // legitimate component, not stray prose. A genuine shadow item in a commented
4703        // section is still caught.
4704        let doc = concat!(
4705            "<!-- agent:backlog -->\n",
4706            "- [ ] [#live1] Keep in backlog\n",
4707            "<!-- /agent:backlog -->\n\n",
4708            "<!-- agent:queue -->\n",
4709            "- do [#live1]\n",
4710            "- [#reaped1]: 0.2.170 installed\n",
4711            "- do [#reaped2]\n",
4712            "<!-- /agent:queue -->\n\n",
4713            "<!-- parked copy\n",
4714            "- [ ] [#lost1] Drifted out of backlog\n",
4715            "-->\n",
4716        );
4717
4718        let report = detect_shadow_open_items(doc).unwrap();
4719        // No queue head — reaped or mirrored — is flagged.
4720        assert!(
4721            report
4722                .shadow_only
4723                .iter()
4724                .all(|item| item.id != "reaped1" && item.id != "reaped2"),
4725            "reaped/mirrored queue id heads must not be shadow_only: {:?}",
4726            report.shadow_only
4727        );
4728        assert!(
4729            report.duplicated_in_live_backlog.is_empty(),
4730            "queue is excluded from the scan, so the mirrored do [#live1] head is not a duplicate: {:?}",
4731            report.duplicated_in_live_backlog
4732        );
4733        // The genuine commented-out shadow item is still caught.
4734        assert_eq!(
4735            report
4736                .shadow_only
4737                .iter()
4738                .map(|item| item.id.as_str())
4739                .collect::<Vec<_>>(),
4740            vec!["lost1"]
4741        );
4742    }
4743
4744    #[test]
4745    fn detect_shadow_open_items_ignores_indented_nested_ids() {
4746        let doc = concat!(
4747            "<!-- agent:backlog -->\n",
4748            "- [ ] [#live1] Parent task\n",
4749            "  - [ ] [#nested1] Nested checklist item\n",
4750            "<!-- /agent:backlog -->\n"
4751        );
4752
4753        let report = detect_shadow_open_items(doc).unwrap();
4754        assert!(report.duplicated_in_live_backlog.is_empty());
4755        assert!(report.shadow_only.is_empty());
4756    }
4757
4758    #[test]
4759    fn extract_items_by_id_preserves_nested_subtasks() {
4760        let body = concat!(
4761            "### Active\n",
4762            "- [ ] [#move1] Parent task\n",
4763            "  - child dependency\n",
4764            "- [ ] [#keep1] Keep task\n"
4765        );
4766        let (remaining, moved, matched) =
4767            extract_items_by_id(body, &["move1".to_string()]).unwrap();
4768        assert_eq!(matched, vec!["move1".to_string()]);
4769        assert!(remaining.contains("### Active\n"));
4770        assert!(!remaining.contains("[#move1]"));
4771        assert!(remaining.contains("[#keep1] Keep task"));
4772        assert_eq!(
4773            moved,
4774            concat!("- [ ] [#move1] Parent task\n", "  - child dependency\n")
4775        );
4776    }
4777
4778    #[test]
4779    fn extract_items_by_id_preserves_ordered_list_style() {
4780        let body = concat!(
4781            "1. [ ] [#move1] Parent task\n",
4782            "2. [ ] [#keep1] Keep task\n",
4783            "3. [ ] [#keep2] Keep task two\n"
4784        );
4785        let (remaining, moved, matched) =
4786            extract_items_by_id(body, &["move1".to_string()]).unwrap();
4787        assert_eq!(matched, vec!["move1".to_string()]);
4788        assert_eq!(moved, "1. [ ] [#move1] Parent task\n");
4789        assert_eq!(
4790            remaining,
4791            concat!(
4792                "1. [ ] [#keep1] Keep task\n",
4793                "2. [ ] [#keep2] Keep task two\n"
4794            )
4795        );
4796    }
4797
4798    #[test]
4799    fn detect_shadow_open_items_ignores_icebox_and_code_blocks() {
4800        let doc = concat!(
4801            "## Pending / Not Built\n\n",
4802            "<!-- agent:backlog -->\n",
4803            "- [ ] [#live1] Keep in backlog\n",
4804            "<!-- /agent:backlog -->\n\n",
4805            "<!-- agent:icebox -->\n",
4806            "- [ ] [#cold1] Intentionally parked\n",
4807            "<!-- /agent:icebox -->\n\n",
4808            "```md\n",
4809            "- [ ] [#code1] Example only\n",
4810            "```\n"
4811        );
4812
4813        let report = detect_shadow_open_items(doc).unwrap();
4814        assert!(report.duplicated_in_live_backlog.is_empty());
4815        assert!(report.shadow_only.is_empty());
4816    }
4817
4818    #[test]
4819    fn detect_shadow_open_items_ignores_exchange_transcript_items() {
4820        let doc = concat!(
4821            "## Exchange\n\n",
4822            "<!-- agent:exchange patch=append -->\n",
4823            "❯ What are #next-steps to implement the planned ipc features?\n\n",
4824            "### Re: What are #next-steps to implement the planned ipc features?\n\n",
4825            "1. [#ipc1] finalize the lazily-serde contract so message shapes are stable.\n\n",
4826            "### Session Summary\n\n",
4827            "*Compacted.*\n\n",
4828            "Icebox:\n",
4829            "- [ ] [#cold1] Intentionally parked\n",
4830            "- [ ] [#cold2] Still parked\n\n",
4831            "❯ #code-review\n",
4832            "<!-- agent:boundary:test -->\n",
4833            "<!-- /agent:exchange -->\n\n",
4834            "<!-- agent:backlog -->\n",
4835            "<!-- /agent:backlog -->\n\n",
4836            "<!-- agent:icebox -->\n",
4837            "- [ ] [#ipc2] Live backlog item\n",
4838            "- [ ] [#cold1] Intentionally parked\n",
4839            "- [ ] [#cold2] Still parked\n",
4840            "<!-- /agent:icebox -->\n"
4841        );
4842
4843        let report = detect_shadow_open_items(doc).unwrap();
4844        assert!(report.duplicated_in_live_backlog.is_empty());
4845        assert!(report.shadow_only.is_empty());
4846    }
4847
4848    #[test]
4849    fn detect_dropped_from_history_catches_missing_item() {
4850        let baseline = concat!(
4851            "<!-- agent:backlog -->\n",
4852            "- [ ] [#keep1] Still here\n",
4853            "- [ ] [#gone1] Was open in baseline\n",
4854            "- [ ] [#gone2] Also open in baseline\n",
4855            "<!-- /agent:backlog -->\n"
4856        );
4857        let current = concat!(
4858            "<!-- agent:backlog -->\n",
4859            "- [ ] [#keep1] Still here\n",
4860            "<!-- /agent:backlog -->\n"
4861        );
4862        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
4863        let ids: Vec<&str> = report.dropped.iter().map(|i| i.id.as_str()).collect();
4864        assert_eq!(ids, vec!["gone1", "gone2"]);
4865    }
4866
4867    #[test]
4868    fn detect_dropped_from_history_allows_done_in_live() {
4869        let baseline = concat!(
4870            "<!-- agent:backlog -->\n",
4871            "- [ ] [#item1] Was open\n",
4872            "<!-- /agent:backlog -->\n"
4873        );
4874        let current = concat!(
4875            "<!-- agent:backlog -->\n",
4876            "- [x] [#item1] Now done\n",
4877            "<!-- /agent:backlog -->\n"
4878        );
4879        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
4880        assert!(report.dropped.is_empty());
4881    }
4882
4883    #[test]
4884    fn detect_dropped_from_history_allows_done_ids() {
4885        let baseline = concat!(
4886            "<!-- agent:backlog -->\n",
4887            "- [ ] [#item1] Was open\n",
4888            "<!-- /agent:backlog -->\n"
4889        );
4890        let current = concat!("<!-- agent:backlog -->\n", "<!-- /agent:backlog -->\n");
4891        let mut done = HashSet::new();
4892        done.insert("item1".to_string());
4893        let report = detect_dropped_from_history(current, baseline, &done).unwrap();
4894        assert!(report.dropped.is_empty());
4895    }
4896
4897    #[test]
4898    fn detect_dropped_from_history_allows_completed_archive_id() {
4899        let baseline = concat!(
4900            "<!-- agent:backlog -->\n",
4901            "- [ ] [#item1] Was open\n",
4902            "<!-- /agent:backlog -->\n"
4903        );
4904        let current = concat!(
4905            "<!-- agent:backlog -->\n",
4906            "<!-- /agent:backlog -->\n\n",
4907            "<!-- agent:done -->\n",
4908            "- 2026-05-10 [#item1] Was open\n",
4909            "<!-- /agent:done -->\n"
4910        );
4911        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
4912        assert!(report.dropped.is_empty());
4913    }
4914
4915    #[test]
4916    fn detect_dropped_from_history_rejects_removed_completed_archive_alias() {
4917        let baseline = concat!(
4918            "<!-- agent:backlog -->\n",
4919            "- [ ] [#item1] Was open\n",
4920            "<!-- /agent:backlog -->\n"
4921        );
4922        let current = concat!(
4923            "<!-- agent:backlog -->\n",
4924            "<!-- /agent:backlog -->\n\n",
4925            "<!-- agent:pending-done -->\n",
4926            "- 2026-05-10 [#item1] Was open\n",
4927            "<!-- /agent:pending-done -->\n"
4928        );
4929        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
4930        assert_eq!(report.dropped.len(), 1);
4931        assert_eq!(report.dropped[0].id, "item1");
4932    }
4933
4934    #[test]
4935    fn detect_dropped_from_history_allows_icebox() {
4936        let baseline = concat!(
4937            "<!-- agent:backlog -->\n",
4938            "- [ ] [#item1] Was open\n",
4939            "<!-- /agent:backlog -->\n"
4940        );
4941        let current = concat!(
4942            "<!-- agent:backlog -->\n",
4943            "<!-- /agent:backlog -->\n",
4944            "<!-- agent:icebox -->\n",
4945            "- [ ] [#item1] Archived\n",
4946            "<!-- /agent:icebox -->\n"
4947        );
4948        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
4949        assert!(report.dropped.is_empty());
4950    }
4951
4952    #[test]
4953    fn detect_dropped_from_history_allows_shadow() {
4954        let baseline = concat!(
4955            "<!-- agent:backlog -->\n",
4956            "- [ ] [#item1] Was open\n",
4957            "<!-- /agent:backlog -->\n"
4958        );
4959        let current = concat!(
4960            "<!-- agent:backlog -->\n",
4961            "<!-- /agent:backlog -->\n\n",
4962            "<!-- parked\n",
4963            "- [ ] [#item1] Drifted to shadow\n",
4964            "-->\n"
4965        );
4966        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
4967        assert!(report.dropped.is_empty());
4968    }
4969
4970    #[test]
4971    fn detect_dropped_from_history_ignores_baseline_done_items() {
4972        let baseline = concat!(
4973            "<!-- agent:backlog -->\n",
4974            "- [x] [#done1] Already done in baseline\n",
4975            "- [/] [#gate1] Gated in baseline\n",
4976            "<!-- /agent:backlog -->\n"
4977        );
4978        let current = concat!("<!-- agent:backlog -->\n", "<!-- /agent:backlog -->\n");
4979        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
4980        let ids: Vec<&str> = report.dropped.iter().map(|i| i.id.as_str()).collect();
4981        assert_eq!(ids, vec!["gate1"]);
4982    }
4983
4984    #[test]
4985    fn detect_dropped_from_history_no_baseline_backlog() {
4986        let baseline = "# Just a document\nNo backlog here.\n";
4987        let current = concat!(
4988            "<!-- agent:backlog -->\n",
4989            "- [ ] [#item1] New item\n",
4990            "<!-- /agent:backlog -->\n"
4991        );
4992        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
4993        assert!(report.dropped.is_empty());
4994    }
4995
4996    #[test]
4997    fn detect_dropped_from_history_ignores_code_blocks_in_current() {
4998        let baseline = concat!(
4999            "<!-- agent:backlog -->\n",
5000            "- [ ] [#item1] Was open\n",
5001            "<!-- /agent:backlog -->\n"
5002        );
5003        let current = concat!(
5004            "<!-- agent:backlog -->\n",
5005            "<!-- /agent:backlog -->\n\n",
5006            "```md\n",
5007            "- [ ] [#item1] In code block only\n",
5008            "```\n"
5009        );
5010        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
5011        let ids: Vec<&str> = report.dropped.iter().map(|i| i.id.as_str()).collect();
5012        assert_eq!(ids, vec!["item1"]);
5013    }
5014}