1use crate::backend::{AgentBackend, AgentEvent, AgentSession, PromptMode, SessionSpec};
17use crate::deps;
18use crate::error::{EngineError, Result};
19use crate::permissions;
20use crate::scrub;
21use crate::ticket::Ticket;
22use crate::types::{MissionConfig, Role};
23use serde::{Deserialize, Serialize};
24use std::collections::HashSet;
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28pub const MAX_NODES: usize = 8;
31
32const PLANNER_STALL_TIMEOUT: Duration = Duration::from_secs(600);
36
37fn default_priority() -> u8 {
40 2
41}
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct PlannedNode {
49 pub slug: String,
50 #[serde(default)]
51 pub title: String,
52 #[serde(default = "default_priority")]
53 pub priority: u8,
54 #[serde(default)]
55 pub goal: String,
56 #[serde(default)]
57 pub context: String,
58 #[serde(default)]
59 pub acceptance_hints: Vec<String>,
60 #[serde(default)]
61 pub blocked_by: Vec<String>,
62}
63
64fn clamp_priority(priority: u8) -> u8 {
67 priority.clamp(1, 3)
68}
69
70const PLANNER_SYSTEM_PROMPT: &str = "You are a decomposition planner for the kranz mission \
81 harness: you split one complex goal into a small DAG of mission tickets. You may read \
82 the repository (read-only) to ground the split. Your final answer is ONLY the JSON \
83 array the user asked for.";
84
85pub fn planner_prompt(goal: &str, existing_slugs: &[String]) -> String {
88 let existing = if existing_slugs.is_empty() {
89 "none".to_string()
90 } else {
91 existing_slugs.join(", ")
92 };
93 format!(
94 "Decompose this goal into a DAG of mission tickets:\n\
95 \n\
96 GOAL:\n{goal}\n\
97 \n\
98 Emit 1 to {MAX_NODES} tickets as a JSON array — output ONLY the JSON, no prose, no \
99 code fences:\n\
100 [{{\"slug\":\"kebab-slug\",\"title\":\"short title\",\"priority\":2,\"goal\":\"what \
101 this ticket delivers\",\"context\":\"grounding notes\",\"acceptanceHints\":[\"checkable \
102 hint\"],\"blockedBy\":[\"other-slug\"]}}]\n\
103 \n\
104 Rules:\n\
105 - Each ticket is one mission-sized unit with its own deliverable; together they cover \
106 the goal.\n\
107 - slug: letters, digits, '-', '_' (no spaces, no separators), unique per ticket. \
108 Already taken (never reuse these): {existing}.\n\
109 - blockedBy: slugs that must Complete before this ticket can run — from your proposed \
110 set or the existing tickets. A ticket with no blockedBy is a root; at least one root \
111 is required. The edges must form a DAG: no cycles, no self-edges.\n\
112 - priority: 1 (high) to 3 (low).\n\
113 - List roots first in the array."
114 )
115}
116
117fn existing_slugs(repo_root: &Path) -> Vec<String> {
120 let mut slugs: Vec<String> = Ticket::list(repo_root)
121 .into_iter()
122 .map(|t| t.slug)
123 .collect();
124 slugs.sort();
125 slugs
126}
127
128pub async fn plan_decomposition(
134 backend: &dyn AgentBackend,
135 repo_root: &Path,
136 goal: &str,
137 cfg: &MissionConfig,
138) -> Result<Vec<PlannedNode>> {
139 let role_cfg = cfg.role(Role::Orchestrator).clone();
140 let mut spec = SessionSpec {
141 cwd: repo_root.to_path_buf(),
142 prompt: PromptMode::SingleShot(planner_prompt(goal, &existing_slugs(repo_root))),
143 append_system_prompt: Some(PLANNER_SYSTEM_PROMPT.to_string()),
144 model: role_cfg.model.clone(),
145 effort: role_cfg.reasoning_effort.clone(),
146 session_id: uuid::Uuid::new_v4().to_string(),
147 resume: None,
148 permission_mode: None,
149 allowed_tools: Vec::new(),
150 disallowed_tools: Vec::new(),
151 tools: role_cfg.tools.clone(),
152 writable: false,
153 settings_json: None,
154 json_schema: None,
155 max_budget_usd: role_cfg.max_budget_usd,
156 max_turns: role_cfg.max_turns,
157 env: std::collections::HashMap::new(),
158 sandbox: None,
159 hook_status: None,
160 };
161 permissions::apply(
162 permissions::for_role(Role::Orchestrator, cfg, &[], &[], &[]),
163 &mut spec,
164 );
165
166 let mut session = backend.start(spec).await?;
167 let text = pump_planner(session.as_mut()).await?;
168 parse_planner_output(&text)
169}
170
171async fn pump_planner(session: &mut dyn AgentSession) -> Result<String> {
177 let mut texts: Vec<String> = Vec::new();
178 loop {
179 let event = match tokio::time::timeout(PLANNER_STALL_TIMEOUT, session.next_event()).await {
180 Err(_elapsed) => {
181 return Err(EngineError::Backend(format!(
182 "decompose planner stream stalled (> {PLANNER_STALL_TIMEOUT:?} without an event)"
183 )))
184 }
185 Ok(result) => result?,
186 };
187 match event {
188 None => {
189 let detail = session
190 .exit_status()
191 .map(|e| format!("{e:?}"))
192 .unwrap_or_else(|| "no exit status".to_string());
193 return Err(EngineError::Backend(format!(
194 "decompose planner stream closed without a result ({detail})"
195 )));
196 }
197 Some(AgentEvent::Text { text, .. }) => texts.push(text),
198 Some(AgentEvent::Result { text, is_error, .. }) => {
199 if is_error {
200 return Err(EngineError::Backend(format!(
201 "decompose planner turn returned an error result: {}",
202 scrub::scrub(&text)
203 )));
204 }
205 let turn_text = if text.trim().is_empty() {
206 texts.join("\n")
207 } else {
208 text
209 };
210 return Ok(scrub::scrub(&turn_text));
211 }
212 Some(_) => {}
215 }
216 }
217}
218
219pub fn parse_planner_output(text: &str) -> Result<Vec<PlannedNode>> {
224 let trimmed = text.trim();
225 match serde_json::from_str::<Vec<PlannedNode>>(trimmed) {
226 Ok(nodes) => Ok(nodes),
227 Err(strict_err) => {
228 if let (Some(start), Some(end)) = (trimmed.find('['), trimmed.rfind(']')) {
229 if start < end {
230 if let Ok(nodes) =
231 serde_json::from_str::<Vec<PlannedNode>>(&trimmed[start..=end])
232 {
233 return Ok(nodes);
234 }
235 }
236 }
237 Err(EngineError::Other(format!(
238 "decompose planner did not emit a valid JSON array of tickets \
239 ({strict_err}); reply began: {}",
240 opening_excerpt(trimmed)
241 )))
242 }
243 }
244}
245
246fn opening_excerpt(text: &str) -> String {
248 const MAX: usize = 120;
249 if text.chars().count() > MAX {
250 format!("{}…", text.chars().take(MAX).collect::<String>())
251 } else {
252 text.to_string()
253 }
254}
255
256pub fn validate_nodes(repo_root: &Path, nodes: &[PlannedNode]) -> Result<()> {
270 if nodes.is_empty() {
271 return Err(EngineError::InvalidState(
272 "decompose planner proposed 0 tickets (need 1..=8)".to_string(),
273 ));
274 }
275 if nodes.len() > MAX_NODES {
276 return Err(EngineError::InvalidState(format!(
277 "decompose planner proposed {} tickets (max {MAX_NODES}) — narrow the goal \
278 or split it by hand",
279 nodes.len()
280 )));
281 }
282
283 let dir = Ticket::tickets_dir(repo_root);
284 let mut seen: HashSet<&str> = HashSet::with_capacity(nodes.len());
285 for node in nodes {
286 Ticket::ensure_valid_slug(&node.slug)?;
287 if !seen.insert(node.slug.as_str()) {
288 return Err(EngineError::InvalidState(format!(
289 "decompose planner proposed duplicate slug '{}'",
290 node.slug
291 )));
292 }
293 let path = dir.join(format!("{}.md", node.slug));
294 if path.exists() {
295 return Err(EngineError::InvalidState(format!(
296 "ticket '{}' already exists at {}",
297 node.slug,
298 path.display()
299 )));
300 }
301 }
302
303 for node in nodes {
304 for blocker in &node.blocked_by {
305 Ticket::ensure_valid_slug(blocker)?;
306 if seen.contains(blocker.as_str()) {
307 continue;
308 }
309 let path = dir.join(format!("{blocker}.md"));
310 if !path.is_file() {
311 return Err(EngineError::InvalidState(format!(
312 "node '{}' is blocked by unknown slug '{blocker}': not in the proposed \
313 set and no existing ticket at {}",
314 node.slug,
315 path.display()
316 )));
317 }
318 }
319 }
320
321 if let Some(cycle) = in_set_cycle(nodes) {
322 return Err(EngineError::InvalidState(format!(
323 "blocked-by cycle in the proposed decomposition: {}",
324 cycle.join(" -> ")
325 )));
326 }
327
328 if !nodes.iter().any(|n| n.blocked_by.is_empty()) {
329 return Err(EngineError::InvalidState(
330 "the proposed decomposition has no root: every node is blocked — at least one \
331 node must have an empty blockedBy"
332 .to_string(),
333 ));
334 }
335 Ok(())
336}
337
338fn in_set_cycle(nodes: &[PlannedNode]) -> Option<Vec<String>> {
344 fn dfs<'a>(
345 nodes: &'a [PlannedNode],
346 current: &'a str,
347 path: &mut Vec<&'a str>,
348 on_path: &mut HashSet<&'a str>,
349 ) -> Option<Vec<String>> {
350 let node = nodes.iter().find(|n| n.slug == current)?;
351 for blocker in &node.blocked_by {
352 let b = blocker.as_str();
353 if !nodes.iter().any(|n| n.slug == b) {
354 continue;
355 }
356 if on_path.contains(b) {
357 let mut cycle: Vec<String> = path.iter().map(|s| (*s).to_string()).collect();
358 cycle.push(b.to_string());
359 let start = cycle
360 .iter()
361 .position(|s| s == b)
362 .expect("blocker is in on_path, so it is in path");
363 return Some(cycle[start..].to_vec());
364 }
365 path.push(b);
366 on_path.insert(b);
367 if let Some(found) = dfs(nodes, b, path, on_path) {
368 return Some(found);
369 }
370 path.pop();
371 on_path.remove(b);
372 }
373 None
374 }
375
376 for node in nodes {
377 let mut path = vec![node.slug.as_str()];
378 let mut on_path: HashSet<&str> = HashSet::from([node.slug.as_str()]);
379 if let Some(cycle) = dfs(nodes, &node.slug, &mut path, &mut on_path) {
380 return Some(cycle);
381 }
382 }
383 None
384}
385
386pub fn render_ticket(node: &PlannedNode) -> String {
394 let title = one_line(&scrub::scrub(node.title.trim()));
395 let goal = scrub::scrub(node.goal.trim());
396 let context = scrub::scrub(node.context.trim());
397 let blocked_by = if node.blocked_by.is_empty() {
398 String::new()
399 } else {
400 format!("blocked-by: [{}]\n", node.blocked_by.join(", "))
401 };
402 let mut hints = String::new();
403 for hint in &node.acceptance_hints {
404 let h = one_line(&scrub::scrub(hint.trim()));
405 if !h.is_empty() {
406 hints.push_str("- ");
407 hints.push_str(&h);
408 hints.push('\n');
409 }
410 }
411 format!(
412 "---\n\
413 title: {title}\n\
414 priority: {priority}\n\
415 schedule: once\n\
416 {blocked_by}\
417 ---\n\
418 \n\
419 ## Goal\n\
420 {goal}\n\
421 \n\
422 ## Context\n\
423 {context}\n\
424 \n\
425 ## Scoping answers\n\
426 \n\
427 ## Acceptance hints\n\
428 {hints}",
429 priority = clamp_priority(node.priority),
430 )
431}
432
433fn one_line(text: &str) -> String {
437 text.split(['\n', '\r'])
438 .map(str::trim)
439 .filter(|s| !s.is_empty())
440 .collect::<Vec<_>>()
441 .join(" ")
442}
443
444pub fn render_preview(nodes: &[PlannedNode]) -> String {
447 let slug_w = nodes.iter().map(|n| n.slug.len()).max().unwrap_or(4).max(4);
448 let edge_cell = |n: &PlannedNode| {
449 if n.blocked_by.is_empty() {
450 "(root)".to_string()
451 } else {
452 format!("blocked-by: {}", n.blocked_by.join(", "))
453 }
454 };
455 let edge_w = nodes.iter().map(|n| edge_cell(n).len()).max().unwrap_or(6);
456 let mut out = format!("proposed decomposition: {} ticket(s)\n", nodes.len());
457 for node in nodes {
458 out.push_str(&format!(
459 " {:<slug_w$} pri={} {:<edge_w$} {}\n",
460 node.slug,
461 clamp_priority(node.priority),
462 edge_cell(node),
463 one_line(node.title.trim()),
464 ));
465 }
466 out
467}
468
469pub fn write_dag(repo_root: &Path, nodes: &[PlannedNode]) -> Result<Vec<PathBuf>> {
480 validate_nodes(repo_root, nodes)?;
481
482 let dir = Ticket::tickets_dir(repo_root);
485 let mut staged: Vec<(PathBuf, String)> = Vec::with_capacity(nodes.len());
486 for node in nodes {
487 let body = render_ticket(node);
488 Ticket::parse(&node.slug, &body).map_err(|e| {
489 EngineError::Other(format!(
490 "internal error: rendered ticket '{}' does not parse: {e}",
491 node.slug
492 ))
493 })?;
494 staged.push((dir.join(format!("{}.md", node.slug)), body));
495 }
496
497 std::fs::create_dir_all(&dir)?;
498 let mut written: Vec<PathBuf> = Vec::with_capacity(staged.len());
499 for (path, body) in &staged {
500 if let Err(e) = std::fs::write(path, body) {
501 rollback(&written);
502 return Err(e.into());
503 }
504 written.push(path.clone());
505 }
506
507 for node in nodes {
511 match deps::detect_cycle(repo_root, &node.slug) {
512 Ok(None) => {}
513 Ok(Some(cycle)) => {
514 rollback(&written);
515 return Err(EngineError::InvalidState(format!(
516 "blocked-by cycle: {} — refusing the decomposition (nothing was written)",
517 cycle.join(" -> ")
518 )));
519 }
520 Err(e) => {
521 rollback(&written);
522 return Err(e);
523 }
524 }
525 }
526 Ok(written)
527}
528
529fn rollback(written: &[PathBuf]) {
531 for path in written {
532 let _ = std::fs::remove_file(path);
533 }
534}
535
536#[derive(Debug, Clone)]
545pub struct DecomposeDrive {
546 pub nodes: Vec<PlannedNode>,
547 pub written: Option<Vec<PathBuf>>,
548}
549
550pub async fn drive_decompose(
554 backend: &dyn AgentBackend,
555 repo_root: &Path,
556 goal: &str,
557 cfg: &MissionConfig,
558 yes: bool,
559) -> Result<DecomposeDrive> {
560 let nodes = plan_decomposition(backend, repo_root, goal, cfg).await?;
561 validate_nodes(repo_root, &nodes)?;
562 let written = if yes {
563 Some(write_dag(repo_root, &nodes)?)
564 } else {
565 None
566 };
567 Ok(DecomposeDrive { nodes, written })
568}
569
570#[cfg(test)]
575mod tests {
576 use super::*;
577
578 fn node(slug: &str, blocked_by: &[&str]) -> PlannedNode {
579 PlannedNode {
580 slug: slug.to_string(),
581 title: format!("title for {slug}"),
582 priority: 2,
583 goal: format!("goal for {slug}"),
584 context: String::new(),
585 acceptance_hints: vec![format!("{slug} works")],
586 blocked_by: blocked_by.iter().map(|s| s.to_string()).collect(),
587 }
588 }
589
590 fn md_files(repo: &Path) -> Vec<String> {
591 let dir = Ticket::tickets_dir(repo);
592 let Ok(rd) = std::fs::read_dir(&dir) else {
593 return Vec::new();
594 };
595 let mut files: Vec<String> = rd
596 .flatten()
597 .filter_map(|e| {
598 let name = e.file_name().to_str()?.to_string();
599 name.ends_with(".md").then_some(name)
600 })
601 .collect();
602 files.sort();
603 files
604 }
605
606 #[test]
607 fn valid_chain_validates_and_writes_all_tickets() {
608 let tmp = tempfile::tempdir().unwrap();
609 let repo = tmp.path();
610 let nodes = vec![
611 node("setup-db", &[]),
612 node("api-layer", &["setup-db"]),
613 node("frontend", &["api-layer"]),
614 ];
615
616 let written = write_dag(repo, &nodes).unwrap();
617 assert_eq!(written.len(), 3);
618 assert_eq!(
619 md_files(repo),
620 vec!["api-layer.md", "frontend.md", "setup-db.md"]
621 );
622
623 let frontend = Ticket::load(&Ticket::tickets_dir(repo).join("frontend.md")).unwrap();
624 assert_eq!(frontend.blocked_by, vec!["api-layer".to_string()]);
625 assert_eq!(frontend.schedule, crate::ticket::Schedule::Once);
626 assert_eq!(frontend.goal, "goal for frontend");
627 assert_eq!(
628 frontend.acceptance_hints,
629 vec!["frontend works".to_string()]
630 );
631
632 let root = Ticket::load(&Ticket::tickets_dir(repo).join("setup-db.md")).unwrap();
633 assert!(root.blocked_by.is_empty());
634 assert_eq!(
635 deps::detect_cycle(repo, "setup-db").unwrap(),
636 None,
637 "a written valid DAG must pass the authoritative cycle gate"
638 );
639 }
640
641 #[test]
642 fn planner_priority_is_clamped_to_the_ticket_scale() {
643 let tmp = tempfile::tempdir().unwrap();
644 let repo = tmp.path();
645 let mut high = node("urgent", &[]);
646 high.priority = 0;
647 let mut low = node("whenever", &[]);
648 low.priority = 9;
649
650 write_dag(repo, &[high, low]).unwrap();
651 let urgent = Ticket::load(&Ticket::tickets_dir(repo).join("urgent.md")).unwrap();
652 let whenever = Ticket::load(&Ticket::tickets_dir(repo).join("whenever.md")).unwrap();
653 assert_eq!(urgent.priority, 1);
654 assert_eq!(whenever.priority, 3);
655 }
656
657 #[test]
658 fn ab_cycle_is_refused_loudly_with_zero_files_written() {
659 let tmp = tempfile::tempdir().unwrap();
660 let repo = tmp.path();
661 let nodes = vec![node("a", &["b"]), node("b", &["a"])];
662
663 let err = write_dag(repo, &nodes).unwrap_err();
664 let msg = err.to_string();
665 assert!(
666 msg.contains("cycle"),
667 "expected a cycle refusal, got: {msg}"
668 );
669 assert!(msg.contains("a") && msg.contains("b"));
670 assert!(
671 md_files(repo).is_empty(),
672 "a refused decomposition must leave zero files behind"
673 );
674 }
675
676 #[test]
677 fn self_edge_is_refused_as_a_cycle_with_zero_files_written() {
678 let tmp = tempfile::tempdir().unwrap();
679 let repo = tmp.path();
680 let nodes = vec![node("solo", &["solo"])];
681
682 let err = write_dag(repo, &nodes).unwrap_err();
683 let msg = err.to_string();
684 assert!(
685 msg.contains("cycle"),
686 "expected a cycle refusal, got: {msg}"
687 );
688 assert!(md_files(repo).is_empty());
689 }
690
691 #[test]
692 fn cycle_through_an_existing_ticket_rolls_back_every_new_file() {
693 let tmp = tempfile::tempdir().unwrap();
694 let repo = tmp.path();
695 let dir = Ticket::tickets_dir(repo);
699 std::fs::create_dir_all(&dir).unwrap();
700 std::fs::write(
701 dir.join("old.md"),
702 "---\ntitle: old\npriority: 2\nschedule: once\nblocked-by: [new-node]\n---\n\n## Goal\nold\n",
703 )
704 .unwrap();
705
706 let nodes = vec![node("root-node", &[]), node("new-node", &["old"])];
707 let err = write_dag(repo, &nodes).unwrap_err();
708 let msg = err.to_string();
709 assert!(
710 msg.contains("cycle"),
711 "expected a cycle refusal, got: {msg}"
712 );
713 assert!(
714 msg.contains("new-node") && msg.contains("old"),
715 "the refusal should name the cycle path: {msg}"
716 );
717 assert_eq!(
718 md_files(repo),
719 vec!["old.md"],
720 "the rollback must remove exactly the files this call wrote"
721 );
722 }
723
724 #[test]
725 fn unknown_blocker_is_refused_and_names_the_slug() {
726 let tmp = tempfile::tempdir().unwrap();
727 let repo = tmp.path();
728 let nodes = vec![node("root", &[]), node("child", &["ghost"])];
729
730 let err = write_dag(repo, &nodes).unwrap_err();
731 let msg = err.to_string();
732 assert!(
733 msg.contains("ghost"),
734 "the refusal must name the unknown blocker: {msg}"
735 );
736 assert!(md_files(repo).is_empty());
737 }
738
739 #[test]
740 fn existing_ticket_satisfies_a_blocker_reference() {
741 let tmp = tempfile::tempdir().unwrap();
742 let repo = tmp.path();
743 Ticket::scaffold(repo, "existing-base", "base", None, None).unwrap();
744
745 let nodes = vec![node("follow-up", &["existing-base"])];
746 let err = validate_nodes(repo, &nodes).unwrap_err();
750 assert!(err.to_string().contains("no root"));
751
752 let nodes = vec![node("root", &[]), node("follow-up", &["existing-base"])];
753 let written = write_dag(repo, &nodes).unwrap();
754 assert_eq!(written.len(), 2);
755 let follow_up = Ticket::load(&Ticket::tickets_dir(repo).join("follow-up.md")).unwrap();
756 assert_eq!(follow_up.blocked_by, vec!["existing-base".to_string()]);
757 }
758
759 #[test]
760 fn collision_with_an_existing_ticket_is_refused() {
761 let tmp = tempfile::tempdir().unwrap();
762 let repo = tmp.path();
763 Ticket::scaffold(repo, "taken", "taken", None, None).unwrap();
764
765 let err = write_dag(repo, &[node("taken", &[])]).unwrap_err();
766 assert!(err.to_string().contains("already exists"));
767 assert_eq!(md_files(repo), vec!["taken.md"]);
768 }
769
770 #[test]
771 fn duplicate_slugs_are_refused() {
772 let tmp = tempfile::tempdir().unwrap();
773 let repo = tmp.path();
774 let nodes = vec![node("dup", &[]), node("dup", &[])];
775
776 let err = validate_nodes(repo, &nodes).unwrap_err();
777 assert!(err.to_string().contains("duplicate slug 'dup'"));
778 assert!(md_files(repo).is_empty());
779 }
780
781 #[test]
782 fn invalid_slug_chars_are_refused() {
783 let tmp = tempfile::tempdir().unwrap();
784 let repo = tmp.path();
785 for bad in ["bad slug", "../evil", ".hidden", ""] {
786 let nodes = vec![node(bad, &[])];
787 assert!(
788 validate_nodes(repo, &nodes).is_err(),
789 "slug '{bad}' must be refused"
790 );
791 }
792 assert!(md_files(repo).is_empty());
793 assert!(!repo.join("evil.md").exists());
795 }
796
797 #[test]
798 fn too_many_nodes_are_refused() {
799 let tmp = tempfile::tempdir().unwrap();
800 let repo = tmp.path();
801 let nodes: Vec<PlannedNode> = (0..=MAX_NODES)
802 .map(|i| node(&format!("n{i}"), &[]))
803 .collect();
804 assert_eq!(nodes.len(), MAX_NODES + 1);
805
806 let err = validate_nodes(repo, &nodes).unwrap_err();
807 assert!(err.to_string().contains("max"), "got: {err}");
808 assert!(md_files(repo).is_empty());
809 }
810
811 #[test]
812 fn zero_nodes_are_refused() {
813 let tmp = tempfile::tempdir().unwrap();
814 let repo = tmp.path();
815 let err = validate_nodes(repo, &[]).unwrap_err();
816 assert!(err.to_string().contains("0 tickets"), "got: {err}");
817 }
818
819 #[test]
820 fn malformed_planner_json_is_a_clear_error_not_a_panic() {
821 let err = parse_planner_output("total prose, no json at all").unwrap_err();
822 let msg = err.to_string();
823 assert!(msg.contains("JSON array"), "got: {msg}");
824 assert!(
825 msg.contains("total prose"),
826 "the reply excerpt helps: {msg}"
827 );
828
829 assert!(parse_planner_output("{\"slug\":\"x\"}").is_err());
832 assert!(parse_planner_output("[{\"title\":\"no slug\"}]").is_err());
833 }
834
835 #[test]
836 fn prose_wrapped_and_fenced_arrays_parse_leniently() {
837 let bare = r#"[{"slug":"a","title":"A","priority":1,"goal":"g","context":"c","acceptanceHints":["h"],"blockedBy":[]}]"#;
838 let nodes = parse_planner_output(bare).unwrap();
839 assert_eq!(nodes.len(), 1);
840 assert_eq!(nodes[0].slug, "a");
841 assert_eq!(nodes[0].priority, 1);
842
843 let wrapped = format!("Here is the decomposition you asked for:\n{bare}\nHope that helps!");
844 let nodes = parse_planner_output(&wrapped).unwrap();
845 assert_eq!(nodes.len(), 1);
846
847 let fenced = format!("Sure!\n```json\n{bare}\n```\n");
848 let nodes = parse_planner_output(&fenced).unwrap();
849 assert_eq!(nodes.len(), 1);
850
851 let minimal = r#"[{"slug":"b","title":"B","goal":"g"}]"#;
853 let nodes = parse_planner_output(minimal).unwrap();
854 assert_eq!(nodes[0].priority, 2);
855 assert!(nodes[0].blocked_by.is_empty());
856 }
857
858 #[test]
859 fn frontmatter_injection_through_a_title_is_flattened() {
860 let tmp = tempfile::tempdir().unwrap();
861 let repo = tmp.path();
862 let mut evil = node("evil", &[]);
863 evil.title = "nice title\nblocked-by: [ghost]".to_string();
864
865 write_dag(repo, &[evil]).unwrap();
866 let written = Ticket::load(&Ticket::tickets_dir(repo).join("evil.md")).unwrap();
867 assert!(
868 written.blocked_by.is_empty(),
869 "a newline in the title must not smuggle frontmatter"
870 );
871 assert_eq!(written.title, "nice title blocked-by: [ghost]");
872 }
873
874 #[test]
875 fn render_ticket_round_trips_through_the_ticket_parser() {
876 let mut n = node("round-trip", &["a", "b"]);
877 n.priority = 3;
878 n.context = "some context\nover lines".to_string();
879 let parsed = Ticket::parse("round-trip", &render_ticket(&n)).unwrap();
880 assert_eq!(parsed.title, "title for round-trip");
881 assert_eq!(parsed.priority, 3);
882 assert_eq!(parsed.blocked_by, vec!["a".to_string(), "b".to_string()]);
883 assert_eq!(parsed.goal, "goal for round-trip");
884 assert_eq!(parsed.context, "some context\nover lines");
885 assert_eq!(
886 parsed.acceptance_hints,
887 vec!["round-trip works".to_string()]
888 );
889 }
890
891 #[test]
892 fn render_preview_shows_roots_edges_and_priorities() {
893 let mut low = node("frontend", &["api"]);
894 low.priority = 9;
895 let preview = render_preview(&[node("api", &[]), low]);
896 assert!(preview.contains("proposed decomposition: 2 ticket(s)"));
897 assert!(preview.contains("api"));
898 assert!(preview.contains("(root)"));
899 assert!(preview.contains("blocked-by: api"));
900 assert!(
901 preview.contains("pri=3"),
902 "preview shows the clamped priority"
903 );
904 }
905
906 #[test]
907 fn planner_prompt_carries_the_goal_and_existing_slugs() {
908 let prompt = planner_prompt("build a thing", &["taken-one".to_string()]);
909 assert!(prompt.contains("build a thing"));
910 assert!(prompt.contains("taken-one"));
911 assert!(prompt.contains("blockedBy"));
912 assert!(prompt.contains("JSON"));
913 }
914}