Skip to main content

kranz_engine/
decompose.rs

1//! `kranz decompose` core (ticket: .kranz/tickets/ticket-dag-decomposition.md):
2//! one planner turn decomposes a complex goal into a small DAG of ordinary
3//! tickets linked by `blocked-by` edges — reusing the existing dependency
4//! machinery (deps.rs satisfaction, cycle detection, work-time skip-on-failed-
5//! blocker) instead of inventing new orchestration. The drain then executes
6//! the DAG in dependency order under the existing claim protocol.
7//!
8//! [`drive_decompose`] owns the call order — planner turn ([`plan_decomposition`])
9//! → deterministic validation ([`validate_nodes`]) → all-or-none write
10//! ([`write_dag`], only when the caller passed `--yes`). It never prints; the
11//! CLI renders the DAG preview ([`render_preview`]) and the result. Each
12//! emitted ticket is an ordinary `.kranz/tickets/<slug>.md`: it flows through
13//! `kranz draft` / `kranz ticket queue` exactly like a hand-written ticket —
14//! the mission stays the atom, the DAG is the molecule.
15
16use 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
28/// Hard cap on the tickets one decomposition may propose (the DAG review is a
29/// single up-front glance; past this the operator should split by hand).
30pub const MAX_NODES: usize = 8;
31
32/// Cap on the silence between two planner stream events before the session is
33/// declared dead — mirrors orchestrator.rs's DEFAULT_ORCH_STALL_TIMEOUT (long
34/// thinking pauses are expected; ten minutes of nothing is not).
35const PLANNER_STALL_TIMEOUT: Duration = Duration::from_secs(600);
36
37/// Default priority when the planner omits it (1 high … 3 low), matching
38/// ticket.rs's DEFAULT_PRIORITY.
39fn default_priority() -> u8 {
40    2
41}
42
43/// One node of the planner's proposed decomposition — the JSON shape the
44/// planner prompt contracts for: `{"slug","title","priority","goal","context",
45/// "acceptanceHints":["..."],"blockedBy":["slug"]}`.
46#[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
64/// Priority inherited from the planner output, clamped to the ticket scale
65/// 1 (high) ..= 3 (low).
66fn clamp_priority(priority: u8) -> u8 {
67    priority.clamp(1, 3)
68}
69
70// ---------------------------------------------------------------------------
71// The planner turn — mirrors the draft loop's session shape (orchestrator role
72// config + permissions, read-only, budget-capped), single-shot: there is no
73// mission to seed a streaming orchestrator for, and no follow-up turn.
74// ---------------------------------------------------------------------------
75
76/// System-prompt appendix for the decomposition planner session. Deliberately
77/// NOT the orchestrator role prompt: that one steers toward mission plans with
78/// validation contracts, while this session's whole job is the ticket-DAG JSON
79/// contract the user prompt carries.
80const 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
85/// Build the planner's user prompt: the goal plus the exact output contract
86/// and the validation rules [`validate_nodes`] will enforce deterministically.
87pub 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
117/// Slugs of every parseable ticket currently in the backlog — the planner may
118/// reference them in `blockedBy` and must not reuse them for new nodes.
119fn 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
128/// Run the one-shot planner turn against `backend` and parse the proposed DAG.
129/// Session construction mirrors the draft loop's orchestrator turn (role model/
130/// effort/budget, read-only orchestrator permissions); errors surface the same
131/// way — backend failures as [`EngineError::Backend`], an unparseable reply as
132/// a clear [`EngineError::Other`] naming the contract (never a panic).
133pub 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
171/// Pump the planner session to its terminal `Result`, mirroring
172/// orchestrator.rs's pump_turn text handling: the turn's text is the `Result`
173/// text when non-empty, else the concatenated assistant `Text` blocks — and it
174/// is credential-scrubbed at this single choke point before anything derives
175/// from it.
176async 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            // Init/ToolUse/ToolResult/Other: transcript-level noise here; the
213            // planner's tools are read-only and its contract is the Result text.
214            Some(_) => {}
215        }
216    }
217}
218
219/// Parse the planner's reply into nodes: strict whole-text parse, then the
220/// first-`[`-to-last-`]` substring (prose-wrapped or fenced output) — the
221/// array-shaped twin of runner.rs's `parse_report` leniency. Anything else is
222/// a clear error quoting the reply's opening, never a panic.
223pub 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
246/// First ~120 chars of a reply, for error messages that quote the planner.
247fn 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
256// ---------------------------------------------------------------------------
257// Deterministic validation — the hard gate, run identically for the dry-run
258// preview and the write. Every refusal is loud and writes NOTHING.
259// ---------------------------------------------------------------------------
260
261/// Validate a proposed DAG against the repo's backlog: 1..=[`MAX_NODES`]
262/// nodes; every slug slug-valid, unique within the set, and not colliding with
263/// an existing ticket; every `blockedBy` edge resolving to a slug in the
264/// proposed set or an existing ticket; no cycle within the proposed set; at
265/// least one root (a node with no blockers). Cycles that run THROUGH
266/// pre-existing ticket edges are caught by the authoritative
267/// [`deps::detect_cycle`] gate inside [`write_dag`] (it reads the files, so it
268/// needs the staged write first).
269pub 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
338/// DFS the `blockedBy` edges restricted to the proposed set — the in-memory
339/// twin of deps.rs's `detect_cycle` (same path/on-path bookkeeping, same
340/// `[a, b, a]` cycle shape), so a cyclic proposal is refused with the cycle
341/// named BEFORE any file is staged. Edges leaving the set (to existing
342/// tickets) are skipped here; [`deps::detect_cycle`] owns them post-write.
343fn 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
386// ---------------------------------------------------------------------------
387// Rendering — the same frontmatter/body shape as Ticket::ticket_template, plus
388// the blocked-by edge and acceptance hints the planner supplied.
389// ---------------------------------------------------------------------------
390
391/// Render one node as `.kranz/tickets/<slug>.md` markdown. The result parses
392/// back cleanly through [`Ticket::parse`] ([`write_dag`] proves it per node).
393pub 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
433/// Flatten to a single line: frontmatter scalars and bullet items must never
434/// carry a line break into the rendered ticket — a "\nblocked-by: …" smuggled
435/// inside a title would inject frontmatter past validation.
436fn 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
444/// Render the dry-run/pre-write DAG preview: one line per node with its
445/// priority, its edges (or `(root)`), and its title.
446pub 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
469// ---------------------------------------------------------------------------
470// write_dag — stage all N tickets, validate, then write all or none
471// ---------------------------------------------------------------------------
472
473/// Validate `nodes`, render every ticket, write them all, then run the
474/// authoritative [`deps::detect_cycle`] gate per new slug. Any refusal — a
475/// validation failure, an I/O error mid-write, or a cycle (including one
476/// running through pre-existing ticket edges) — rolls back every file this
477/// call wrote and returns the error loudly: the operator never ends up with
478/// half a DAG. Returns the written paths on success.
479pub fn write_dag(repo_root: &Path, nodes: &[PlannedNode]) -> Result<Vec<PathBuf>> {
480    validate_nodes(repo_root, nodes)?;
481
482    // Stage: render every ticket and prove it parses back (mirrors
483    // Ticket::scaffold's parse-back check) BEFORE any file is written.
484    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    // The authoritative cycle gate (deps::detect_cycle reads the ticket files,
508    // so it also sees edges through pre-existing tickets — the in-set DFS
509    // above cannot). Any cycle rolls the whole write back.
510    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
529/// Best-effort removal of the files a refused [`write_dag`] already wrote.
530fn rollback(written: &[PathBuf]) {
531    for path in written {
532        let _ = std::fs::remove_file(path);
533    }
534}
535
536// ---------------------------------------------------------------------------
537// drive_decompose — the sequencing core (mirrors draft.rs's drive_draft)
538// ---------------------------------------------------------------------------
539
540/// [`drive_decompose`]'s return: the validated nodes plus, when `yes` was
541/// passed, the paths [`write_dag`] wrote (`None` = dry-run preview, nothing
542/// written). The core never prints; the caller renders [`render_preview`] and
543/// the outcome.
544#[derive(Debug, Clone)]
545pub struct DecomposeDrive {
546    pub nodes: Vec<PlannedNode>,
547    pub written: Option<Vec<PathBuf>>,
548}
549
550/// Drive one decomposition: planner turn → [`validate_nodes`] (the preview and
551/// the write are gated identically, so a dry run of an invalid DAG refuses
552/// just as loudly) → [`write_dag`] when `yes`, nothing otherwise.
553pub 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// ---------------------------------------------------------------------------
571// tests — validation/refusal gates over tempdir fixtures (no git, no backend)
572// ---------------------------------------------------------------------------
573
574#[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        // Pre-existing ticket whose blocked-by dangles onto the slug the
696        // planner is about to propose — writing the node closes old -> new ->
697        // old, a cycle only deps::detect_cycle can see (it reads the files).
698        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        // No empty-blockedBy node here — "existing-base" is not in the set, so
747        // this also exercises that the root rule looks only at the proposal.
748        // (It has no root, so validation must refuse; add a root to write.)
749        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        // The traversal attempt must not have escaped the tickets dir.
794        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        // A JSON object (not an array) and an array missing required keys
830        // fail the same clear way.
831        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        // Defaults: missing priority/context/hints/blockedBy fill in.
852        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}