cliban 0.13.0

Self-hosted, agent-first kanban for the terminal: flat CLI, ratatui board, SQLite storage
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
//! Parses and mutates the cliban issue/milestone description markdown contract.
//!
//! All functions are pure: input string in, output string + error out. The store
//! layer wraps these in SQL transactions so mutations are atomic.

use chrono::{DateTime, Utc};

/// Timestamp format for `## Activity Log` entries: RFC-3339 with minute
/// precision, UTC, "Z" suffix.
const ACTIVITY_LOG_TIME_FORMAT: &str = "%Y-%m-%dT%H:%MZ";

/// Locating an H2 section now lives in `cliban_core::sections`, because the
/// Linear bridge needs the identical boundaries when it replaces `## Spec`
/// without disturbing `## Plan`. Re-exported here so every call site in this
/// crate reads the same as it always did.
pub use cliban_core::sections::{find_section, task_number};

/// Constructs a descmd error string with the structured `descmd: ` prefix.
fn errf(msg: String) -> String {
    format!("descmd: {msg}")
}

/// The `### Task N:` headings of a plan body, in order, as
/// (number, heading range). Anything else at H3 is not a task and is skipped
/// here — `lint` is what reports it.
///
/// Like sections, these come from the markdown grammar rather than a line
/// prefix, so a `### Task 2:` quoted inside a fenced code block in one task's
/// body no longer ends that task.
pub fn task_headings(plan_body: &str) -> Vec<(i32, std::ops::Range<usize>)> {
    cliban_core::sections::h3_headings(plan_body)
        .into_iter()
        .filter_map(|h| task_number(&h.text).map(|n| (n, h.range)))
        .collect()
}

/// Locates the N-th task within a plan-section body. Tasks are identified by an
/// H3 heading of the form "### Task <N>:". Returns the [start, end) byte offsets
/// of the task's body — content AFTER the heading line, up to (but excluding)
/// the next H3 heading or end of input.
pub fn find_task(plan_body: &str, n: i32) -> (usize, usize, bool) {
    // End at the next H3 of any kind, task-shaped or not: a malformed heading
    // still visually opens a new block, and swallowing it into the previous
    // task would let `tick` reach steps the author filed under something else.
    let all_h3 = cliban_core::sections::h3_headings(plan_body);
    for (i, h) in all_h3.iter().enumerate() {
        if task_number(&h.text) == Some(n) {
            let start = h.range.end;
            let end = all_h3
                .get(i + 1)
                .map_or(plan_body.len(), |next| next.range.start);
            return (start, end, true);
        }
    }
    (0, 0, false)
}

/// Number of `### Task N:` headings inside `## Plan` — what lets `promote`
/// infer `--task` when the plan has exactly one.
pub fn count_tasks(desc: &str) -> usize {
    let (start, end, ok) = find_section(desc, "Plan");
    if !ok {
        return 0;
    }
    task_headings(&desc[start..end]).len()
}

/// One bite-sized step line in a Task body.
pub struct Step {
    /// 1-based step index within the task; reserved for future step-level
    /// mutations.
    #[allow(dead_code)]
    pub index: i32,
    /// Current checkbox state.
    pub checked: bool,
    /// Byte offset of the line start within the task body.
    pub line_start: usize,
    /// Byte offset just past the trailing newline (or len(task) if last).
    /// Reserved for future step-level mutations.
    #[allow(dead_code)]
    pub line_end: usize,
    /// The full line content including the trailing newline (and \r if input
    /// uses CRLF) — preserved verbatim for round-trip mutations.
    pub raw: String,
}

/// Locates the M-th step line in a task body. Steps are top-level GFM checkbox
/// list items: markdown list items whose first line begins with "- [ ] " or
/// "- [x] ". Child bullets are nested items, not steps, and are ignored.
///
/// `raw` is deliberately only the item's *first line*, not its whole markdown
/// range: a step may own a sublist or a wrapped continuation, and `tick`
/// splices over `raw`. Taking the full item range would flip the checkbox and
/// delete the step's children with it.
pub fn find_step(task_body: &str, m: i32) -> Option<Step> {
    let mut count = 0i32;
    for range in cliban_core::sections::top_level_list_items(task_body) {
        let item = &task_body[range.clone()];
        let first_line_len = item.find('\n').map_or(item.len(), |i| i + 1);
        let raw = &item[..first_line_len];
        if raw.starts_with("- [ ] ") || raw.starts_with("- [x] ") {
            count += 1;
            if count == m {
                return Some(Step {
                    index: m,
                    checked: raw.starts_with("- [x] "),
                    line_start: range.start,
                    line_end: range.start + first_line_len,
                    raw: raw.to_string(),
                });
            }
        }
    }
    None
}

/// The two success shapes of [`tick_step`]: the step was flipped, or it was
/// already in the desired state. An already-checked step is NOT an error — a
/// caller retrying after a timeout is asking for a state the plan already has —
/// but it is a distinct, detectable case so the CLI can say "nothing to do"
/// instead of pretending it wrote.
pub enum TickOutcome {
    /// The step was unchecked; here is the rewritten description.
    Ticked(String),
    /// The step is already checked — the description is untouched.
    AlreadyChecked,
}

/// Flips the M-th step of task N in the description from "- [ ] ..." to
/// "- [x] ...". Returns [`TickOutcome::Ticked`] with the rewritten description,
/// or [`TickOutcome::AlreadyChecked`] when the box is already ticked (desired
/// state, no rewrite). Returns an error only for genuinely wrong targets: the
/// `## Plan` section is missing, the task is missing, or the step is missing.
pub fn tick_step(desc: &str, task_n: i32, step_m: i32) -> Result<TickOutcome, String> {
    let (plan_start, plan_end, ok) = find_section(desc, "Plan");
    if !ok {
        return Err(errf("no ## Plan section".to_string()));
    }
    let plan_body = &desc[plan_start..plan_end];
    let (task_start, task_end, ok) = find_task(plan_body, task_n);
    if !ok {
        return Err(errf(format!("no Task {task_n} in ## Plan")));
    }
    let task_body = &plan_body[task_start..task_end];
    let step = match find_step(task_body, step_m) {
        Some(s) => s,
        None => return Err(errf(format!("no Step {step_m} in Task {task_n}"))),
    };
    if step.checked {
        return Ok(TickOutcome::AlreadyChecked);
    }
    // Absolute offset of the step line inside the original desc.
    let abs = plan_start + task_start + step.line_start;
    // The step line is guaranteed to start with "- [ ] ".
    let new_line = format!("- [x] {}", &step.raw["- [ ] ".len()..]);
    Ok(TickOutcome::Ticked(format!(
        "{}{}{}",
        &desc[..abs],
        new_line,
        &desc[abs + step.raw.len()..]
    )))
}

/// After a description splice, assert the section structure changed only as
/// intended: no H2 anchor appears or disappears (except `writing` itself,
/// when `may_create`), and every section other than `writing` still resolves
/// to byte-identical content. The whole-document check that catches what
/// payload sanitization cannot: an unclosed fence swallowing later sections,
/// and fence-state interactions between the payload and what the description
/// already holds.
pub fn check_section_structure(
    before: &str,
    after: &str,
    writing: &str,
    may_create: bool,
) -> Result<(), String> {
    let mut expected = cliban_core::sections::h2_anchors(before);
    if may_create && !expected.iter().any(|a| a == writing) {
        expected.push(writing.to_string());
    }
    let got = cliban_core::sections::h2_anchors(after);
    let count = |v: &[String], s: &str| v.iter().filter(|x| x.as_str() == s).count();
    if let Some(new_anchor) = got.iter().find(|a| count(&got, a) > count(&expected, a)) {
        return Err(format!(
            "the write would create a top-level \"## {new_anchor}\" section boundary, \
             splitting ## {writing}; demote the heading to ### or deeper, or quote \
             it in a fenced code block"
        ));
    }
    if let Some(lost) = expected.iter().find(|e| count(&expected, e) > count(&got, e)) {
        return Err(format!(
            "the write would leave the \"## {lost}\" section unreachable behind an \
             unclosed code fence; close the fence — if the description already \
             carries the open fence, repair it first with a full-description \
             edit (--description-file)"
        ));
    }
    // A same-named phantom plus a swallow cancel out in the counts above, so
    // also require every other section to resolve to the same bytes.
    for anchor in expected.iter().filter(|a| a.as_str() != writing) {
        let (s1, e1, ok1) = find_section(before, anchor);
        let (s2, e2, ok2) = find_section(after, anchor);
        if ok1
            && (!ok2
                || before[s1..e1].trim_end_matches('\n') != after[s2..e2].trim_end_matches('\n'))
        {
            return Err(format!(
                "the write would displace the \"## {anchor}\" section — afterwards it \
                 would no longer resolve to its current content; demote any heading \
                 in the payload and close any code fence"
            ));
        }
    }
    Ok(())
}

/// Appends a chronological entry to the "## Activity Log" section. The entry is
/// formatted as "- <UTC-ts> — <msg>" with the timestamp rendered as minute
/// precision RFC-3339 UTC. If the section does not exist, one is created at the
/// end of the description.
///
/// Normalization: the resulting section always ends with exactly one trailing
/// newline when Activity Log is the last section in desc, or two trailing
/// newlines when followed by another section (the second newline being the
/// inter-section blank line preserved per markdown convention).
pub fn append_activity_log(desc: &str, msg: &str, ts: DateTime<Utc>) -> Result<String, String> {
    let stamp = ts.format(ACTIVITY_LOG_TIME_FORMAT).to_string();
    // Sanitize by structure rather than reject: indenting every continuation
    // line makes the whole message part of this list item, so an inner `## `
    // is not a document-level heading and an unclosed fence closes at the
    // item boundary. The invariant checks below stay as the backstop.
    let msg = msg.trim_end_matches('\n');
    let entry = format!("- {stamp} — {}\n", msg.replace('\n', "\n  "));
    let (start, end, ok) = find_section(desc, "Activity Log");
    let new_desc = if !ok {
        let sep = if desc.is_empty() {
            ""
        } else if !desc.ends_with('\n') {
            "\n\n"
        } else {
            "\n"
        };
        format!("{desc}{sep}## Activity Log\n\n{entry}")
    } else {
        // Insert the entry at the end of the section body. Strip trailing
        // newlines, append the new entry (which ends with \n), then restore
        // the blank-line separator if not the last section.
        let body = &desc[start..end];
        let trimmed = body.trim_end_matches('\n');
        let mut rebuilt = format!("{trimmed}\n{entry}");
        if end < desc.len() {
            rebuilt.push('\n');
        }
        format!("{}{}{}", &desc[..start], rebuilt, &desc[end..])
    };

    // Validate the artifact, not the fragment: whether a line is a heading
    // depends on the whole document's fence state. Structure may change only
    // by creating ## Activity Log itself; the entry must also be readable
    // where readers look (checked below).
    check_section_structure(desc, &new_desc, "Activity Log", true)?;
    // And the entry itself must be readable: appended after an unclosed fence
    // already in ## Activity Log it would be fenced content, reported as
    // written and visible to no reader.
    let first_line = msg.lines().next().unwrap_or("");
    // Compare modulo trailing whitespace: list_item_body trims a single-line
    // item's tail, so a message with a trailing space would never match.
    let head = format!("{stamp} — {first_line}");
    let head = head.trim_end();
    let visible = |d: &str| {
        activity_entries(d)
            .iter()
            .filter(|e| e.head.trim_end() == head)
            .count()
    };
    if visible(&new_desc) <= visible(desc) {
        return Err(
            "log entry would never render — an unclosed code fence or unterminated \
             block already inside ## Activity Log would absorb it; repair the \
             description first with issue edit --description-file"
                .to_string(),
        );
    }
    Ok(new_desc)
}

/// One entry in the `## Activity Log` section — one markdown list item.
pub struct ActivityEntry {
    /// The item's first line without its list marker: what an error message
    /// should quote, and where the timestamp lives.
    pub head: String,
    /// Timestamp and message, when the entry has the `- <ts> — <msg>` shape.
    /// `None` for hand-written prose or an entry from some other tool.
    pub parsed: Option<(DateTime<Utc>, String)>,
}

/// Every entry in the `## Activity Log` section, in document order.
///
/// An entry is a *markdown list item*, not a line. That distinction is the
/// whole fix: an indented `- ` beneath an entry is a nested list item and a
/// wrapped prose line beneath one is a lazy continuation — both belong to the
/// entry that opened, and scanning lines made the first look like two more
/// malformed entries and dropped the second on the floor.
///
/// The reader and `lint` both come through here, so they cannot disagree about
/// what an entry is.
pub fn activity_entries(desc: &str) -> Vec<ActivityEntry> {
    let (start, end, ok) = find_section(desc, "Activity Log");
    if !ok {
        return Vec::new();
    }
    let body = &desc[start..end];
    cliban_core::sections::top_level_list_items(body)
        .into_iter()
        .map(|r| {
            let entry = cliban_core::sections::list_item_body(&body[r]);
            let (head, rest) = match entry.split_once('\n') {
                Some((h, r)) => (h.to_string(), Some(r)),
                None => (entry.clone(), None),
            };
            let parsed = parse_entry_head(&head).map(|(ts, msg)| match rest {
                // The body beneath the first line is part of this entry's
                // message: a sublist of detail, or the rest of a wrapped
                // sentence. It is carried through with its nesting intact.
                Some(rest) if !rest.trim().is_empty() => {
                    (ts, format!("{msg}\n{}", rest.trim_end()))
                }
                _ => (ts, msg),
            });
            ActivityEntry { head, parsed }
        })
        .collect()
}

/// `2026-08-08T10:00Z — did a thing` → its timestamp and message.
pub(crate) fn parse_entry_head(head: &str) -> Option<(DateTime<Utc>, String)> {
    // The separator is an em dash surrounded by spaces; the timestamp may not
    // contain one, so the first occurrence is the split point.
    let (stamp, msg) = head.split_once(" — ")?;
    let ts = chrono::NaiveDateTime::parse_from_str(
        stamp.trim(),
        // Minute precision, as written; fall back to full RFC3339 for entries
        // someone recorded by hand.
        ACTIVITY_LOG_TIME_FORMAT,
    )
    .map(|t| t.and_utc())
    .or_else(|_| DateTime::parse_from_rfc3339(stamp.trim()).map(|t| t.with_timezone(&Utc)))
    .ok()?;
    Some((ts, msg.trim().to_string()))
}

/// Reads back the entries [`append_activity_log`] wrote: `- <ts> — <msg>`,
/// oldest first. Entries that don't match that shape are skipped rather than
/// guessed at.
pub fn parse_activity_log(desc: &str) -> Vec<(DateTime<Utc>, String)> {
    activity_entries(desc)
        .into_iter()
        .filter_map(|e| e.parsed)
        .collect()
}

/// Replaces the M-th step line in task N with the provided `new_line`.
/// `new_line` must end with a single newline character; otherwise an error is
/// returned. The caller is responsible for ensuring `new_line` remains a valid
/// step — the function performs no syntax validation on its content.
pub fn rewrite_step_line(
    desc: &str,
    task_n: i32,
    step_m: i32,
    new_line: &str,
) -> Result<String, String> {
    if !new_line.ends_with('\n') {
        return Err(errf("newLine must end with newline".to_string()));
    }
    let (plan_start, plan_end, ok) = find_section(desc, "Plan");
    if !ok {
        return Err(errf("no ## Plan section".to_string()));
    }
    let plan_body = &desc[plan_start..plan_end];
    let (task_start, task_end, ok) = find_task(plan_body, task_n);
    if !ok {
        return Err(errf(format!("no Task {task_n} in ## Plan")));
    }
    let task_body = &plan_body[task_start..task_end];
    let step = match find_step(task_body, step_m) {
        Some(s) => s,
        None => return Err(errf(format!("no Step {step_m} in Task {task_n}"))),
    };
    let abs = plan_start + task_start + step.line_start;
    Ok(format!(
        "{}{}{}",
        &desc[..abs],
        new_line,
        &desc[abs + step.raw.len()..]
    ))
}

/// The `issue cp` description transform: duplicate the shape, never the
/// history. Keeps only the contract sections `## Spec`, `## Plan`, and
/// `## Notes` — in the order they appear in the source — with the plan body
/// passed through [`reset_plan_body`]. `## Activity Log` and any non-contract
/// H2 section are dropped: they record what happened to the original, not
/// what the work is.
pub fn copy_description(desc: &str) -> String {
    let mut kept: Vec<(usize, &str, &str)> = Vec::new();
    for anchor in ["Spec", "Plan", "Notes"] {
        let (start, end, ok) = find_section(desc, anchor);
        if ok {
            kept.push((start, anchor, desc[start..end].trim_matches('\n')));
        }
    }
    kept.sort_by_key(|(start, _, _)| *start);
    let mut out = String::new();
    for (_, anchor, body) in kept {
        if !out.is_empty() {
            out.push('\n');
        }
        out.push_str("## ");
        out.push_str(anchor);
        out.push('\n');
        let body = if anchor == "Plan" {
            reset_plan_body(body)
        } else {
            body.to_string()
        };
        if !body.trim().is_empty() {
            out.push('\n');
            out.push_str(body.trim_matches('\n'));
            out.push('\n');
        }
    }
    out
}

/// Reset a plan body to its unstarted shape. Pure transform, two rules:
///   * a column-zero `- [x] ` step becomes `- [ ] ` (indented child boxes are
///     not steps and pass through untouched),
///   * a step's trailing promotion marker ` → KEY` is stripped — but only
///     when the suffix is actually issue-key-shaped, so a step titled
///     "map a → b" keeps its arrow. The marker records that the *original*
///     split that step into an issue; the copy carries no relations, so the
///     pointer would dangle.
pub fn reset_plan_body(plan_body: &str) -> String {
    let mut out = String::with_capacity(plan_body.len());
    for line in plan_body.split_inclusive('\n') {
        if line.starts_with("- [ ] ") || line.starts_with("- [x] ") {
            let rest = &line["- [x] ".len()..];
            out.push_str("- [ ] ");
            out.push_str(&strip_promotion_suffix(rest));
        } else {
            out.push_str(line);
        }
    }
    out
}

/// Strip a trailing ` → KEY` (issue-key-shaped only) from a step line,
/// preserving the line's original newline (if any).
fn strip_promotion_suffix(line: &str) -> String {
    let (body, newline) = match line.strip_suffix('\n') {
        Some(b) => (b.strip_suffix('\r').unwrap_or(b), &line[b.len()..]),
        None => (line, ""),
    };
    let stripped = match body.rfind(" → ") {
        Some(idx) if is_issue_key_shaped(body[idx + " → ".len()..].trim()) => &body[..idx],
        _ => body,
    };
    format!("{}{}", stripped.trim_end(), newline)
}

/// `PROJECT-N`: letters/digits (starting with a letter) before the last dash,
/// a positive integer after it.
pub fn is_issue_key_shaped(s: &str) -> bool {
    let Some(idx) = s.rfind('-') else {
        return false;
    };
    let (project, seq) = (&s[..idx], &s[idx + 1..]);
    !project.is_empty()
        && project
            .chars()
            .next()
            .is_some_and(|c| c.is_ascii_alphabetic())
        && project.chars().all(|c| c.is_ascii_alphanumeric())
        && !seq.is_empty()
        && seq.chars().all(|c| c.is_ascii_digit())
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::{TimeZone, Utc};

    #[test]
    fn find_section_returns_content_range() {
        let d = "## Spec\n\nbody\n\n## Plan\n\nplan body\n";
        let (s, e, ok) = find_section(d, "Spec");
        assert!(ok);
        assert_eq!(&d[s..e], "\nbody\n\n");
    }

    #[test]
    fn tick_step_checks_box() {
        let d = "## Plan\n\n### Task 1: x\n\n- [ ] Step 1\n- [ ] Step 2\n";
        let out = match tick_step(d, 1, 1).unwrap() {
            TickOutcome::Ticked(out) => out,
            TickOutcome::AlreadyChecked => panic!("unchecked step must tick"),
        };
        assert!(out.contains("- [x] Step 1"));
        assert!(out.contains("- [ ] Step 2"));
    }

    #[test]
    fn tick_already_checked_is_a_detectable_noop_not_an_error() {
        let d = "## Plan\n\n### Task 1: x\n\n- [x] Step 1\n";
        assert!(matches!(
            tick_step(d, 1, 1),
            Ok(TickOutcome::AlreadyChecked)
        ));
    }

    #[test]
    fn tick_wrong_targets_stay_errors() {
        let d = "## Plan\n\n### Task 1: x\n\n- [x] Step 1\n";
        assert!(tick_step(d, 1, 2).is_err(), "no such step");
        assert!(tick_step(d, 2, 1).is_err(), "no such task");
        assert!(tick_step("no plan here\n", 1, 1).is_err(), "no ## Plan");
    }

    #[test]
    fn reset_unticks_column_zero_steps_only() {
        let body = "### Task 1: x\n\n- [x] **Step 1: done**\n  - [x] child stays\n- [ ] **Step 2: open**\n  prose\n";
        let out = reset_plan_body(body);
        assert!(out.contains("- [ ] **Step 1: done**"), "got {out:?}");
        assert!(
            out.contains("  - [x] child stays"),
            "indented boxes are not steps: {out:?}"
        );
        assert!(out.contains("- [ ] **Step 2: open**"));
        assert!(out.contains("### Task 1: x"));
    }

    #[test]
    fn reset_strips_key_shaped_promotion_suffixes_only() {
        let body = "- [x] split me → CLI-53\n- [ ] map a → b\n- [x] pipeline x → y → COOK-7\n";
        let out = reset_plan_body(body);
        assert_eq!(
            out,
            "- [ ] split me\n- [ ] map a → b\n- [ ] pipeline x → y\n"
        );
    }

    #[test]
    fn reset_handles_last_line_without_newline() {
        assert_eq!(reset_plan_body("- [x] tail → ABC-12"), "- [ ] tail");
    }

    #[test]
    fn key_shaped_suffix_rules() {
        assert!(is_issue_key_shaped("CLI-53"));
        assert!(is_issue_key_shaped("cook2-101"));
        assert!(!is_issue_key_shaped("b"));
        assert!(!is_issue_key_shaped("a-"));
        assert!(!is_issue_key_shaped("-7"));
        assert!(!is_issue_key_shaped("7-7"));
        assert!(!is_issue_key_shaped("two words-3"));
    }

    #[test]
    fn copy_keeps_contract_sections_in_source_order_and_resets_plan() {
        let d = "## Notes\n\nlesson\n\n## Spec\n\nthe spec\n\n## Plan\n\n### Task 1: t\n\n- [x] done → CLI-9\n- [ ] open\n\n## Activity Log\n\n- 2026-01-01T00:00Z — history\n";
        let out = copy_description(d);
        assert_eq!(
            out,
            "## Notes\n\nlesson\n\n## Spec\n\nthe spec\n\n## Plan\n\n### Task 1: t\n\n- [x] done → CLI-9\n- [ ] open\n"
                .replace("- [x] done → CLI-9", "- [ ] done"),
            "got {out:?}"
        );
        assert!(!out.contains("Activity Log"));
        assert!(!out.contains("history"));
    }

    #[test]
    fn copy_drops_non_contract_sections() {
        let d = "## Spec\n\ns\n\n## Decisions so far\n\n- deliberation\n\n## Plan\n\n- [ ] step\n";
        let out = copy_description(d);
        assert!(!out.contains("Decisions so far"));
        assert!(!out.contains("deliberation"));
        assert!(out.contains("## Spec\n\ns\n"));
        assert!(out.contains("## Plan\n\n- [ ] step\n"));
    }

    #[test]
    fn copy_tolerates_missing_and_empty_sections() {
        assert_eq!(copy_description("free text, no sections\n"), "");
        assert_eq!(copy_description(""), "");
        // An empty section keeps its heading without growing blank runs.
        let out = copy_description("## Spec\n\n## Plan\n\n- [ ] s\n");
        assert_eq!(out, "## Spec\n\n## Plan\n\n- [ ] s\n");
        assert!(!out.contains("\n\n\n"));
    }

    #[test]
    fn an_indented_sublist_is_part_of_its_entry() {
        // Under CommonMark an indented `-` beneath an entry is a nested list
        // item, not a new entry. It used to parse as two more entries that
        // failed the `- <ts> — <msg>` shape and were silently dropped, while
        // lint warned about each one.
        let d = "## Activity Log\n\n- 2026-08-08T10:00Z — did a thing\n  - detail one\n  \
                 - detail two\n- 2026-08-08T11:00Z — next thing\n";
        let out = parse_activity_log(d);
        assert_eq!(out.len(), 2, "two entries, not four: {out:?}");
        assert_eq!(
            out[0].1, "did a thing\n- detail one\n- detail two",
            "the sublist rides along with its entry"
        );
        assert_eq!(out[1].1, "next thing");
    }

    #[test]
    fn a_wrapped_prose_line_folds_into_its_entry() {
        // A plain continuation line is a lazy continuation of the list item.
        // It used to be dropped outright — the message silently lost its tail.
        let d = "## Activity Log\n\n- 2026-08-08T11:00Z — wrapped entry that continues\n  \
                 onto a prose line\n";
        let out = parse_activity_log(d);
        assert_eq!(out.len(), 1);
        assert_eq!(
            out[0].1, "wrapped entry that continues\nonto a prose line",
            "the continuation is not dropped"
        );
    }

    #[test]
    fn an_entry_that_is_not_log_shaped_is_still_reported_once() {
        let d = "## Activity Log\n\n- yesterday: did stuff\n  - with detail\n";
        assert!(parse_activity_log(d).is_empty());
        let entries = activity_entries(d);
        assert_eq!(entries.len(), 1, "one malformed entry, not two");
        assert_eq!(entries[0].head, "yesterday: did stuff");
    }

    #[test]
    fn a_fenced_log_line_is_not_an_entry() {
        let d = "## Activity Log\n\n- 2026-08-08T10:00Z — real\n\n```\n- 2026-01-01T00:00Z — fake\n```\n";
        let out = parse_activity_log(d);
        assert_eq!(
            out.len(),
            1,
            "the fenced line is code, not an entry: {out:?}"
        );
        assert_eq!(out[0].1, "real");
    }

    #[test]
    fn a_fenced_task_heading_does_not_end_a_task() {
        // Same family as the section bug, one level down: a plan step that
        // quotes the task format used to truncate the task it lives in.
        let plan = "### Task 1: t\n\n- [ ] **Step 1: a**\n- [ ] **Step 2: b**\n\n```markdown\n\
                    ### Task 2: not real\n```\n\n- [ ] **Step 3: c**\n";
        let (s, e, ok) = find_task(plan, 1);
        assert!(ok);
        let body = &plan[s..e];
        assert!(body.contains("Step 3: c"), "task not truncated: {body:?}");
        assert!(find_step(body, 3).is_some(), "the third step is reachable");
        assert_eq!(count_tasks(&format!("## Plan\n\n{plan}")), 1);
    }

    #[test]
    fn ticking_a_step_that_owns_a_sublist_keeps_its_children() {
        // find_step reports only the item's first line precisely so that the
        // tick splice cannot swallow the step's children.
        let d = "## Plan\n\n### Task 1: t\n\n- [ ] **Step 1: a**\n  - note one\n  - note two\n\
                 - [ ] **Step 2: b**\n";
        let out = match tick_step(d, 1, 1).unwrap() {
            TickOutcome::Ticked(out) => out,
            TickOutcome::AlreadyChecked => panic!("unchecked step must tick"),
        };
        assert!(out.contains("- [x] **Step 1: a**"));
        assert!(out.contains("  - note one\n  - note two"), "got {out:?}");
        assert!(out.contains("- [ ] **Step 2: b**"));
    }

    #[test]
    fn a_fenced_checkbox_is_not_a_step() {
        let task = "- [ ] **Step 1: real**\n\n```\n- [ ] fenced, not a step\n```\n\n\
                    - [ ] **Step 2: also real**\n";
        assert!(find_step(task, 1).unwrap().raw.contains("real"));
        assert!(find_step(task, 2).unwrap().raw.contains("also real"));
        assert!(find_step(task, 3).is_none(), "only two steps exist");
    }

    #[test]
    fn append_activity_log_minute_precision() {
        let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
        let out = append_activity_log("## Spec\n\nbody\n", "hello", ts).unwrap();
        assert!(
            out.contains("## Activity Log\n\n- 2026-06-19T14:46Z — hello\n"),
            "got {out}"
        );
    }

    #[test]
    fn append_activity_log_sanitizes_a_top_level_h2_into_the_entry() {
        // A multi-line handoff containing `## ` headings used to be refused;
        // indentation keeps the heading inside the list item instead.
        let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
        let msg = "intro\n\n## A heading\n\nbody";
        let out = append_activity_log("## Spec\n\nbody\n", msg, ts).unwrap();
        assert_eq!(
            cliban_core::sections::h2_anchors(&out),
            vec!["Spec".to_string(), "Activity Log".to_string()],
            "the inner heading must not become a section: {out}"
        );
        let entries = activity_entries(&out);
        assert_eq!(entries.len(), 1, "one entry: {out}");
        let (_, body) = entries[0].parsed.as_ref().unwrap();
        assert!(body.contains("## A heading"), "content preserved: {body}");
    }

    #[test]
    fn append_activity_log_allows_fenced_and_deeper_headings() {
        let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
        let fenced = "quoting the grammar:\n\n```\n## Spec\n```";
        assert!(append_activity_log("", fenced, ts).is_ok());
        let h3 = "handoff\n\n### converted call sites\n\n- a\n- b";
        assert!(append_activity_log("", h3, ts).is_ok());
    }

    #[test]
    fn append_activity_log_contains_fences_inside_the_entry() {
        // An unclosed fence used to leak across entries and turn a later
        // entry's heading into a real boundary; indentation closes it at the
        // item boundary, so both entries land and no section appears.
        let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
        let d = append_activity_log("## Spec\n\ns\n", "opening a fence\n\n```\nquoted", ts)
            .unwrap();
        let msg = "more quote\n```\n\n## Phantom\n\nsplit content";
        let out = append_activity_log(&d, msg, ts).unwrap();
        assert_eq!(
            cliban_core::sections::h2_anchors(&out),
            vec!["Spec".to_string(), "Activity Log".to_string()],
            "{out}"
        );
        assert_eq!(activity_entries(&out).len(), 2, "both entries render: {out}");
    }

    #[test]
    fn append_activity_log_keeps_a_same_name_heading_inside_the_entry() {
        // The entry mentions ## Notes and opens a fence; sanitized, the real
        // ## Notes section stays exactly where and what it was.
        let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
        let d = "## Activity Log\n\n- 2026-01-01T00:00Z — x\n\n## Notes\n\nreal notes\n";
        let out = append_activity_log(d, "x\n\n## Notes\n\n```\nunclosed", ts).unwrap();
        let (s, e, ok) = find_section(&out, "Notes");
        assert!(ok);
        assert_eq!(out[s..e].trim(), "real notes", "{out}");
    }

    #[test]
    fn append_activity_log_refuses_to_write_an_invisible_entry() {
        // Pre-existing corruption (a document-level open fence inside the
        // section, e.g. from a raw description edit) would absorb the entry:
        // reported as written, rendered never. Still refused, with repair
        // guidance — this is not the agent's entry needing a retry.
        let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
        let d = "## Activity Log\n\n- 2026-01-01T00:00Z — x\n\n```\nstill open\n";
        let err = append_activity_log(d, "hello", ts).unwrap_err();
        assert!(err.contains("never render"), "got {err}");
        assert!(err.contains("--description-file"), "repair guidance: {err}");
    }

    #[test]
    fn append_activity_log_accepts_a_trailing_whitespace_message() {
        // list_item_body trims a single-line entry's tail; the visibility
        // check must compare modulo that or reject an innocent message.
        let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
        assert!(append_activity_log("", "oops trailing space ", ts).is_ok());
    }

    #[test]
    fn append_activity_log_fence_cannot_swallow_a_later_section() {
        // Activity Log is not the last section; the entry's unclosed fence
        // is contained by the item, so ## Notes survives untouched.
        let ts = Utc.with_ymd_and_hms(2026, 6, 19, 14, 46, 30).unwrap();
        let d = "## Activity Log\n\n- 2026-01-01T00:00Z — x\n\n## Notes\n\nn\n";
        let out = append_activity_log(d, "opens\n\n```\nunclosed", ts).unwrap();
        let (s, e, ok) = find_section(&out, "Notes");
        assert!(ok, "{out}");
        assert_eq!(out[s..e].trim(), "n", "{out}");
    }
}