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(§ion);
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}