Skip to main content

agent_doc_element_backlog/
backlog.rs

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