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
18pub 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 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
326pub 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 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 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}