Skip to main content

kranz_engine/
draft.rs

1//! Non-interactive draft core (roadmap f-1-1): the sequencing behind
2//! `kranz draft`, hoisted out of the CLI so any surface (CLI, REST) can drive
3//! a ticket through its planning conversation.
4//!
5//! [`drive_draft`] owns the engine call order — `write_state(Drafting)` →
6//! `record_mission` → `planning_turn` (seeded with the whole ticket) →
7//! `request_plan` → branch on the result — and the terminal filesystem/queue
8//! side effects ([`Ticket::append_needs_context`],
9//! [`Ticket::append_wrong_plan`], `approve_plan`, `queue::enqueue`). It does
10//! not print anything and does not touch the operator's git checkout; both
11//! stay with the caller.
12
13use crate::error::Result;
14use crate::orchestrator::{MissionEngine, PlanRequest};
15use crate::queue::{self, QueueEntry};
16use crate::ticket::{Ticket, TicketState};
17use crate::types::Plan;
18use std::path::Path;
19
20// ---------------------------------------------------------------------------
21// draft — pure decision helper
22// ---------------------------------------------------------------------------
23
24/// What a `draft` turn resolved to, given the [`PlanRequest`] and whether
25/// `--yes` (auto-approve+enqueue) was passed. Separating the decision from the
26/// I/O keeps the state-machine unit-testable without a backend.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum DraftDecision {
29    /// Plan ready: `approve_plan` (commits plan.md) then set this state.
30    /// `Queued` when `--yes` also enqueues; otherwise `Review` (parked).
31    Approve {
32        then_enqueue: bool,
33        next_state: TicketState,
34    },
35    /// Orchestrator wants answers first: append its questions to the ticket
36    /// and set `NeedsContext`. Short-circuits before any approval.
37    NeedsContext { questions: Vec<String> },
38    /// Planner-initiated escalation: it CAN plan but believes the plan is
39    /// likely wrong. Append the reason to the ticket and set `WrongPlan`.
40    /// Short-circuits before any approval; `--yes` never overrides it.
41    WrongPlan { reason: String },
42}
43
44/// Map a completed plan request + the `--yes` flag to the next action. Pure:
45/// the caller performs the git/state side effects the decision names.
46pub fn draft_decision(request: &PlanRequest, yes: bool) -> DraftDecision {
47    match request {
48        PlanRequest::Ready(_) => DraftDecision::Approve {
49            then_enqueue: yes,
50            next_state: if yes {
51                TicketState::Queued
52            } else {
53                TicketState::Review
54            },
55        },
56        PlanRequest::NotReady(text) => DraftDecision::NeedsContext {
57            questions: split_questions(text),
58        },
59        PlanRequest::WrongPlan { reason } => DraftDecision::WrongPlan {
60            reason: reason.clone(),
61        },
62    }
63}
64
65/// Does a NotReady reply look like a COMPLETE plan the orchestrator chatted
66/// out as prose instead of returning through the plan channel? Matches the
67/// plan schema's two distinctive top-level keys.
68pub fn looks_like_plan_json(reply: &str) -> bool {
69    reply.contains("\"validationContract\"") && reply.contains("\"milestones\"")
70}
71
72/// Split the orchestrator's "not ready" prose into individual questions: each
73/// non-empty line, with any leading bullet/number marker stripped. A reply
74/// with no line breaks becomes a single one-item list.
75pub fn split_questions(text: &str) -> Vec<String> {
76    let items: Vec<String> = text
77        .lines()
78        .map(|l| l.trim())
79        .filter(|l| !l.is_empty())
80        .map(|l| strip_bullet(l).to_string())
81        .filter(|l| !l.is_empty())
82        .collect();
83    if items.is_empty() {
84        // Preserve *something* so the ticket records the orchestrator spoke.
85        vec![text.trim().to_string()]
86            .into_iter()
87            .filter(|s| !s.is_empty())
88            .collect()
89    } else {
90        items
91    }
92}
93
94/// Strip a single leading `-`/`*`/`+` bullet or `N.`/`N)` number marker.
95fn strip_bullet(line: &str) -> &str {
96    for marker in ["- ", "* ", "+ "] {
97        if let Some(rest) = line.strip_prefix(marker) {
98            return rest.trim_start();
99        }
100    }
101    // Numbered: leading digits then `.`/`)` then a space.
102    let bytes = line.as_bytes();
103    let mut i = 0;
104    while i < bytes.len() && bytes[i].is_ascii_digit() {
105        i += 1;
106    }
107    if i > 0 && i < bytes.len() && (bytes[i] == b'.' || bytes[i] == b')') {
108        return line[i + 1..].trim_start();
109    }
110    line
111}
112
113// ---------------------------------------------------------------------------
114// drive_draft — the sequencing core
115// ---------------------------------------------------------------------------
116
117/// Terminal result of driving one ticket through a draft turn.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum DraftOutcome {
120    /// Plan approved and committed on `mission_branch`, parked for review
121    /// (ticket set to [`TicketState::Review`]).
122    ParkedForReview {
123        mission_id: String,
124        mission_branch: String,
125    },
126    /// Plan approved and the mission enqueued (ticket set to
127    /// [`TicketState::Queued`]).
128    Enqueued { mission_id: String },
129    /// The orchestrator wants answers first (ticket set to
130    /// [`TicketState::NeedsContext`], questions appended to the ticket body).
131    NeedsContext {
132        mission_id: String,
133        questions: Vec<String>,
134    },
135    /// The planner escalated: it can produce a plan but believes it is
136    /// likely wrong (ticket set to [`TicketState::WrongPlan`], reason
137    /// appended to the ticket body, `.status` note prefixed `WRONG-PLAN: `).
138    WrongPlan { mission_id: String, reason: String },
139    /// The orchestrator produced a plan but emitted it as prose instead of
140    /// through the plan channel, and a bounded retry did not recover it. No
141    /// plan JSON is filed to the ticket body; the ticket is parked in
142    /// NeedsContext with a short .status note and the user re-runs draft.
143    PlanAsProse { mission_id: String },
144}
145
146/// [`drive_draft`]'s return: the terminal [`DraftOutcome`] plus the display
147/// payload a caller needs to reproduce the pre-hoist CLI output exactly —
148/// the seed reply (from the session-start turn) and, on the Approve path,
149/// the approved [`Plan`]. The core itself never prints either; it only
150/// avoids dropping them.
151#[derive(Debug, Clone)]
152pub struct DraftDrive {
153    pub outcome: DraftOutcome,
154    /// The orchestrator's session-start reply, captured before `request_plan`.
155    pub seed_reply: Option<String>,
156    /// The approved plan, cloned before it was moved into `approve_plan`.
157    /// `None` on the `NeedsContext`, `WrongPlan`, and `PlanAsProse` paths.
158    pub plan: Option<Plan>,
159}
160
161/// Drive `ticket` through one non-interactive draft turn against an
162/// already-constructed `engine` (holding its backend): seed the orchestrator
163/// with the whole ticket, demand the plan, and resolve via [`draft_decision`].
164///
165/// Backend-agnostic and side-effect-scoped to the ticket/queue filesystem
166/// state — no printing, no checkout restoration (the caller's job). On a seed
167/// or plan-request error the ticket is rolled back to [`TicketState::New`]
168/// and the error is propagated.
169pub async fn drive_draft(
170    engine: &mut MissionEngine,
171    repo: &Path,
172    ticket: &Ticket,
173    then_enqueue: bool,
174) -> Result<DraftDrive> {
175    let slug = ticket.slug.as_str();
176    // Notes load BEFORE any state flip: a corrupt notes file fails the draft
177    // with the ticket untouched, never stranded in Drafting.
178    let notes_context = crate::ticket_notes::draft_context(repo, slug)?;
179    Ticket::write_state(repo, slug, TicketState::Drafting, None)?;
180
181    let mission_id = engine.mission_id().to_string();
182    Ticket::record_mission(repo, slug, &mission_id)?;
183
184    // The RECORDED mission goal stays `ticket.mission_goal()` (approve's
185    // goal-matching and `parse_task_class_from_goal` read it back); the notes
186    // ride along in the SEED MESSAGE only — the drafter sees the ticket's
187    // "why" (D-BW-3) without forking the goal the queue later matches on.
188    let mut goal = ticket.mission_goal();
189    if let Some(section) = notes_context {
190        goal.push_str(&section);
191    }
192    if let Err(e) = engine.planning_turn(&goal).await {
193        Ticket::write_state(repo, slug, TicketState::New, None)?;
194        return Err(e);
195    }
196    // Capture the seed reply (session-start turn) so the caller can display
197    // it exactly as pre-hoist `cmd_draft` did; the core itself never prints.
198    let seed_reply = engine.take_seed_reply();
199
200    let request = match engine.request_plan().await {
201        Ok(r) => r,
202        Err(e) => {
203            Ticket::write_state(repo, slug, TicketState::New, None)?;
204            return Err(e);
205        }
206    };
207
208    // A NotReady reply that reads as a complete plan JSON blob means the
209    // orchestrator chatted the plan out instead of returning through the plan
210    // channel — filing that blob as "questions" would dump multi-KB plan JSON
211    // into the ticket body. Give it exactly one more chance via the plan
212    // channel before giving up honestly.
213    if let PlanRequest::NotReady(text) = &request {
214        if looks_like_plan_json(text) {
215            return match engine.request_plan().await {
216                Ok(PlanRequest::Ready(plan)) => Ok(approve(
217                    engine,
218                    repo,
219                    slug,
220                    &mission_id,
221                    ticket,
222                    plan,
223                    then_enqueue,
224                    seed_reply,
225                )?),
226                _ => {
227                    Ticket::write_state(
228                        repo,
229                        slug,
230                        TicketState::NeedsContext,
231                        Some(
232                            "The orchestrator produced a plan but emitted it as prose instead \
233                             of through the plan channel — re-run `kranz draft` for this ticket."
234                                .to_string(),
235                        ),
236                    )?;
237                    Ok(DraftDrive {
238                        outcome: DraftOutcome::PlanAsProse { mission_id },
239                        seed_reply,
240                        plan: None,
241                    })
242                }
243            };
244        }
245    }
246
247    match draft_decision(&request, then_enqueue) {
248        DraftDecision::NeedsContext { questions } => {
249            Ticket::append_needs_context(repo, slug, &questions)?;
250            Ok(DraftDrive {
251                outcome: DraftOutcome::NeedsContext {
252                    mission_id,
253                    questions,
254                },
255                seed_reply,
256                plan: None,
257            })
258        }
259        DraftDecision::WrongPlan { reason } => {
260            Ticket::append_wrong_plan(repo, slug, &reason)?;
261            Ok(DraftDrive {
262                outcome: DraftOutcome::WrongPlan { mission_id, reason },
263                seed_reply,
264                plan: None,
265            })
266        }
267        DraftDecision::Approve {
268            then_enqueue,
269            next_state: _,
270        } => {
271            let PlanRequest::Ready(plan) = request else {
272                unreachable!("Approve decision implies a Ready plan");
273            };
274            approve(
275                engine,
276                repo,
277                slug,
278                &mission_id,
279                ticket,
280                plan,
281                then_enqueue,
282                seed_reply,
283            )
284        }
285    }
286}
287
288/// Shared approval side effects for a [`PlanRequest::Ready`] plan, whether it
289/// arrived via the normal path or the plan-as-prose bounded retry: commit the
290/// plan (`approve_plan`), then either enqueue+park `Queued` or park `Review`.
291#[allow(clippy::too_many_arguments)]
292fn approve(
293    engine: &mut MissionEngine,
294    repo: &Path,
295    slug: &str,
296    mission_id: &str,
297    ticket: &Ticket,
298    plan: Plan,
299    then_enqueue: bool,
300    seed_reply: Option<String>,
301) -> Result<DraftDrive> {
302    let approved_plan = plan.clone();
303    engine.approve_plan(plan)?;
304    let mission_branch = engine.state().mission.mission_branch.clone();
305
306    if then_enqueue {
307        queue::enqueue(
308            repo,
309            QueueEntry {
310                mission_id: mission_id.to_string(),
311                ticket_slug: Some(slug.to_string()),
312                priority: ticket.priority,
313                seq: 0, // assigned by enqueue
314            },
315        )?;
316        Ticket::write_state(repo, slug, TicketState::Queued, None)?;
317        Ok(DraftDrive {
318            outcome: DraftOutcome::Enqueued {
319                mission_id: mission_id.to_string(),
320            },
321            seed_reply,
322            plan: Some(approved_plan),
323        })
324    } else {
325        Ticket::write_state(repo, slug, TicketState::Review, None)?;
326        Ok(DraftDrive {
327            outcome: DraftOutcome::ParkedForReview {
328                mission_id: mission_id.to_string(),
329                mission_branch,
330            },
331            seed_reply,
332            plan: Some(approved_plan),
333        })
334    }
335}