Skip to main content

kranz_cli/
backlog.rs

1//! The backlog CLI: `kranz ticket …`, `kranz draft`, `kranz queue`, and the
2//! `kranz work` dispatcher (design: docs/backlog-and-slack.md).
3//!
4//! Tickets are missions-in-waiting authored as `.kranz/tickets/<slug>.md`.
5//! The pipeline is: `ticket new` scaffolds one; `draft` runs the planning
6//! conversation non-interactively (orchestrator only, budget-capped) and parks
7//! a committed `plan.md` for review (or bounces the ticket back with the
8//! orchestrator's questions); `ticket approve` enqueues the parked mission;
9//! `work` drains the per-repo queue, one mission at a time.
10//!
11//! The rendering and decision logic here are pure functions (data → String,
12//! or state → next-action) so they are unit-tested without a backend; the two
13//! handlers that spawn `claude` (`draft`, `work`) are thin async wrappers over
14//! the shared engine + `run_mission_loop`.
15
16use crate::commands::{build_backend, load_config, run_mission_loop};
17use crate::output;
18use anyhow::{bail, Context, Result};
19use kranz_engine::deps;
20use kranz_engine::draft::{drive_draft, DraftOutcome};
21use kranz_engine::git_ops::GitRepo;
22use kranz_engine::orchestrator::MissionEngine;
23use kranz_engine::queue::{self, QueueEntry};
24use kranz_engine::ticket::{Ticket, TicketState};
25use std::path::{Path, PathBuf};
26
27// Relocated into kranz-engine (roadmap f-1-1): the pure draft decision helpers
28// live in `kranz_engine::draft` now so any surface can reuse the sequencing
29// core. Re-exported here so existing CLI callers/tests keep working.
30pub use kranz_engine::draft::{draft_decision, split_questions, DraftDecision};
31
32// Relocated into kranz-engine (roadmap f-1-1): the drain/claim/skip loop and
33// its pure decision helpers live in `kranz_engine::work` now so any surface
34// (CLI, REST, Slack) can drain a repo's queue. Re-exported here so existing
35// CLI callers/tests keep working.
36pub use kranz_engine::work::{
37    next_work_action, ticket_state_for_mission, work_skip_for_failed_blocker, WorkAction,
38};
39
40// ---------------------------------------------------------------------------
41// ticket new — scaffold
42// ---------------------------------------------------------------------------
43
44/// Back-compat wrapper (no context) over [`Ticket::ticket_template`], which
45/// now lives in `kranz-engine` so the REST `POST /api/tickets` handler shares
46/// it instead of duplicating.
47pub fn ticket_template(title: &str, goal: Option<&str>) -> String {
48    Ticket::ticket_template(title, goal, None)
49}
50
51/// Scaffold `.kranz/tickets/<slug>.md`. Refuses (error) if a ticket with that
52/// slug already exists. Thin wrapper over [`Ticket::scaffold`]. Returns the
53/// written path.
54pub fn cmd_ticket_new(repo: &Path, slug: &str, title: &str, goal: Option<&str>) -> Result<PathBuf> {
55    Ticket::scaffold(repo, slug, title, goal, None).map_err(anyhow::Error::from)
56}
57
58// ---------------------------------------------------------------------------
59// ticket list / show — pure rendering
60// ---------------------------------------------------------------------------
61
62/// UPPERCASE label for a ticket pipeline state (mirrors mission status labels).
63pub fn ticket_state_label(state: TicketState) -> &'static str {
64    match state {
65        TicketState::New => "NEW",
66        TicketState::Drafting => "DRAFTING",
67        TicketState::NeedsContext => "NEEDS-CONTEXT",
68        TicketState::WrongPlan => "WRONG-PLAN",
69        TicketState::Review => "REVIEW",
70        TicketState::Queued => "QUEUED",
71        TicketState::Running => "RUNNING",
72        TicketState::Done => "DONE",
73        TicketState::Failed => "FAILED",
74        TicketState::Parked => "PARKED",
75        // Operator-closed lifecycle states (committed `state:` frontmatter):
76        // rendered distinctly from DONE — no delivery happened.
77        TicketState::Superseded => "SUPERSEDED",
78        TicketState::Wontfix => "WONTFIX",
79    }
80}
81
82/// A ticket's terminal-state label, splitting `Done` into `DELIVERED`
83/// (mission complete but its branch is not yet merged into base) vs
84/// `LANDED` (mission branch merged, or no mission-merge information to
85/// distinguish otherwise) — reusing the engine's merged-ancestor probe
86/// ([`kranz_engine::merged::ticket_merged`]) so this can never drift from the
87/// REST `/api/tickets` projection. Non-`Done` states render exactly as
88/// [`ticket_state_label`].
89pub fn ticket_terminal_label(repo: &Path, slug: &str, state: TicketState) -> &'static str {
90    if state != TicketState::Done {
91        return ticket_state_label(state);
92    }
93    match kranz_engine::merged::ticket_merged(repo, slug) {
94        Some(false) => "DELIVERED",
95        Some(true) | None => "LANDED",
96    }
97}
98
99/// One row of the `kranz ticket list` table: the ticket plus its resolved
100/// terminal label (already split into DELIVERED/LANDED for a Done ticket).
101pub struct TicketRow<'a> {
102    pub ticket: &'a Ticket,
103    pub label: &'static str,
104}
105
106/// Render the `kranz ticket list` table: slug, priority, state, title.
107pub fn render_ticket_list(rows: &[TicketRow<'_>]) -> String {
108    if rows.is_empty() {
109        return "no tickets\n".to_string();
110    }
111    let slug_w = rows
112        .iter()
113        .map(|r| r.ticket.slug.len())
114        .max()
115        .unwrap_or(4)
116        .max(4);
117    let state_w = rows.iter().map(|r| r.label.len()).max().unwrap_or(5).max(5);
118    let mut out = String::new();
119    out.push_str(&format!(
120        "{:<slug_w$}  {:<3}  {:<state_w$}  {}\n",
121        "SLUG", "PRI", "STATE", "TITLE",
122    ));
123    for row in rows {
124        out.push_str(&format!(
125            "{:<slug_w$}  {:<3}  {:<state_w$}  {}\n",
126            row.ticket.slug,
127            row.ticket.priority,
128            row.label,
129            output::one_line(&row.ticket.title, 60),
130        ));
131    }
132    out
133}
134
135/// Render the `kranz ticket ready` table: the ready rows in the same shape as
136/// [`render_ticket_list`], then — only when `include_deferred` asked for it —
137/// a second table of the not-yet-ready deferred tickets with their defer
138/// times (D-BW-3: operator visibility without polluting the default listing).
139pub fn render_ticket_ready(
140    ready: &[TicketRow<'_>],
141    deferred: &[TicketRow<'_>],
142    include_deferred: bool,
143) -> String {
144    let mut out = String::new();
145    if ready.is_empty() {
146        out.push_str("no ready tickets\n");
147    } else {
148        out.push_str(&render_ticket_list(ready));
149    }
150    if include_deferred && !deferred.is_empty() {
151        let slug_w = deferred
152            .iter()
153            .map(|r| r.ticket.slug.len())
154            .max()
155            .unwrap_or(4)
156            .max(4);
157        let state_w = deferred
158            .iter()
159            .map(|r| r.label.len())
160            .max()
161            .unwrap_or(5)
162            .max(5);
163        out.push_str("\ndeferred (not ready yet):\n");
164        out.push_str(&format!(
165            "{:<slug_w$}  {:<3}  {:<state_w$}  {:<25}  {}\n",
166            "SLUG", "PRI", "STATE", "DEFER-UNTIL", "TITLE",
167        ));
168        for row in deferred {
169            let until = row
170                .ticket
171                .defer_until
172                .map(|ts| ts.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
173                .unwrap_or_default();
174            out.push_str(&format!(
175                "{:<slug_w$}  {:<3}  {:<state_w$}  {:<25}  {}\n",
176                row.ticket.slug,
177                row.ticket.priority,
178                row.label,
179                until,
180                output::one_line(&row.ticket.title, 60),
181            ));
182        }
183    }
184    out
185}
186
187/// Render `kranz ticket show <slug>`: the parsed ticket, its resolved
188/// terminal label, and any "needs context" / "wrong plan" block appended to
189/// the ticket body.
190pub fn render_ticket_show(ticket: &Ticket, label: &str) -> String {
191    // Ticket prose is unauthenticated tree data and the "needs context" /
192    // "wrong plan" blocks are planner-authored, so every interpolated field
193    // goes through the control-character filter (H9).
194    let clean = output::sanitize_untrusted;
195    let mut out = String::new();
196    out.push_str(&format!("ticket {} [{}]\n", clean(&ticket.slug), label));
197    out.push_str(&format!("  title:    {}\n", clean(&ticket.title)));
198    out.push_str(&format!("  priority: {}\n", ticket.priority));
199    out.push_str(&format!("  schedule: {:?}\n", ticket.schedule));
200    if let Some(budget) = ticket.max_budget_usd {
201        out.push_str(&format!("  budget:   ${budget:.2}\n"));
202    }
203    if !ticket.repo_refs.is_empty() {
204        out.push_str(&format!(
205            "  refs:     {}\n",
206            clean(&ticket.repo_refs.join(", "))
207        ));
208    }
209    if !ticket.blocked_by.is_empty() {
210        out.push_str(&format!(
211            "  blocked-by: {}\n",
212            clean(&ticket.blocked_by.join(", "))
213        ));
214    }
215
216    if !ticket.goal.trim().is_empty() {
217        out.push_str("\n## Goal\n");
218        out.push_str(clean(ticket.goal.trim()).trim());
219        out.push('\n');
220    }
221    if !ticket.context.trim().is_empty() {
222        out.push_str("\n## Context\n");
223        out.push_str(clean(ticket.context.trim()).trim());
224        out.push('\n');
225    }
226    if !ticket.scoping_answers.is_empty() {
227        out.push_str("\n## Scoping answers\n");
228        for item in &ticket.scoping_answers {
229            out.push_str(&format!("- {}\n", clean(item)));
230        }
231    }
232    if !ticket.acceptance_hints.is_empty() {
233        out.push_str("\n## Acceptance hints\n");
234        for item in &ticket.acceptance_hints {
235            out.push_str(&format!("- {}\n", clean(item)));
236        }
237    }
238
239    // The "needs context" questions are appended to the raw body by the engine;
240    // surface them verbatim so `show` is enough to answer the ticket.
241    if let Some(block) = section_block(&ticket.raw_body, "needs context") {
242        let block = clean(&block);
243        out.push('\n');
244        out.push_str(&block);
245        if !block.ends_with('\n') {
246            out.push('\n');
247        }
248    }
249    // Same for a draft-stage wrong-plan escalation: the planner's reason,
250    // verbatim, so `show` is enough to reframe or re-scope the ticket.
251    if let Some(block) = section_block(&ticket.raw_body, "wrong plan") {
252        let block = clean(&block);
253        out.push('\n');
254        out.push_str(&block);
255        if !block.ends_with('\n') {
256            out.push('\n');
257        }
258    }
259    out
260}
261
262/// Extract a `## <heading>` section (heading + following lines) from a ticket
263/// body, matched case-insensitively by heading prefix (`"needs context"`,
264/// `"wrong plan"`). Returns everything from that heading to the next `##`
265/// heading (or end of body).
266fn section_block(body: &str, heading_prefix: &str) -> Option<String> {
267    let mut lines = body.lines().peekable();
268    let mut collecting = false;
269    let mut out: Vec<&str> = Vec::new();
270    for line in &mut lines {
271        let is_section = line.trim_start().starts_with("##");
272        if collecting && is_section {
273            break; // next section ends the block
274        }
275        if is_section
276            && line
277                .trim_start_matches('#')
278                .trim()
279                .to_ascii_lowercase()
280                .starts_with(heading_prefix)
281        {
282            collecting = true;
283        }
284        if collecting {
285            out.push(line);
286        }
287    }
288    if out.is_empty() {
289        None
290    } else {
291        Some(out.join("\n").trim_end().to_string())
292    }
293}
294
295// ---------------------------------------------------------------------------
296// queue — pure rendering
297// ---------------------------------------------------------------------------
298
299/// Render the `kranz queue` table: position, priority, mission id, ticket
300/// slug, plus a header noting whether the repo is currently busy.
301pub fn render_queue(entries: &[QueueEntry], busy_with: Option<&str>) -> String {
302    let mut out = String::new();
303    match busy_with {
304        Some(id) => out.push_str(&format!("repo busy: mission {id} is running\n")),
305        None => out.push_str("repo idle\n"),
306    }
307    if entries.is_empty() {
308        out.push_str("queue empty\n");
309        return out;
310    }
311    out.push_str(&format!(
312        "{:<3}  {:<3}  {:<14}  {}\n",
313        "#", "PRI", "MISSION", "TICKET"
314    ));
315    for (i, entry) in entries.iter().enumerate() {
316        out.push_str(&format!(
317            "{:<3}  {:<3}  {:<14}  {}\n",
318            i + 1,
319            entry.priority,
320            entry.mission_id,
321            entry.ticket_slug.as_deref().unwrap_or("-"),
322        ));
323    }
324    out
325}
326
327// ---------------------------------------------------------------------------
328// Handlers (list/show/new/approve/queue are backend-free; draft/work spawn)
329// ---------------------------------------------------------------------------
330
331/// `kranz ticket list`.
332pub fn cmd_ticket_list(repo: &Path) -> String {
333    let tickets = Ticket::list(repo);
334    let rows: Vec<TicketRow<'_>> = tickets
335        .iter()
336        .map(|t| {
337            let state = Ticket::read_state(repo, &t.slug);
338            TicketRow {
339                ticket: t,
340                label: ticket_terminal_label(repo, &t.slug, state),
341            }
342        })
343        .collect();
344    render_ticket_list(&rows)
345}
346
347/// `kranz ticket ready [--include-deferred]`: the pick-up-now listing
348/// (D-BW-3). Ready = an actionable pipeline state (not in flight, not
349/// terminal) AND no `defer-until` still in the future — a deferred ticket
350/// simply becomes listable on its day; the clock at listing time is the only
351/// arbiter, there is no scheduler. `--include-deferred` appends the parked
352/// deferred tickets with their defer times for operator visibility.
353pub fn cmd_ticket_ready(repo: &Path, include_deferred: bool) -> String {
354    let now = chrono::Utc::now();
355    let tickets = Ticket::list(repo);
356    let mut ready: Vec<TicketRow<'_>> = Vec::new();
357    let mut deferred: Vec<TicketRow<'_>> = Vec::new();
358    for t in &tickets {
359        let state = Ticket::read_state(repo, &t.slug);
360        if !matches!(
361            state,
362            TicketState::New
363                | TicketState::NeedsContext
364                | TicketState::WrongPlan
365                | TicketState::Review
366                | TicketState::Parked
367        ) {
368            continue;
369        }
370        let row = TicketRow {
371            ticket: t,
372            label: ticket_terminal_label(repo, &t.slug, state),
373        };
374        if t.is_ready_at(now) {
375            ready.push(row);
376        } else {
377            deferred.push(row);
378        }
379    }
380    render_ticket_ready(&ready, &deferred, include_deferred)
381}
382
383/// `kranz ticket show <slug>`.
384pub fn cmd_ticket_show(repo: &Path, slug: &str) -> Result<String> {
385    let path = Ticket::tickets_dir(repo).join(format!("{slug}.md"));
386    if !path.is_file() {
387        bail!("ticket '{slug}' not found at {}", path.display());
388    }
389    let ticket = Ticket::load(&path)?;
390    let state = Ticket::read_state(repo, slug);
391    let label = ticket_terminal_label(repo, slug, state);
392    Ok(render_ticket_show(&ticket, label))
393}
394
395/// `kranz queue`.
396pub fn cmd_queue(repo: &Path) -> String {
397    let entries = queue::list(repo);
398    let busy = queue::is_repo_busy(repo);
399    render_queue(&entries, busy.as_deref())
400}
401
402/// `kranz queue --remove <mission-id>`: retire only the runnable queue entry.
403/// The mission record remains append-only/auditable and may be abandoned or
404/// re-enqueued by the caller's own lifecycle policy.
405pub fn cmd_queue_remove(repo: &Path, mission_id: &str) -> Result<String> {
406    if !queue::remove(repo, mission_id) {
407        bail!("mission '{mission_id}' is not queued");
408    }
409    Ok(format!("removed {mission_id} from the queue\n"))
410}
411
412/// Load a ticket by slug (error if missing).
413fn load_ticket(repo: &Path, slug: &str) -> Result<Ticket> {
414    let path = Ticket::tickets_dir(repo).join(format!("{slug}.md"));
415    if !path.is_file() {
416        bail!("ticket '{slug}' not found at {}", path.display());
417    }
418    Ok(Ticket::load(&path)?)
419}
420
421/// Apply a ticket's per-ticket budget override to the orchestrator role, so
422/// draft spend is bounded by the ticket's `maxBudgetUsd` when it sets one.
423fn config_for_ticket(
424    mut cfg: kranz_engine::types::MissionConfig,
425    ticket: &Ticket,
426) -> kranz_engine::types::MissionConfig {
427    if let Some(budget) = ticket.max_budget_usd {
428        cfg.orchestrator.max_budget_usd = Some(budget);
429    }
430    cfg
431}
432
433/// `kranz draft <slug> [--yes] [--from-mission m-xxxx]`: non-interactive plan
434/// drafting.
435///
436/// Sets the ticket Drafting, creates a mission seeded with the whole ticket,
437/// requests the plan, and resolves via [`draft_decision`]:
438/// - Ready → `approve_plan` (commits plan.md on the mission branch), then set
439///   Review (parked) or, with `--yes`, enqueue + set Queued.
440/// - NotReady → append the orchestrator's questions to the ticket and set
441///   NeedsContext.
442/// - WrongPlan → append the planner's escalation reason to the ticket and set
443///   WrongPlan (parked for the operator; never queued).
444///
445/// `--from-mission` first seeds the ticket's `traced-from-mission`
446/// frontmatter (drafting a defect ticket traced back to the mission that
447/// shipped it — the flight-surgeon false-green join).
448///
449/// Only the orchestrator runs (no workers); spend is bounded by the
450/// orchestrator budget cap (per-ticket override applied).
451pub async fn cmd_draft(
452    repo: PathBuf,
453    slug: &str,
454    yes: bool,
455    from_mission: Option<&str>,
456    dangerously_allow_all: bool,
457) -> Result<i32> {
458    if let Some(mission_id) = from_mission {
459        Ticket::seed_traced_from_mission(&repo, slug, mission_id)
460            .with_context(|| format!("seeding traced-from-mission on ticket '{slug}'"))?;
461        println!("ticket '{slug}' traced from mission {mission_id}");
462    }
463    let ticket = load_ticket(&repo, slug)?;
464    let cfg = config_for_ticket(load_config(&repo, dangerously_allow_all)?, &ticket);
465    let backend = build_backend(&cfg)?;
466
467    // Remember where the operator was: parking the plan checks out the
468    // mission branch, and the draft must put the checkout back afterward.
469    let original_branch = GitRepo::open(&repo)
470        .ok()
471        .and_then(|g| g.current_branch().ok());
472    let mut engine = MissionEngine::create(backend, repo.clone(), &ticket.mission_goal(), cfg)?;
473    println!(
474        "drafting ticket '{slug}' as mission {}",
475        engine.mission_id()
476    );
477
478    let drive = drive_draft(&mut engine, &repo, &ticket, yes)
479        .await
480        .map_err(|e| crate::commands::augment_limit_hint(e.into()))
481        .with_context(|| format!("drafting ticket '{slug}'"))?;
482
483    // Surface any captured seed reply (session start) for visibility, same as
484    // pre-hoist `cmd_draft`.
485    if let Some(seed) = &drive.seed_reply {
486        println!("orchestrator: {}", output::one_line(seed, 200));
487    }
488
489    let mission_branch = engine.state().mission.mission_branch.clone();
490    // The checkout only ever moves in the Approve path (`approve_plan` checks
491    // out the mission branch to commit plan.md); NeedsContext/WrongPlan never
492    // touch it, so — matching pre-hoist `cmd_draft` — only restore when a plan
493    // was produced. Drop the engine (flush + release the mission lock) first.
494    if let Some(plan) = &drive.plan {
495        println!("{}", output::render_plan(plan));
496        drop(engine);
497        restore_draft_checkout(&repo, original_branch.as_deref(), &mission_branch);
498    } else {
499        drop(engine);
500    }
501
502    match drive.outcome {
503        DraftOutcome::NeedsContext {
504            mission_id: _,
505            questions,
506        } => {
507            println!("ticket '{slug}' needs context — the orchestrator asked:");
508            for q in &questions {
509                println!("  - {}", output::sanitize_untrusted(q));
510            }
511            println!(
512                "answer them in {} then run `kranz draft {slug}` again.",
513                Ticket::tickets_dir(&repo)
514                    .join(format!("{slug}.md"))
515                    .display()
516            );
517        }
518        DraftOutcome::WrongPlan { mission_id, reason } => {
519            println!(
520                "ticket '{slug}' WRONG-PLAN escalation (mission {mission_id}) — the planner \
521                 can produce a plan but believes it is likely wrong:"
522            );
523            println!("  {}", output::sanitize_untrusted(&reason));
524            println!(
525                "edit or re-scope {} then run `kranz draft {slug}` again.",
526                Ticket::tickets_dir(&repo)
527                    .join(format!("{slug}.md"))
528                    .display()
529            );
530        }
531        DraftOutcome::PlanAsProse { mission_id } => {
532            println!(
533                "ticket '{slug}' NOT queued: mission {mission_id}'s orchestrator produced a \
534                 plan but emitted it as prose instead of through the plan channel, so nothing \
535                 was queued. Run `kranz draft {slug}` again."
536            );
537        }
538        DraftOutcome::Enqueued { mission_id } => {
539            println!(
540                "plan committed on {mission_branch}; mission {mission_id} approved and QUEUED. \
541                 Run it with `kranz work`."
542            );
543        }
544        DraftOutcome::ParkedForReview {
545            mission_id,
546            mission_branch: _,
547        } => {
548            println!(
549                "plan committed on {mission_branch} for review; mission {mission_id} parked. \
550                 Review it, then run `kranz ticket approve {slug}` to queue it \
551                 (or `kranz plan --mission {mission_id}` to reshape)."
552            );
553        }
554    }
555    Ok(0)
556}
557
558/// `kranz decompose <goal> [--yes]`: one planner turn decomposes a complex
559/// goal into a blocked-by ticket DAG (design: .kranz/tickets/
560/// ticket-dag-decomposition.md). The proposed DAG is always printed; tickets
561/// are written only with `--yes` (dry-run preview otherwise), all-or-none —
562/// any validation refusal (slug rules, unknown blocker, missing root, cycle)
563/// writes nothing.
564///
565/// The sequencing core (planner turn, validation, staged write) lives in
566/// [`kranz_engine::decompose`]; this wrapper keeps the CLI-only concerns —
567/// config/backend resolution (the same call path as [`cmd_draft`]) and
568/// printing. Emitted tickets flow through the normal draft/queue pipeline.
569pub async fn cmd_decompose(
570    repo: PathBuf,
571    goal: &str,
572    yes: bool,
573    dangerously_allow_all: bool,
574) -> Result<i32> {
575    let cfg = load_config(&repo, dangerously_allow_all)?;
576    let backend = build_backend(&cfg)?;
577    let drive =
578        kranz_engine::decompose::drive_decompose(backend.as_ref(), &repo, goal, &cfg, yes).await?;
579
580    print!("{}", kranz_engine::decompose::render_preview(&drive.nodes));
581    match &drive.written {
582        None => println!(
583            "dry run — nothing written; re-run with --yes to write these {} ticket(s).",
584            drive.nodes.len()
585        ),
586        Some(paths) => {
587            for path in paths {
588                println!("wrote {}", path.display());
589            }
590            println!(
591                "draft each node with `kranz draft <slug>` — deps gating runs the DAG in \
592                 dependency order."
593            );
594        }
595    }
596    Ok(0)
597}
598
599/// `kranz ticket queue <slug> [--mission <id>]`: enqueue the parked (Review)
600/// mission for the ticket and set the ticket Queued.
601///
602/// `draft` (no `--yes`) leaves the ticket in Review with a committed plan.md on
603/// a mission branch but nothing in the queue. Queueing picks that mission:
604/// the explicit `--mission` if given, else the newest mission on the repo
605/// whose recorded goal equals the ticket's folded [`Ticket::mission_goal`]
606/// (that is exactly what `draft` seeded it with).
607///
608/// The gate (cycle detection, unsatisfied-blocker refusal) and the enqueue
609/// side effects live in [`deps::approve_ticket`] — the same core the REST
610/// `POST /api/tickets/:slug/approve` handler calls, so the two surfaces can
611/// never drift on what "approvable" means.
612///
613/// Ticket-queueing is verb "Queue" (see docs/scoping/pipeline-view.md
614/// decision D-A); "Approve" is reserved for plan approval.
615pub fn cmd_ticket_queue(
616    repo: &Path,
617    slug: &str,
618    explicit_mission: Option<&str>,
619    force: bool,
620) -> Result<i32> {
621    let approved = deps::approve_ticket(repo, slug, explicit_mission, force)?;
622    println!(
623        "ticket '{slug}' QUEUED (mission {}, priority {}). Run it with `kranz work`.",
624        approved.mission_id, approved.priority
625    );
626    Ok(0)
627}
628
629/// Deprecated alias for [`cmd_ticket_queue`]. `kranz ticket approve` used to
630/// be the only spelling for ticket-queueing; D-A renamed it to `queue` and
631/// reserved "approve" for plan approval. Kept one release for compatibility.
632pub fn cmd_ticket_approve(
633    repo: &Path,
634    slug: &str,
635    explicit_mission: Option<&str>,
636    force: bool,
637) -> Result<i32> {
638    eprintln!("warning: `kranz ticket approve` is deprecated, use `kranz ticket queue` instead");
639    cmd_ticket_queue(repo, slug, explicit_mission, force)
640}
641
642/// `kranz ticket migrate-state [--yes]`: the one-time fold of terminal
643/// `.status` sidecars into committed frontmatter `state:` keys (design
644/// ticket-state-frontmatter, rule 4). Dry-run by default — the report lists
645/// every fold it WOULD make plus the loud per-name skips — `--yes` applies.
646/// The fold logic and its skip rules live in
647/// [`kranz_engine::migrate_state`]; this wrapper only renders.
648pub fn cmd_ticket_migrate_state(repo: &Path, yes: bool) -> Result<i32> {
649    let report = kranz_engine::migrate_state::fold_sidecar_states(repo, yes)?;
650    print!("{}", render_migration_report(&report));
651    Ok(0)
652}
653
654/// Render the fold report: one line per fold (or would-fold), one loud line
655/// per dirty skip, a line per ticket with a NON-terminal sidecar (pipeline
656/// state is left sidecar-owned by design), and a summary. No-sidecar tickets
657/// are summary-counted only — a line each would drown the signal.
658pub fn render_migration_report(report: &kranz_engine::migrate_state::MigrationReport) -> String {
659    use kranz_engine::migrate_state::FoldAction;
660    let verb = if report.applied {
661        "folded"
662    } else {
663        "would fold"
664    };
665    let mut out = String::new();
666    for action in &report.actions {
667        match action {
668            FoldAction::Fold { slug, note } => {
669                out.push_str(&format!(
670                    "{verb} {slug}: .status done → frontmatter state: done"
671                ));
672                if let Some(note) = note {
673                    out.push_str(&format!(" (state-note: {})", output::one_line(note, 60)));
674                }
675                out.push('\n');
676            }
677            FoldAction::SkipDirty { slug } => {
678                out.push_str(&format!(
679                    "SKIP {slug}: uncommitted changes — in-flight work; commit it, then re-run to fold\n"
680                ));
681            }
682            FoldAction::AlreadyMigrated { slug } => {
683                out.push_str(&format!(
684                    "skip {slug}: frontmatter already carries a state: key\n"
685                ));
686            }
687            FoldAction::NoTerminalSidecar {
688                slug,
689                sidecar: Some(state),
690            } => {
691                out.push_str(&format!(
692                    "leave {slug}: sidecar state {} is pipeline, not operator lifecycle\n",
693                    ticket_state_label(*state)
694                ));
695            }
696            FoldAction::NoTerminalSidecar { sidecar: None, .. } => {}
697        }
698    }
699    out.push_str(&format!(
700        "{}: {} {}, {} dirty-skipped, {} already migrated, {} left alone (no terminal sidecar)\n",
701        if report.applied { "applied" } else { "dry run" },
702        report.folds(),
703        if report.applied { "folded" } else { "to fold" },
704        report.dirty_skips(),
705        report.already_migrated(),
706        report.left_alone(),
707    ));
708    if !report.applied && report.folds() > 0 {
709        out.push_str("nothing written — re-run with --yes to apply the fold.\n");
710    }
711    out
712}
713
714/// Find the mission `draft` created for a ticket: the newest-by-event-log
715/// mission whose recorded goal equals the ticket's folded mission goal.
716/// Dispatcher-exit twin of [`restore_draft_checkout`]: put the checkout back
717/// where the operator started `kranz work`. Skipped when the operator was
718/// already on a mission branch (restoring TO one would recreate the very
719/// stranding this exists to end).
720fn restore_work_checkout(repo: &Path, original: Option<&str>) {
721    let Some(original) = original else { return };
722    if original.starts_with("kranz/mission-") {
723        return;
724    }
725    let Ok(git) = GitRepo::open(repo) else { return };
726    if git.current_branch().ok().as_deref() == Some(original) {
727        return;
728    }
729    match git.is_clean_tracked() {
730        Ok(true) => match git.checkout(original) {
731            Ok(()) => println!("checkout restored to {original}"),
732            Err(e) => eprintln!("warning: could not restore checkout to {original}: {e}"),
733        },
734        Ok(false) => {
735            eprintln!("warning: checkout left in place: tracked files have uncommitted changes")
736        }
737        Err(e) => {
738            eprintln!("warning: could not probe the working tree ({e}); checkout left in place")
739        }
740    }
741}
742
743/// Put the checkout back where the operator had it before `kranz draft`
744/// parked the plan. Untracked files ride along; TRACKED modifications abort
745/// the restore — never carry uncommitted operator edits across a branch
746/// switch silently.
747fn restore_draft_checkout(repo: &Path, original: Option<&str>, mission_branch: &str) {
748    let Some(original) = original else { return };
749    if original == mission_branch {
750        return;
751    }
752    let Ok(git) = GitRepo::open(repo) else { return };
753    match git.is_clean_tracked() {
754        Ok(true) => match git.checkout(original) {
755            Ok(()) => println!("checkout restored to {original}"),
756            Err(e) => eprintln!("warning: could not restore checkout to {original}: {e}"),
757        },
758        Ok(false) => eprintln!(
759            "warning: leaving checkout on {mission_branch}: tracked files have \
760             uncommitted changes"
761        ),
762        Err(e) => eprintln!(
763            "warning: could not probe the working tree ({e}); checkout left on {mission_branch}"
764        ),
765    }
766}
767
768/// `kranz work [--once]`: the dispatcher. Drains the per-repo queue one
769/// mission at a time; `--once` processes exactly one front entry (or exits if
770/// the repo is busy). Per-repo serialization is enforced by `is_repo_busy`.
771///
772/// Thin wrapper over [`kranz_engine::work::drain_queue`] (roadmap f-1-1): the
773/// core loop lives in the engine so any surface can drive it headlessly; this
774/// wrapper keeps the CLI-only concerns — operator checkout capture/restore,
775/// live progress printing, and tailing each mission's events to stderr via
776/// [`run_mission_loop`].
777pub async fn cmd_work(repo: PathBuf, once: bool, expected: Option<String>) -> Result<i32> {
778    // Remember the operator's checkout: each mission's run() asserts its own
779    // branch, so when the dispatcher exits it puts the checkout back where
780    // the operator started (tracked-dirty trees abort the restore).
781    let dispatch_branch = GitRepo::open(&repo)
782        .ok()
783        .and_then(|g| g.current_branch().ok());
784    // Claims abandoned by a crashed dispatcher come back first (review P1).
785    // Reported here (rather than inside the engine core) so the CLI keeps its
786    // pre-hoist wording; the core's own `recover_dead_claims` call is a no-op
787    // second pass over whatever's left.
788    let recovered = queue::recover_dead_claims(&repo);
789    if recovered > 0 {
790        println!(
791            "recovered {recovered} claimed queue entr{} from dead dispatchers",
792            if recovered == 1 { "y" } else { "ies" }
793        );
794    }
795
796    let report =
797        kranz_engine::work::drain_queue_expected(&repo, once, expected.as_deref(), |mission_id| {
798            let repo = repo.clone();
799            async move {
800                println!("running mission {mission_id} from the queue");
801                let status = drive_mission(repo, &mission_id).await;
802                if let Err(e) = &status {
803                    eprintln!("kranz: mission {mission_id} errored: {e:#}");
804                }
805                status
806            }
807        })
808        .await?;
809
810    if report.stopped_busy {
811        // `--once` against a busy repo: nothing was claimed or run, so the
812        // checkout is left exactly where the busy sibling dispatcher needs
813        // it — restoring here would switch branches out from under its
814        // still-running mission.
815        return Ok(0);
816    }
817    if let Some(front) = report.expected_mismatch {
818        println!(
819            "queue front changed to {front}; expected {} — nothing ran",
820            expected.as_deref().unwrap_or("-")
821        );
822        restore_work_checkout(&repo, dispatch_branch.as_deref());
823        return Ok(0);
824    }
825    if report.ran.is_empty() && report.skipped.is_empty() && report.parked.is_empty() {
826        println!("queue empty — nothing to do.");
827    } else {
828        if !report.parked.is_empty() {
829            println!("parked (backend not ready): {}", report.parked.join(", "));
830        }
831        if !report.skipped.is_empty() {
832            println!("skipped: {}", report.skipped.join(", "));
833        }
834        if !report.ran.is_empty() {
835            println!("ran: {}", report.ran.join(", "));
836        }
837    }
838    restore_work_checkout(&repo, dispatch_branch.as_deref());
839    Ok(0)
840}
841
842/// Run one queued mission to a terminal state, returning the `run_mission_loop`
843/// exit code (0 complete / 2 blocked / 1 failed). Extracted so `cmd_work` maps
844/// it to a ticket state.
845async fn drive_mission(repo: PathBuf, mission_id: &str) -> Result<i32> {
846    run_mission_loop(
847        repo,
848        mission_id.to_string(),
849        kranz_engine::event_log::LockForce::No,
850        false,
851    )
852    .await
853}
854
855// ---------------------------------------------------------------------------
856// tests — Delivered/Landed split on the CLI ticket list/show renderer
857// ---------------------------------------------------------------------------
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862    use kranz_engine::events::{Event, EventKind};
863    use std::sync::Once;
864    use tempfile::TempDir;
865
866    static ENV_ISOLATION: Once = Once::new();
867
868    /// Mask the host's global/system git config (mirrors
869    /// `crates/engine/tests/merged_test.rs::isolate_git_env`) so identity,
870    /// signing, and hooks never leak into the throwaway repos.
871    fn isolate_git_env() {
872        ENV_ISOLATION.call_once(|| {
873            let missing = std::env::temp_dir().join(format!(
874                "kranz-cli-backlog-test-no-config-{}",
875                std::process::id()
876            ));
877            std::env::set_var("GIT_CONFIG_GLOBAL", &missing);
878            std::env::set_var("GIT_CONFIG_SYSTEM", &missing);
879            if let Ok(ceiling) = std::fs::canonicalize(std::env::temp_dir()) {
880                std::env::set_var("GIT_CEILING_DIRECTORIES", ceiling);
881            }
882        });
883    }
884
885    fn git_available() -> bool {
886        std::process::Command::new("git")
887            .arg("--version")
888            .output()
889            .map(|o| o.status.success())
890            .unwrap_or(false)
891    }
892
893    fn setup() -> bool {
894        isolate_git_env();
895        if git_available() {
896            true
897        } else {
898            kranz_engine::test_capability::skip(
899                kranz_engine::test_capability::capability::GIT,
900                "git is not on PATH",
901            );
902            false
903        }
904    }
905
906    fn raw_git(dir: &Path, args: &[&str]) {
907        let out = std::process::Command::new("git")
908            .args(args)
909            .current_dir(dir)
910            .output()
911            .expect("spawn git");
912        assert!(
913            out.status.success(),
914            "git {args:?} failed: {}",
915            String::from_utf8_lossy(&out.stderr)
916        );
917    }
918
919    /// Fresh repo on branch `main` with one seed commit; returns
920    /// (tempdir, canonicalized root, seed commit sha).
921    fn init_repo() -> (TempDir, PathBuf, String) {
922        let dir = TempDir::new().unwrap();
923        let init = std::process::Command::new("git")
924            .args(["init", "-b", "main"])
925            .current_dir(dir.path())
926            .output()
927            .expect("spawn git init");
928        if !init.status.success() {
929            raw_git(dir.path(), &["init"]);
930            raw_git(dir.path(), &["symbolic-ref", "HEAD", "refs/heads/main"]);
931        }
932        raw_git(dir.path(), &["config", "user.name", "test"]);
933        raw_git(dir.path(), &["config", "user.email", "test@example.com"]);
934        std::fs::write(dir.path().join("README.md"), "seed\n").unwrap();
935        raw_git(dir.path(), &["add", "-A"]);
936        raw_git(dir.path(), &["commit", "-m", "seed"]);
937        let root = std::fs::canonicalize(dir.path()).expect("canonicalize repo root");
938        let sha = {
939            let out = std::process::Command::new("git")
940                .args(["rev-parse", "HEAD"])
941                .current_dir(&root)
942                .output()
943                .expect("rev-parse HEAD");
944            String::from_utf8_lossy(&out.stdout).trim().to_string()
945        };
946        (dir, root, sha)
947    }
948
949    fn write_events(repo_root: &Path, mission_id: &str, kinds: Vec<EventKind>) {
950        let dir = repo_root.join(".kranz").join("missions").join(mission_id);
951        std::fs::create_dir_all(&dir).unwrap();
952        let mut lines = String::new();
953        for (i, kind) in kinds.into_iter().enumerate() {
954            let event = Event {
955                seq: (i + 1) as u64,
956                ts: chrono::Utc::now(),
957                mission_id: mission_id.to_string(),
958                kind,
959            };
960            lines.push_str(&serde_json::to_string(&event).unwrap());
961            lines.push('\n');
962        }
963        std::fs::write(dir.join("events.jsonl"), lines).unwrap();
964    }
965
966    fn created(mission_branch: &str) -> EventKind {
967        EventKind::MissionCreated {
968            goal: "fixture mission".to_string(),
969            base_branch: "main".to_string(),
970            mission_branch: mission_branch.to_string(),
971            config: kranz_engine::types::MissionConfig::default(),
972        }
973    }
974
975    /// Create a Done ticket linked to `mission_id`, with the mission's branch
976    /// branched off `base_sha` and (optionally) merged back into `main`
977    /// before the mission's events are written as Complete.
978    fn scaffold_done_ticket_with_mission(
979        repo_root: &Path,
980        slug: &str,
981        mission_id: &str,
982        base_sha: &str,
983        merge_into_base: bool,
984    ) {
985        Ticket::scaffold(repo_root, slug, "fixture ticket", None, None).unwrap();
986        Ticket::write_state(repo_root, slug, TicketState::Done, None).unwrap();
987        Ticket::record_mission(repo_root, slug, mission_id).unwrap();
988
989        let branch = format!("kranz/mission-{mission_id}");
990        raw_git(repo_root, &["checkout", "-b", &branch, base_sha]);
991        std::fs::write(repo_root.join("feature.txt"), "new feature\n").unwrap();
992        raw_git(repo_root, &["add", "--", "feature.txt"]);
993        raw_git(repo_root, &["commit", "-m", "add feature"]);
994        raw_git(repo_root, &["checkout", "main"]);
995        if merge_into_base {
996            raw_git(repo_root, &["merge", "--no-ff", "--no-edit", &branch]);
997        }
998
999        write_events(
1000            repo_root,
1001            mission_id,
1002            vec![created(&branch), EventKind::MissionCompleted {}],
1003        );
1004    }
1005
1006    #[test]
1007    fn cli_ticket_delivered_landed_when_done_and_unmerged() {
1008        if !setup() {
1009            return;
1010        }
1011        let (_dir, repo_root, base_sha) = init_repo();
1012        scaffold_done_ticket_with_mission(&repo_root, "unmerged", "m-unmerged", &base_sha, false);
1013
1014        let label = ticket_terminal_label(&repo_root, "unmerged", TicketState::Done);
1015        assert_eq!(label, "DELIVERED");
1016
1017        let ticket = load_ticket(&repo_root, "unmerged").unwrap();
1018        assert!(render_ticket_show(&ticket, label).contains("[DELIVERED]"));
1019    }
1020
1021    #[test]
1022    fn cli_ticket_delivered_landed_when_done_and_merged() {
1023        if !setup() {
1024            return;
1025        }
1026        let (_dir, repo_root, base_sha) = init_repo();
1027        scaffold_done_ticket_with_mission(&repo_root, "merged", "m-merged", &base_sha, true);
1028
1029        let label = ticket_terminal_label(&repo_root, "merged", TicketState::Done);
1030        assert_eq!(label, "LANDED");
1031
1032        let ticket = load_ticket(&repo_root, "merged").unwrap();
1033        assert!(render_ticket_show(&ticket, label).contains("[LANDED]"));
1034    }
1035
1036    #[test]
1037    fn cli_ticket_delivered_landed_when_done_and_no_mission() {
1038        if !setup() {
1039            return;
1040        }
1041        let (_dir, repo_root, _base_sha) = init_repo();
1042        Ticket::scaffold(&repo_root, "no-mission", "fixture ticket", None, None).unwrap();
1043        Ticket::write_state(&repo_root, "no-mission", TicketState::Done, None).unwrap();
1044
1045        let label = ticket_terminal_label(&repo_root, "no-mission", TicketState::Done);
1046        assert_eq!(label, "LANDED", "Done with no linked mission => Landed");
1047    }
1048
1049    #[test]
1050    fn cli_ticket_delivered_landed_leaves_non_terminal_states_unchanged() {
1051        if !setup() {
1052            return;
1053        }
1054        let (_dir, repo_root, _base_sha) = init_repo();
1055        Ticket::scaffold(&repo_root, "queued", "fixture ticket", None, None).unwrap();
1056        Ticket::write_state(&repo_root, "queued", TicketState::Queued, None).unwrap();
1057
1058        let label = ticket_terminal_label(&repo_root, "queued", TicketState::Queued);
1059        assert_eq!(label, "QUEUED");
1060        assert_eq!(label, ticket_state_label(TicketState::Queued));
1061
1062        for state in [
1063            TicketState::New,
1064            TicketState::Drafting,
1065            TicketState::NeedsContext,
1066            TicketState::WrongPlan,
1067            TicketState::Review,
1068            TicketState::Running,
1069            TicketState::Failed,
1070            TicketState::Parked,
1071        ] {
1072            assert_eq!(
1073                ticket_terminal_label(&repo_root, "queued", state),
1074                ticket_state_label(state),
1075                "non-Done state {state:?} must render exactly as ticket_state_label"
1076            );
1077        }
1078    }
1079}