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