Skip to main content

batty_cli/team/tact/
parser.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4use serde::Deserialize;
5use tracing::warn;
6
7use super::{GeneratedTask, TaskSpec};
8use crate::task::load_tasks_from_dir;
9
10#[derive(Debug, Deserialize)]
11struct Frontmatter {
12    title: Option<String>,
13    priority: Option<String>,
14    depends_on: Option<Vec<u32>>,
15    tags: Option<Vec<String>>,
16}
17
18/// Parse the architect's planning response into task specifications.
19pub fn parse_planning_response(response: &str) -> Vec<TaskSpec> {
20    let mut specs = Vec::new();
21    let trimmed = response.trim();
22    if trimmed.is_empty() {
23        return specs;
24    }
25
26    let mut rest = trimmed;
27    loop {
28        rest = rest.trim_start();
29        let Some(after_open) = rest.strip_prefix("---") else {
30            break;
31        };
32        let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
33        let Some(frontmatter_end) = after_open.find("\n---") else {
34            warn!("skipping tact block with unterminated frontmatter");
35            break;
36        };
37
38        let frontmatter_raw = &after_open[..frontmatter_end];
39        let after_frontmatter = &after_open[frontmatter_end + 4..];
40        let body_start = after_frontmatter
41            .strip_prefix('\n')
42            .unwrap_or(after_frontmatter);
43
44        let next_block = body_start.find("\n---");
45        let (body_raw, next_rest) = match next_block {
46            Some(index) => (&body_start[..index], Some(&body_start[index..])),
47            None => (body_start, None),
48        };
49
50        match serde_yaml::from_str::<Frontmatter>(frontmatter_raw) {
51            Ok(frontmatter) => {
52                let Some(title) = frontmatter.title.map(|title| title.trim().to_string()) else {
53                    warn!("skipping tact block without title");
54                    rest = next_rest.unwrap_or("");
55                    continue;
56                };
57                if title.is_empty() {
58                    warn!("skipping tact block with empty title");
59                    rest = next_rest.unwrap_or("");
60                    continue;
61                }
62                let body = body_raw.trim().to_string();
63                specs.push(TaskSpec {
64                    title,
65                    body,
66                    priority: frontmatter.priority.map(|value| value.trim().to_string()),
67                    depends_on: frontmatter.depends_on.unwrap_or_default(),
68                    tags: frontmatter.tags.unwrap_or_default(),
69                });
70            }
71            Err(error) => warn!(%error, "skipping tact block with malformed frontmatter"),
72        }
73
74        rest = next_rest.unwrap_or("");
75        if rest.trim().is_empty() {
76            break;
77        }
78    }
79
80    specs
81}
82
83pub fn parse_task_specs(response: &str) -> Vec<TaskSpec> {
84    parse_planning_response(response)
85}
86
87fn build_create_task_args(spec: &TaskSpec) -> Vec<String> {
88    let mut args = vec![
89        "create".to_string(),
90        spec.title.clone(),
91        "--body".to_string(),
92        spec.body.clone(),
93    ];
94    if let Some(priority) = spec.priority.as_deref() {
95        args.push("--priority".to_string());
96        args.push(priority.to_string());
97    }
98    if !spec.tags.is_empty() {
99        args.push("--tags".to_string());
100        args.push(spec.tags.join(","));
101    }
102    if !spec.depends_on.is_empty() {
103        args.push("--depends-on".to_string());
104        args.push(
105            spec.depends_on
106                .iter()
107                .map(u32::to_string)
108                .collect::<Vec<_>>()
109                .join(","),
110        );
111    }
112    args
113}
114
115fn normalize_generated_text(value: &str) -> String {
116    let mut normalized = String::with_capacity(value.len());
117    let mut last_was_space = false;
118
119    for ch in value.chars().flat_map(char::to_lowercase) {
120        if ch.is_ascii_alphanumeric() {
121            normalized.push(ch);
122            last_was_space = false;
123        } else if !last_was_space {
124            normalized.push(' ');
125            last_was_space = true;
126        }
127    }
128
129    normalized.trim().to_string()
130}
131
132fn normalize_generated_title(title: &str) -> String {
133    normalize_generated_text(title)
134}
135
136fn normalize_generated_body(body: &str) -> String {
137    normalize_generated_text(body)
138}
139
140fn is_open_task_status(status: &str) -> bool {
141    !matches!(status, "done" | "archived")
142}
143
144fn generated_task_equivalence_key(task: &GeneratedTask) -> String {
145    let mut tags = task
146        .tags
147        .iter()
148        .map(|tag| normalize_generated_text(tag))
149        .filter(|tag| !tag.is_empty())
150        .collect::<Vec<_>>();
151    tags.sort();
152    tags.dedup();
153
154    let priority = task
155        .priority
156        .as_deref()
157        .map(normalize_generated_text)
158        .unwrap_or_default();
159    let depends_on = task
160        .depends_on
161        .iter()
162        .map(u32::to_string)
163        .collect::<Vec<_>>()
164        .join(",");
165
166    format!(
167        "{}|{}|{}|{}",
168        normalize_generated_body(&task.body),
169        priority,
170        tags.join(","),
171        depends_on
172    )
173}
174
175fn looks_like_raw_test_log(body: &str) -> bool {
176    let has_running_header = body.lines().any(|line| {
177        let trimmed = line.trim();
178        trimmed.starts_with("running ") && trimmed.ends_with(" tests")
179    });
180    let test_line_count = body
181        .lines()
182        .filter(|line| line.trim_start().starts_with("test "))
183        .count();
184    let has_failure_marker = body.lines().any(|line| {
185        let trimmed = line.trim();
186        trimmed.ends_with("FAILED")
187            || trimmed.starts_with("failures:")
188            || trimmed.starts_with("error:")
189            || trimmed.contains("panicked at")
190            || trimmed.contains("No such file or directory")
191    });
192
193    (has_running_header && has_failure_marker && test_line_count >= 1)
194        || (has_running_header && test_line_count >= 3)
195        || (test_line_count >= 5 && has_failure_marker)
196}
197
198fn sanitize_generated_task(spec: &GeneratedTask) -> Option<GeneratedTask> {
199    let title = spec.title.trim();
200    let body = spec.body.trim();
201
202    if title.is_empty() {
203        warn!("rejecting generated task with empty title");
204        return None;
205    }
206    if body.is_empty() {
207        warn!(title, "rejecting generated task with empty body");
208        return None;
209    }
210    if looks_like_raw_test_log(body) {
211        warn!(title, "rejecting generated task with raw log body");
212        return None;
213    }
214
215    Some(GeneratedTask {
216        title: title.to_string(),
217        body: body.to_string(),
218        priority: spec.priority.as_ref().map(|value| value.trim().to_string()),
219        depends_on: spec.depends_on.clone(),
220        tags: spec.tags.iter().map(|tag| tag.trim().to_string()).collect(),
221    })
222}
223
224pub(crate) fn dedupe_generated_tasks(
225    existing: &[crate::task::Task],
226    proposed: Vec<GeneratedTask>,
227) -> Vec<GeneratedTask> {
228    let mut open_titles = existing
229        .iter()
230        .filter(|task| is_open_task_status(&task.status))
231        .map(|task| normalize_generated_title(&task.title))
232        .filter(|title| !title.is_empty())
233        .collect::<std::collections::HashSet<_>>();
234    let mut open_specs = existing
235        .iter()
236        .filter(|task| is_open_task_status(&task.status))
237        .filter_map(|task| {
238            let body = task.description.trim();
239            if body.is_empty() {
240                return None;
241            }
242            Some(generated_task_equivalence_key(&GeneratedTask {
243                title: task.title.clone(),
244                body: body.to_string(),
245                priority: Some(task.priority.clone()),
246                depends_on: task.depends_on.clone(),
247                tags: task.tags.clone(),
248            }))
249        })
250        .collect::<std::collections::HashSet<_>>();
251    let mut seen_titles = std::collections::HashSet::new();
252    let mut seen_specs = std::collections::HashSet::new();
253    let mut deduped = Vec::with_capacity(proposed.len());
254
255    for task in proposed {
256        let title_key = normalize_generated_title(&task.title);
257        let spec_key = generated_task_equivalence_key(&task);
258        let duplicate_title = !title_key.is_empty()
259            && (!seen_titles.insert(title_key.clone()) || open_titles.contains(&title_key));
260        let duplicate_spec = !spec_key.is_empty()
261            && (!seen_specs.insert(spec_key.clone()) || open_specs.contains(&spec_key));
262
263        if duplicate_title || duplicate_spec {
264            warn!(
265                title = %task.title,
266                duplicate_title,
267                duplicate_spec,
268                "suppressing duplicate generated task"
269            );
270            continue;
271        }
272
273        if !title_key.is_empty() {
274            open_titles.insert(title_key);
275        }
276        if !spec_key.is_empty() {
277            open_specs.insert(spec_key);
278        }
279        deduped.push(task);
280    }
281
282    deduped
283}
284
285fn create_board_tasks_with_program(
286    specs: &[TaskSpec],
287    board_dir: &Path,
288    program: &str,
289) -> Result<Vec<u32>> {
290    if !board_dir.exists() {
291        anyhow::bail!("board directory does not exist: {}", board_dir.display());
292    }
293
294    let existing_tasks = load_tasks_from_dir(&board_dir.join("tasks")).unwrap_or_default();
295    let generated = specs
296        .iter()
297        .filter_map(sanitize_generated_task)
298        .collect::<Vec<_>>();
299    let deduped = dedupe_generated_tasks(&existing_tasks, generated);
300    let mut created_ids = Vec::with_capacity(deduped.len());
301    for sanitized in deduped {
302        let args = build_create_task_args(&sanitized);
303        let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
304        let output = crate::team::board_cmd::run_board_with_program(program, board_dir, &arg_refs)
305            .with_context(|| format!("failed to create board task '{}'", sanitized.title))?;
306
307        // kanban-md output has evolved over versions: older releases print
308        // `Created task #629\n`, newer ones (0.32+) print
309        // `Created task #629: <title>\n`. Parse only the leading run of
310        // digits after `#` so the parser works across both shapes instead
311        // of crashing planning responses whenever a task is created.
312        let raw = output.stdout.trim();
313        let after_prefix = raw.strip_prefix("Created task #").unwrap_or(raw);
314        let digits: String = after_prefix
315            .chars()
316            .take_while(|c| c.is_ascii_digit())
317            .collect();
318        let parsed_id = digits
319            .parse::<u32>()
320            .with_context(|| format!("invalid task id returned by kanban-md: '{raw}'"))?;
321        created_ids.push(parsed_id);
322    }
323    Ok(created_ids)
324}
325
326/// Create board tasks from parsed specs by shelling out to kanban-md.
327pub fn create_board_tasks(specs: &[TaskSpec], board_dir: &Path) -> Result<Vec<u32>> {
328    create_board_tasks_with_program(specs, board_dir, "kanban-md")
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    fn setup_fake_kanban(tmp: &tempfile::TempDir) -> std::path::PathBuf {
336        let fake_bin = tmp.path().join("fake-bin");
337        std::fs::create_dir_all(&fake_bin).unwrap();
338        let script = fake_bin.join("kanban-md");
339        std::fs::write(
340            &script,
341            "#!/bin/bash\nset -euo pipefail\nif [ \"$1\" != \"create\" ]; then exit 1; fi\nshift\ntitle=\"$1\"\nshift\nbody=\"\"\npriority=\"high\"\ntags=\"\"\ndepends_on=\"\"\nwhile [ $# -gt 0 ]; do\n  case \"$1\" in\n    --body) body=\"$2\"; shift 2 ;;\n    --priority) priority=\"$2\"; shift 2 ;;\n    --tags) tags=\"$2\"; shift 2 ;;\n    --depends-on) depends_on=\"$2\"; shift 2 ;;\n    --dir) board_dir=\"$2\"; shift 2 ;;\n    *) shift ;;\n  esac\ndone\nmkdir -p \"$board_dir/tasks\"\ncount=$(find \"$board_dir/tasks\" -maxdepth 1 -name '*.md' | wc -l | tr -d ' ')\nid=$((count + 1))\nprintf -- '---\\nid: %s\\ntitle: %s\\nstatus: todo\\npriority: %s\\n' \"$id\" \"$title\" \"$priority\" > \"$board_dir/tasks/$(printf '%03d' \"$id\")-task.md\"\nif [ -n \"$tags\" ]; then printf 'tags: [%s]\\n' \"$tags\" >> \"$board_dir/tasks/$(printf '%03d' \"$id\")-task.md\"; fi\nif [ -n \"$depends_on\" ]; then printf 'depends_on: [%s]\\n' \"$depends_on\" >> \"$board_dir/tasks/$(printf '%03d' \"$id\")-task.md\"; fi\nprintf -- '---\\n\\n%s\\n' \"$body\" >> \"$board_dir/tasks/$(printf '%03d' \"$id\")-task.md\"\nprintf 'Created task #%s\\n' \"$id\"\n",
342        )
343        .unwrap();
344        #[cfg(unix)]
345        {
346            use std::os::unix::fs::PermissionsExt;
347            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
348        }
349        fake_bin
350    }
351
352    fn write_task_file(board_dir: &Path, id: u32, title: &str, status: &str) {
353        std::fs::write(
354            board_dir.join("tasks").join(format!("{id:03}-task.md")),
355            format!(
356                "---\nid: {id}\ntitle: {title}\nstatus: {status}\npriority: critical\n---\n\nTask body.\n"
357            ),
358        )
359        .unwrap();
360    }
361
362    #[test]
363    fn parse_single_task() {
364        let response = r#"---
365title: "Add tact parser"
366priority: high
367tags: [core, tact]
368---
369Implement parser logic."#;
370
371        let specs = parse_planning_response(response);
372        assert_eq!(specs.len(), 1);
373        assert_eq!(specs[0].title, "Add tact parser");
374        assert_eq!(specs[0].priority.as_deref(), Some("high"));
375        assert_eq!(specs[0].tags, vec!["core", "tact"]);
376        assert_eq!(specs[0].body, "Implement parser logic.");
377    }
378
379    #[test]
380    fn test_parse_task_specs_single() {
381        let response = r#"---
382title: "Add tact parser"
383priority: high
384---
385Implement parser logic."#;
386
387        let specs = parse_task_specs(response);
388        assert_eq!(specs.len(), 1);
389        assert_eq!(specs[0].title, "Add tact parser");
390    }
391
392    #[test]
393    fn parse_multiple_tasks() {
394        let response = r#"---
395title: "Task one"
396priority: low
397---
398Body one.
399---
400title: "Task two"
401priority: medium
402---
403Body two.
404---
405title: "Task three"
406priority: high
407---
408Body three."#;
409
410        let specs = parse_planning_response(response);
411        assert_eq!(specs.len(), 3);
412        assert_eq!(specs[0].title, "Task one");
413        assert_eq!(specs[1].title, "Task two");
414        assert_eq!(specs[2].title, "Task three");
415    }
416
417    #[test]
418    fn test_parse_task_specs_multiple() {
419        let response = r#"---
420title: "Task one"
421priority: low
422---
423Body one.
424---
425title: "Task two"
426priority: medium
427---
428Body two."#;
429
430        let specs = parse_task_specs(response);
431        assert_eq!(specs.len(), 2);
432    }
433
434    #[test]
435    fn parse_with_dependencies() {
436        let response = r#"---
437title: "Dependent task"
438depends_on: [1, 2, 8]
439---
440Body."#;
441
442        let specs = parse_planning_response(response);
443        assert_eq!(specs[0].depends_on, vec![1, 2, 8]);
444    }
445
446    #[test]
447    fn test_parse_task_specs_with_depends() {
448        let response = r#"---
449title: "Dependent task"
450depends_on: [42]
451---
452Body."#;
453
454        let specs = parse_task_specs(response);
455        assert_eq!(specs[0].depends_on, vec![42]);
456    }
457
458    #[test]
459    fn parse_malformed_skips_bad_blocks() {
460        let response = r#"---
461title: "Good task"
462priority: high
463---
464Good body.
465---
466title: [unterminated
467---
468Bad body.
469---
470title: "Second good task"
471---
472Second body."#;
473
474        let specs = parse_planning_response(response);
475        assert_eq!(specs.len(), 2);
476        assert_eq!(specs[0].title, "Good task");
477        assert_eq!(specs[1].title, "Second good task");
478    }
479
480    #[test]
481    fn parse_empty_response() {
482        assert!(parse_planning_response("").is_empty());
483        assert!(parse_planning_response("   \n\t").is_empty());
484    }
485
486    #[test]
487    fn test_parse_task_specs_empty() {
488        assert!(parse_task_specs("").is_empty());
489        assert!(parse_task_specs("garbage").is_empty());
490    }
491
492    #[test]
493    fn parse_no_frontmatter() {
494        assert!(parse_planning_response("just some freeform planning text").is_empty());
495    }
496
497    #[test]
498    fn parse_missing_title_skips_block() {
499        let response = r#"---
500priority: high
501---
502No title here."#;
503        assert!(parse_planning_response(response).is_empty());
504    }
505
506    #[test]
507    fn create_board_tasks_round_trip_creates_tasks_with_metadata() {
508        let tmp = tempfile::tempdir().unwrap();
509        let board_dir = tmp.path().join("board");
510        std::fs::create_dir_all(board_dir.join("tasks")).unwrap();
511        let fake_kanban = setup_fake_kanban(&tmp).join("kanban-md");
512
513        let specs = vec![
514            TaskSpec {
515                title: "Task one".into(),
516                body: "Body one".into(),
517                priority: Some("high".into()),
518                depends_on: vec![],
519                tags: vec!["tact".into()],
520            },
521            TaskSpec {
522                title: "Task two".into(),
523                body: "Body two".into(),
524                priority: Some("medium".into()),
525                depends_on: vec![1],
526                tags: vec!["integration".into()],
527            },
528        ];
529
530        let ids =
531            create_board_tasks_with_program(&specs, &board_dir, fake_kanban.to_str().unwrap())
532                .unwrap();
533        assert_eq!(ids, vec![1, 2]);
534        let tasks = crate::task::load_tasks_from_dir(&board_dir.join("tasks")).unwrap();
535        assert_eq!(tasks.len(), 2);
536        assert_eq!(tasks[1].depends_on, vec![1]);
537        assert_eq!(tasks[1].tags, vec!["integration"]);
538    }
539
540    #[test]
541    fn create_board_tasks_missing_board_dir_returns_clear_error() {
542        let specs = vec![TaskSpec {
543            title: "Task one".into(),
544            body: "Body one".into(),
545            priority: None,
546            depends_on: vec![],
547            tags: vec![],
548        }];
549        let tmp = tempfile::tempdir().unwrap();
550        let error = create_board_tasks(&specs, &tmp.path().join("missing")).unwrap_err();
551        assert!(error.to_string().contains("board directory does not exist"),);
552    }
553
554    #[test]
555    fn test_create_board_tasks_formats_command() {
556        let args = build_create_task_args(&TaskSpec {
557            title: "Plan tact".into(),
558            body: "Create the daemon prompt.".into(),
559            priority: Some("high".into()),
560            depends_on: vec![17],
561            tags: vec!["tact".into(), "daemon".into()],
562        });
563        assert_eq!(
564            args,
565            vec![
566                "create",
567                "Plan tact",
568                "--body",
569                "Create the daemon prompt.",
570                "--priority",
571                "high",
572                "--tags",
573                "tact,daemon",
574                "--depends-on",
575                "17",
576            ]
577        );
578    }
579
580    #[test]
581    fn create_board_tasks_rejects_raw_log_dump() {
582        let tmp = tempfile::tempdir().unwrap();
583        let board_dir = tmp.path().join("board");
584        std::fs::create_dir_all(board_dir.join("tasks")).unwrap();
585        let fake_kanban = setup_fake_kanban(&tmp).join("kanban-md");
586
587        let raw_body = "\
588running 3144 tests
589test agent::claude::tests::default_mode_is_interactive ... ok
590test tmux::tests::split_window_horizontal_creates_new_pane ... FAILED
591
592failures:
593
594---- tmux::tests::split_window_horizontal_creates_new_pane stdout ----
595thread 'tmux::tests::split_window_horizontal_creates_new_pane' panicked at src/tmux.rs:1903:71:
596called `Result::unwrap()` on an `Err` value: failed to create tmux session 'batty-test-hsplit'
597
598Caused by:
599    No such file or directory (os error 2)
600
601test result: FAILED. 3011 passed; 14 failed; 119 ignored; 0 measured; 0 filtered out;
602";
603        let specs = vec![TaskSpec {
604            title: "Reopen tmux runtime test hardening - make cargo test green on main".into(),
605            body: raw_body.into(),
606            priority: Some("critical".into()),
607            depends_on: vec![],
608            tags: vec!["stability".into(), "tmux".into()],
609        }];
610
611        let ids =
612            create_board_tasks_with_program(&specs, &board_dir, fake_kanban.to_str().unwrap())
613                .unwrap();
614        assert!(ids.is_empty());
615
616        let tasks = crate::task::load_tasks_from_dir(&board_dir.join("tasks")).unwrap();
617        assert!(tasks.is_empty());
618    }
619
620    #[test]
621    fn create_board_tasks_skips_duplicate_open_reopen_title_variants() {
622        let tmp = tempfile::tempdir().unwrap();
623        let board_dir = tmp.path().join("board");
624        std::fs::create_dir_all(board_dir.join("tasks")).unwrap();
625        let fake_kanban = setup_fake_kanban(&tmp).join("kanban-md");
626
627        write_task_file(
628            &board_dir,
629            41,
630            "Reopen tmux runtime test hardening - make cargo test green on main",
631            "todo",
632        );
633
634        let specs = vec![TaskSpec {
635            title: "Reopen tmux runtime test hardening — make cargo test green on main".into(),
636            body: "running 3144 tests\ntest tmux::tests::split_window_horizontal_creates_new_pane ... FAILED\n".into(),
637            priority: Some("critical".into()),
638            depends_on: vec![],
639            tags: vec!["stability".into()],
640        }];
641
642        let ids =
643            create_board_tasks_with_program(&specs, &board_dir, fake_kanban.to_str().unwrap())
644                .unwrap();
645        assert!(ids.is_empty());
646
647        let tasks = crate::task::load_tasks_from_dir(&board_dir.join("tasks")).unwrap();
648        assert_eq!(tasks.len(), 1);
649        assert_eq!(tasks[0].id, 41);
650    }
651
652    #[test]
653    fn create_board_tasks_skips_duplicate_open_title() {
654        let tmp = tempfile::tempdir().unwrap();
655        let board_dir = tmp.path().join("board");
656        std::fs::create_dir_all(board_dir.join("tasks")).unwrap();
657        let fake_kanban = setup_fake_kanban(&tmp).join("kanban-md");
658
659        write_task_file(&board_dir, 41, "Ship planning telemetry", "todo");
660
661        let specs = vec![TaskSpec {
662            title: "Ship planning telemetry".into(),
663            body: "Fresh body that should still be suppressed.".into(),
664            priority: Some("high".into()),
665            depends_on: vec![],
666            tags: vec!["tact".into()],
667        }];
668
669        let ids =
670            create_board_tasks_with_program(&specs, &board_dir, fake_kanban.to_str().unwrap())
671                .unwrap();
672        assert!(ids.is_empty());
673
674        let tasks = crate::task::load_tasks_from_dir(&board_dir.join("tasks")).unwrap();
675        assert_eq!(tasks.len(), 1);
676        assert_eq!(tasks[0].id, 41);
677    }
678
679    #[test]
680    fn create_board_tasks_skips_equivalent_generated_specs() {
681        let tmp = tempfile::tempdir().unwrap();
682        let board_dir = tmp.path().join("board");
683        std::fs::create_dir_all(board_dir.join("tasks")).unwrap();
684        let fake_kanban = setup_fake_kanban(&tmp).join("kanban-md");
685
686        let specs = vec![
687            TaskSpec {
688                title: "Plan planning telemetry".into(),
689                body: "Record planning cycle events in the orchestrator log.".into(),
690                priority: Some("high".into()),
691                depends_on: vec![],
692                tags: vec!["tact".into(), "telemetry".into()],
693            },
694            TaskSpec {
695                title: "Backfill planning telemetry".into(),
696                body: "Record planning cycle events in the orchestrator log.".into(),
697                priority: Some("high".into()),
698                depends_on: vec![],
699                tags: vec!["telemetry".into(), "tact".into()],
700            },
701        ];
702
703        let ids =
704            create_board_tasks_with_program(&specs, &board_dir, fake_kanban.to_str().unwrap())
705                .unwrap();
706        assert_eq!(ids, vec![1]);
707
708        let tasks = crate::task::load_tasks_from_dir(&board_dir.join("tasks")).unwrap();
709        assert_eq!(tasks.len(), 1);
710        assert_eq!(tasks[0].title, "Plan planning telemetry");
711    }
712
713    /// Stand up a fake kanban-md binary that emits the new `Created task
714    /// #ID: Title` output shape introduced in kanban-md 0.32+. The previous
715    /// implementation only understood the older `Created task #ID` form and
716    /// planning cycles would crash with
717    /// `invalid task id returned by kanban-md: '629: Auto-repair…'`.
718    fn setup_fake_kanban_with_title_suffix(tmp: &tempfile::TempDir) -> std::path::PathBuf {
719        let fake_bin = tmp.path().join("fake-bin-titled");
720        std::fs::create_dir_all(&fake_bin).unwrap();
721        let script = fake_bin.join("kanban-md");
722        std::fs::write(
723            &script,
724            "#!/bin/bash\nset -euo pipefail\nif [ \"$1\" != \"create\" ]; then exit 1; fi\nshift\ntitle=\"$1\"\nshift\nbody=\"\"\npriority=\"high\"\nwhile [ $# -gt 0 ]; do\n  case \"$1\" in\n    --body) body=\"$2\"; shift 2 ;;\n    --priority) priority=\"$2\"; shift 2 ;;\n    --tags) shift 2 ;;\n    --depends-on) shift 2 ;;\n    --dir) board_dir=\"$2\"; shift 2 ;;\n    *) shift ;;\n  esac\ndone\nmkdir -p \"$board_dir/tasks\"\ncount=$(find \"$board_dir/tasks\" -maxdepth 1 -name '*.md' | wc -l | tr -d ' ')\nid=$((count + 1))\nprintf -- '---\\nid: %s\\ntitle: %s\\nstatus: todo\\npriority: %s\\n---\\n\\n%s\\n' \"$id\" \"$title\" \"$priority\" \"$body\" > \"$board_dir/tasks/$(printf '%03d' \"$id\")-task.md\"\nprintf 'Created task #%s: %s\\n' \"$id\" \"$title\"\n",
725        )
726        .unwrap();
727        #[cfg(unix)]
728        {
729            use std::os::unix::fs::PermissionsExt;
730            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
731        }
732        fake_bin
733    }
734
735    #[test]
736    fn create_board_tasks_parses_new_output_shape_with_title_suffix() {
737        // Regression: kanban-md 0.32+ appends `: <title>` after the ID in
738        // its `Created task #` output, and the parser used to crash planning
739        // with `invalid task id returned by kanban-md: '629: Auto-repair…'`.
740        let tmp = tempfile::tempdir().unwrap();
741        let board_dir = tmp.path().join("board");
742        std::fs::create_dir_all(board_dir.join("tasks")).unwrap();
743        let fake_kanban = setup_fake_kanban_with_title_suffix(&tmp).join("kanban-md");
744
745        let specs = vec![TaskSpec {
746            title: "Auto-repair legacy telemetry schemas before event writes fail".into(),
747            body: "Planning response body.".into(),
748            priority: Some("high".into()),
749            depends_on: vec![],
750            tags: vec!["stability".into()],
751        }];
752
753        let ids =
754            create_board_tasks_with_program(&specs, &board_dir, fake_kanban.to_str().unwrap())
755                .unwrap();
756        assert_eq!(
757            ids,
758            vec![1],
759            "parser should extract numeric id from new output shape"
760        );
761    }
762
763    #[test]
764    fn create_board_tasks_allows_new_reopen_after_terminal_duplicate() {
765        let tmp = tempfile::tempdir().unwrap();
766        let board_dir = tmp.path().join("board");
767        std::fs::create_dir_all(board_dir.join("tasks")).unwrap();
768        let fake_kanban = setup_fake_kanban(&tmp).join("kanban-md");
769
770        write_task_file(
771            &board_dir,
772            41,
773            "Reopen tmux runtime test hardening - make cargo test green on main",
774            "archived",
775        );
776
777        let specs = vec![TaskSpec {
778            title: "Reopen tmux runtime test hardening — make cargo test green on main".into(),
779            body: "Automatic reopen after failed verification.".into(),
780            priority: Some("critical".into()),
781            depends_on: vec![],
782            tags: vec!["stability".into()],
783        }];
784
785        let ids =
786            create_board_tasks_with_program(&specs, &board_dir, fake_kanban.to_str().unwrap())
787                .unwrap();
788        assert_eq!(ids, vec![2]);
789
790        let tasks = crate::task::load_tasks_from_dir(&board_dir.join("tasks")).unwrap();
791        assert_eq!(tasks.len(), 2);
792    }
793}