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