kranz_engine/ticket.rs
1//! Mission tickets — `.kranz/tickets/<slug>.md` (design: docs/backlog-and-slack.md).
2//!
3//! A ticket is a mission-in-waiting authored by a human as markdown: a small
4//! `---` frontmatter block (parsed here without a YAML dependency) plus body
5//! sections (`## Goal`, `## Context`, `## Scoping answers`, `## Acceptance
6//! hints`). The `.md` stays human-authored; mutable pipeline status lives in a
7//! sibling `<slug>.status` JSON file so the ticket text is never rewritten by
8//! the engine (except the explicit "needs context" append the orchestrator
9//! makes, and the committed lifecycle state below).
10//!
11//! ## Committed lifecycle state (design: ticket-state-frontmatter)
12//!
13//! The `.status` sidecar is gitignored runtime: on a fresh clone it vanishes,
14//! and with it any operator verdict like done/superseded — a closed ticket
15//! would silently re-enter the ready path. The durable home for that verdict
16//! is the ticket .md itself: an optional additive `state:` frontmatter key
17//! ([`TicketLifecycle`]; `open` default, plus terminal `done`, `superseded`,
18//! `wontfix`) with an optional free-text `state-note:`. It is the SINGLE
19//! SOURCE OF TRUTH: reads resolve with frontmatter precedence (a diverging
20//! sidecar cache is logged, never silently followed), and the one lifecycle
21//! write path — [`Ticket::write_lifecycle`] — writes BOTH, demoting the
22//! sidecar to a write-through cache so existing readers keep working. A
23//! ticket with NO `state:` key reads its sidecar exactly as before this
24//! schema existed (backcompat).
25//!
26//! [`Ticket::mission_goal`] folds the whole ticket into one readable markdown
27//! blob so the non-interactive draft driver can seed the orchestrator with the
28//! entire ticket in a single message.
29
30use crate::error::{EngineError, Result};
31use serde::{Deserialize, Serialize};
32use std::io::Read as _;
33use std::path::{Path, PathBuf};
34
35/// Default priority when frontmatter omits it (1 high … 3 low).
36const DEFAULT_PRIORITY: u8 = 2;
37
38/// Heading [`Ticket::mission_goal`] appends before the task class (empty
39/// when the ticket declares none), and [`parse_task_class_from_goal`] looks
40/// for on the way back out.
41const TASK_CLASS_HEADING: &str = "## Task class\n";
42
43/// Ceiling on the per-ticket `max-budget-usd` override (audit M10). Ticket
44/// bytes are unauthenticated tree data — a worker commit or a PR-comment
45/// webhook can write them — and the value raises the orchestrator's own
46/// spend cap, so a ticket may lower or moderately raise the default
47/// ($20, [`crate::types::MissionConfig::default`]) but never name an
48/// unbounded one.
49pub const MAX_TICKET_BUDGET_USD: f64 = 100.0;
50
51/// Longest accepted `task-class` value.
52const MAX_TASK_CLASS_LEN: usize = 64;
53
54/// Every frontmatter key the parser recognizes, in both accepted spellings.
55/// Unknown keys stay ignored for forward compatibility, but are warned about
56/// rather than dropped in silence (audit M10) — a misspelled authority-
57/// bearing key must not read as "accepted".
58const KNOWN_FRONTMATTER_KEYS: &[&str] = &[
59 "title",
60 "priority",
61 "repo-refs",
62 "reporefs",
63 "blocked-by",
64 "blockedby",
65 "task-class",
66 "taskclass",
67 "review-artifact",
68 "reviewartifact",
69 "review-output",
70 "reviewoutput",
71 "trigger",
72 "traced-from-mission",
73 "tracedfrommission",
74 "defer-until",
75 "deferuntil",
76 "schedule",
77 "state",
78 "state-note",
79 "statenote",
80 "max-budget-usd",
81 "maxbudgetusd",
82];
83
84/// Whether `key` is a frontmatter key this parser recognizes. Public so the
85/// allowlist is testable as the list it is, rather than only through the
86/// match arms that consume it.
87pub fn is_known_frontmatter_key(key: &str) -> bool {
88 KNOWN_FRONTMATTER_KEYS.contains(&key)
89}
90
91/// Normalize and validate a `task-class` value: one lowercase identifier of
92/// `[a-z0-9]` and `-`, bounded length. The class selects standards rules and
93/// the executor tier, so a value carrying spaces, path segments, or newlines
94/// is dropped (`None`) rather than passed on — the routing table is
95/// operator-configured, so the SHAPE is checked here and membership stays
96/// the table's business.
97fn parse_task_class_value(slug: &str, raw: &str) -> Option<String> {
98 let value = crate::routing::normalize_task_class(raw);
99 if value.is_empty() {
100 return None;
101 }
102 let well_formed = value.len() <= MAX_TASK_CLASS_LEN
103 && value.starts_with(|c: char| c.is_ascii_alphanumeric())
104 && value
105 .chars()
106 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
107 if !well_formed {
108 tracing::warn!(slug, value = %value, "invalid ticket task-class; ignoring");
109 return None;
110 }
111 Some(value)
112}
113
114/// Recover the task class [`Ticket::mission_goal`] folded in, from a mission
115/// `goal` string. [`crate::orchestrator::MissionEngine::create`] calls this
116/// to route the executor tier for a mission seeded from a ticket, since by
117/// the time `create` runs it only has the folded goal, not the `Ticket`.
118pub fn parse_task_class_from_goal(goal: &str) -> Option<String> {
119 // The engine-authored appendix is last and ALWAYS present (empty when
120 // the ticket sets no class), so `rfind` lands on engine bytes even when
121 // ticket prose carries a lookalike heading.
122 let idx = goal.rfind(TASK_CLASS_HEADING)?;
123 let rest = &goal[idx + TASK_CLASS_HEADING.len()..];
124 let line = rest.lines().next()?.trim();
125 (!line.is_empty()).then(|| line.to_string())
126}
127
128/// How often a ticket re-instantiates. Recurring schedules are re-drafted by
129/// the scheduler; `Once` is the default one-shot ticket.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
131#[serde(rename_all = "kebab-case")]
132pub enum Schedule {
133 #[default]
134 Once,
135 Nightly,
136 Weekly,
137}
138
139impl Schedule {
140 /// Parse case-insensitively; an unknown value maps to [`Schedule::Once`]
141 /// with a warning (a typo should not silently drop the whole ticket).
142 fn parse(raw: &str) -> Schedule {
143 match raw.trim().to_ascii_lowercase().as_str() {
144 "once" => Schedule::Once,
145 "nightly" => Schedule::Nightly,
146 "weekly" => Schedule::Weekly,
147 other => {
148 tracing::warn!(schedule = %other, "unknown ticket schedule; defaulting to once");
149 Schedule::Once
150 }
151 }
152 }
153}
154
155/// The committed, operator-declared lifecycle state of a ticket — the
156/// optional `state:` frontmatter key (design: ticket-state-frontmatter).
157/// Unlike the pipeline [`TicketState`] (which the engine flips as a ticket
158/// moves draft → review → queue → run), this is the human's terminal verdict,
159/// and it lives IN the committed .md so it survives a fresh clone. Terminal
160/// values (`done`/`superseded`/`wontfix`) exclude the ticket from ready/queue
161/// evaluation exactly like terminal pipeline states.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
163pub enum TicketLifecycle {
164 /// Not operator-closed; the sidecar pipeline state governs. This is also
165 /// the meaning of an ABSENT `state:` key (backcompat with tickets
166 /// authored before the schema existed).
167 #[default]
168 Open,
169 Done,
170 Superseded,
171 Wontfix,
172}
173
174impl TicketLifecycle {
175 /// The frontmatter spelling (lower-case, matching the other keys).
176 pub fn as_str(self) -> &'static str {
177 match self {
178 TicketLifecycle::Open => "open",
179 TicketLifecycle::Done => "done",
180 TicketLifecycle::Superseded => "superseded",
181 TicketLifecycle::Wontfix => "wontfix",
182 }
183 }
184
185 /// Parse a `state:` value. An unknown value is a hard error naming the
186 /// ticket (the same rule as `defer-until`, and for the mirror-image
187 /// reason): silently defaulting a mistyped terminal state back to open
188 /// would re-queue work its author explicitly closed — the very bug this
189 /// schema exists to fix.
190 fn parse(slug: &str, raw: &str) -> Result<TicketLifecycle> {
191 match raw.trim().to_ascii_lowercase().as_str() {
192 "open" => Ok(TicketLifecycle::Open),
193 "done" => Ok(TicketLifecycle::Done),
194 "superseded" => Ok(TicketLifecycle::Superseded),
195 "wontfix" => Ok(TicketLifecycle::Wontfix),
196 other => Err(EngineError::Config(format!(
197 "ticket {slug}: invalid state '{other}' (expected open, done, \
198 superseded, or wontfix)"
199 ))),
200 }
201 }
202
203 /// The pipeline projection of a terminal lifecycle state, or `None` for
204 /// [`TicketLifecycle::Open`]: an open ticket makes no lifecycle claim on
205 /// the pipeline, so the sidecar state governs it.
206 fn terminal_pipeline_state(self) -> Option<TicketState> {
207 match self {
208 TicketLifecycle::Open => None,
209 TicketLifecycle::Done => Some(TicketState::Done),
210 TicketLifecycle::Superseded => Some(TicketState::Superseded),
211 TicketLifecycle::Wontfix => Some(TicketState::Wontfix),
212 }
213 }
214}
215
216/// A parsed ticket: frontmatter fields plus body sections.
217#[derive(Debug, Clone, PartialEq)]
218pub struct Ticket {
219 pub slug: String,
220 pub title: String,
221 pub priority: u8,
222 pub repo_refs: Vec<String>,
223 pub schedule: Schedule,
224 pub max_budget_usd: Option<f64>,
225 pub goal: String,
226 pub context: String,
227 pub scoping_answers: Vec<String>,
228 pub acceptance_hints: Vec<String>,
229 /// Slugs of tickets that must reach a Complete mission before this one
230 /// can be approved (`blocked-by: [a, b]` frontmatter).
231 pub blocked_by: Vec<String>,
232 /// Backlog task class (`task-class: execution-class` frontmatter), used
233 /// to route the executor to a tier via [`crate::config::task_class_to_tier`].
234 pub task_class: Option<String>,
235 /// Tracked text artifact reviewed by `spec-review` / `incident-review`.
236 /// It is context, never a writable deliverable.
237 pub review_artifact: Option<String>,
238 /// Required review deliverable. Review tickets default this to
239 /// `reviews/<slug>.md`; non-review tickets carry neither field.
240 pub review_output: Option<String>,
241 /// External trigger provenance (`trigger: ci-failure|pr-comment`
242 /// frontmatter) — set on webhook-drafted tickets (design D-F,
243 /// [`crate::hooks`]); `None` on human-authored tickets.
244 pub trigger: Option<String>,
245 /// Defect→mission link (`traced-from-mission: m-xxxx` frontmatter) — the
246 /// ONE data addition of the flight-surgeon console (ticket
247 /// `flight-surgeon-dashboard`): a defect ticket traces back to the mission
248 /// that shipped it. Seeded by `kranz draft --from-mission` or added by
249 /// hand; absent means "not a traced defect" (no false positives).
250 pub traced_from_mission: Option<String>,
251 /// Deferral (`defer-until: <RFC 3339>` frontmatter, D-BW-3 adopted from
252 /// beads): present but NOT ready until the timestamp passes. Evaluated
253 /// against the clock at listing/admission time — no scheduler machinery;
254 /// `None` means ready now.
255 pub defer_until: Option<chrono::DateTime<chrono::Utc>>,
256 /// Operator-declared lifecycle (`state:` frontmatter; see the module
257 /// docs). `None` = the key is absent, so the `.status` sidecar governs
258 /// exactly as before this schema existed (backcompat); `Some(Open)` = an
259 /// explicit open, which defers to the sidecar the same way.
260 pub lifecycle: Option<TicketLifecycle>,
261 /// The free-text `state-note:` frontmatter carried alongside a lifecycle
262 /// state (e.g. "superseded by the flight-surgeon console"). Never parsed
263 /// for meaning — notes are discussion, not a second state channel.
264 pub state_note: Option<String>,
265 /// The full markdown body (everything after the frontmatter block).
266 pub raw_body: String,
267}
268
269/// Pipeline status of a ticket, stored in `<slug>.status` (never time-based —
270/// determinism matters for the event-sourced engine).
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
272#[serde(rename_all = "kebab-case")]
273pub enum TicketState {
274 #[default]
275 New,
276 Drafting,
277 NeedsContext,
278 /// The planner escalated at draft time: it CAN plan, but believes the
279 /// plan is likely wrong (goal misframed, premise broken). Parks like
280 /// [`TicketState::NeedsContext`] — re-draftable, never schedulable/queueable — but is
281 /// distinct from it everywhere the state surfaces.
282 WrongPlan,
283 Review,
284 Queued,
285 Running,
286 Done,
287 Failed,
288 /// Claimed then removed from the queue because backend readiness failed
289 /// (missing binary, unauthenticated, unsupported config). Distinct from
290 /// [`TicketState::Failed`] so operators can re-queue after fixing the environment
291 /// without treating the mission run itself as a failure.
292 Parked,
293 /// Operator-closed without delivery (`state: superseded` frontmatter —
294 /// the work moved elsewhere). Reached only through frontmatter
295 /// precedence ([`Ticket::read_state`]) or the lifecycle write path;
296 /// terminal everywhere [`TicketState::Done`] is. Additive serde variant: sidecars
297 /// written before it existed never spelled it.
298 Superseded,
299 /// Operator-closed as not-worth-doing (`state: wontfix` frontmatter).
300 /// Same reachability and terminality as [`TicketState::Superseded`].
301 Wontfix,
302}
303
304/// On-disk shape of `<slug>.status`.
305#[derive(Debug, Clone, Serialize, Deserialize)]
306struct StatusFile {
307 state: TicketState,
308 #[serde(skip_serializing_if = "Option::is_none")]
309 note: Option<String>,
310 /// The mission `kranz draft` created for this ticket — the durable
311 /// ticket→mission link `kranz ticket approve <slug>` resolves by.
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 mission_id: Option<String>,
314}
315
316/// One observed frontmatter/sidecar disagreement: the committed frontmatter
317/// `state:` won over the `.status` sidecar cache. Surfaced (and
318/// `tracing::warn!`-logged by [`Ticket::read_state`]) rather than silently
319/// resolved — design rule 2 is "never a silent divergence".
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub struct StateDivergence {
322 /// The winning state, projected from the frontmatter lifecycle.
323 pub frontmatter: TicketState,
324 /// The discarded sidecar cache state.
325 pub sidecar: TicketState,
326}
327
328/// The outcome of resolving a ticket's effective state under frontmatter
329/// precedence (see [`Ticket::resolve_state`]).
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct ResolvedTicketState {
332 /// The state every ready/queue/list evaluation must use.
333 pub state: TicketState,
334 /// `Some` when a present sidecar disagreed with a terminal frontmatter
335 /// state (the frontmatter won). `None` when they agree, when the
336 /// frontmatter defers, or when the cache is simply cold.
337 pub divergence: Option<StateDivergence>,
338}
339
340impl Ticket {
341 /// Directory holding ticket markdown for a repo: `.kranz/tickets/`.
342 pub fn tickets_dir(repo_root: &Path) -> PathBuf {
343 repo_root.join(".kranz").join("tickets")
344 }
345
346 /// A slug is a bare file stem, never a path: reject separators, `..`,
347 /// leading dots, and empties BEFORE any join — a slug like `../x` must
348 /// not escape `.kranz/tickets/` (review P3).
349 pub fn valid_slug(slug: &str) -> bool {
350 !slug.is_empty()
351 && slug.len() <= 128
352 && !slug.starts_with('.')
353 && !slug.contains("..")
354 && slug
355 .chars()
356 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
357 }
358
359 /// [`valid_slug`](Self::valid_slug) as an error for write/scaffold paths.
360 pub fn ensure_valid_slug(slug: &str) -> Result<()> {
361 if Self::valid_slug(slug) {
362 Ok(())
363 } else {
364 Err(EngineError::Config(format!(
365 "invalid ticket slug '{slug}': use letters, digits, '-', '_' \
366 (no path separators, no leading dot, no '..')"
367 )))
368 }
369 }
370
371 /// The scaffolded body of a new ticket. Frontmatter carries the title;
372 /// the body is the four sections the orchestrator expects (`## Goal`,
373 /// `## Context`, `## Scoping answers`, `## Acceptance hints`), pre-seeded
374 /// with the goal/context when supplied. The result parses back cleanly
375 /// through [`Ticket::parse`].
376 pub fn ticket_template(title: &str, goal: Option<&str>, context: Option<&str>) -> String {
377 let title = crate::scrub::scrub(title.trim());
378 let goal_body = goal
379 .map(str::trim)
380 .filter(|g| !g.is_empty())
381 .map(crate::scrub::scrub)
382 .unwrap_or_default();
383 let context_body = context
384 .map(str::trim)
385 .filter(|c| !c.is_empty())
386 .map(crate::scrub::scrub)
387 .unwrap_or_default();
388 format!(
389 "---\n\
390 title: {title}\n\
391 priority: 2\n\
392 schedule: once\n\
393 ---\n\
394 \n\
395 ## Goal\n\
396 {goal_body}\n\
397 \n\
398 ## Context\n\
399 {context_body}\n\
400 \n\
401 ## Scoping answers\n\
402 \n\
403 ## Acceptance hints\n"
404 )
405 }
406
407 /// Scaffold `.kranz/tickets/<slug>.md` from the template. Shared by the
408 /// CLI (`kranz ticket new`) and the REST `POST /api/tickets` handler so
409 /// the template lives in exactly one place. `EngineError::Config` for an
410 /// invalid slug, `EngineError::InvalidState` (→ 409 over REST) if a
411 /// ticket with that slug already exists. Returns the written path.
412 pub fn scaffold(
413 repo_root: &Path,
414 slug: &str,
415 title: &str,
416 goal: Option<&str>,
417 context: Option<&str>,
418 ) -> Result<PathBuf> {
419 let body = Self::ticket_template(title, goal, context);
420 Self::create_markdown(repo_root, slug, &body)
421 }
422
423 /// Create a complete ticket without following links or replacing an
424 /// existing entry. Retain directory capabilities through the write so a
425 /// concurrent parent rename cannot redirect it outside the ticket tree.
426 pub fn create_markdown(repo_root: &Path, slug: &str, body: &str) -> Result<PathBuf> {
427 use cap_fs_ext::OpenOptionsFollowExt as _;
428 use cap_primitives::fs::FollowSymlinks;
429 use cap_std::ambient_authority;
430 use cap_std::fs::{Dir, OpenOptions};
431 use std::io::Write as _;
432
433 Self::ensure_valid_slug(slug)?;
434 Self::parse(slug, body)?;
435 let mut dir = Dir::open_ambient_dir(repo_root, ambient_authority())?;
436 let mut path = repo_root.to_path_buf();
437 for segment in [".kranz", "tickets"] {
438 path.push(segment);
439 dir = crate::paths::open_real_subdir(&dir, segment, &path, true)?;
440 }
441 let name = format!("{slug}.md");
442 path.push(&name);
443 let mut options = OpenOptions::new();
444 options
445 .write(true)
446 .create_new(true)
447 .follow(FollowSymlinks::No);
448 let mut file = dir.open_with(&name, &options).map_err(|error| {
449 if error.kind() == std::io::ErrorKind::AlreadyExists {
450 EngineError::InvalidState(format!(
451 "ticket '{slug}' already exists at {}",
452 path.display()
453 ))
454 } else {
455 error.into()
456 }
457 })?;
458 file.write_all(body.as_bytes())?;
459 file.sync_data()?;
460 Ok(path)
461 }
462
463 /// Parse ticket markdown. `slug` is supplied by the caller (usually the
464 /// file stem). Malformed frontmatter is an [`EngineError::Config`].
465 pub fn parse(slug: &str, markdown: &str) -> Result<Ticket> {
466 let (front, body) = split_frontmatter(slug, markdown)?;
467
468 let mut title: Option<String> = None;
469 let mut priority = DEFAULT_PRIORITY;
470 let mut repo_refs: Vec<String> = Vec::new();
471 let mut schedule = Schedule::Once;
472 let mut max_budget_usd: Option<f64> = None;
473 let mut blocked_by: Vec<String> = Vec::new();
474 let mut task_class: Option<String> = None;
475 let mut review_artifact: Option<String> = None;
476 let mut review_output: Option<String> = None;
477 let mut trigger: Option<String> = None;
478 let mut traced_from_mission: Option<String> = None;
479 let mut defer_until: Option<chrono::DateTime<chrono::Utc>> = None;
480 let mut lifecycle: Option<TicketLifecycle> = None;
481 let mut state_note: Option<String> = None;
482
483 for (key, value) in front {
484 match key.as_str() {
485 "title" => title = Some(value.scalar()),
486 "priority" => {
487 if let Ok(p) = value.scalar().parse::<u8>() {
488 priority = p;
489 } else {
490 tracing::warn!(slug, value = %value.scalar(), "invalid ticket priority; keeping default");
491 }
492 }
493 // Both spellings — frontmatter is kebab-case per the design doc,
494 // but tolerate the camelCase a hand-editor might type.
495 "repo-refs" | "reporefs" => repo_refs = value.list(),
496 "blocked-by" | "blockedby" => blocked_by = value.list(),
497 "task-class" | "taskclass" => {
498 task_class = parse_task_class_value(slug, &value.scalar());
499 }
500 "review-artifact" | "reviewartifact" => {
501 let v = value.scalar().trim().to_string();
502 review_artifact = if v.is_empty() { None } else { Some(v) };
503 }
504 "review-output" | "reviewoutput" => {
505 let v = value.scalar().trim().to_string();
506 review_output = if v.is_empty() { None } else { Some(v) };
507 }
508 // External trigger provenance (design D-F); additive — older
509 // readers ignore it via the unknown-key arm below.
510 "trigger" => {
511 let v = value.scalar().trim().to_string();
512 trigger = if v.is_empty() { None } else { Some(v) };
513 }
514 // Defect→mission link (flight-surgeon console); additive —
515 // older readers ignore it via the unknown-key arm below.
516 "traced-from-mission" | "tracedfrommission" => {
517 let v = value.scalar().trim().to_string();
518 traced_from_mission = if v.is_empty() { None } else { Some(v) };
519 }
520 // Deferral (D-BW-3); additive. Unlike the warn-and-default
521 // scalar fields, a malformed timestamp is a hard parse error
522 // naming the ticket: silently dropping a deferral would queue
523 // work its author explicitly parked.
524 "defer-until" | "deferuntil" => {
525 let v = value.scalar().trim().to_string();
526 if !v.is_empty() {
527 defer_until = Some(parse_defer_until(slug, &v)?);
528 }
529 }
530 "schedule" => schedule = Schedule::parse(&value.scalar()),
531 // The committed lifecycle state (design
532 // ticket-state-frontmatter); additive — older readers ignore
533 // it via the unknown-key arm below. An empty value is absent;
534 // an unknown one is a hard parse error (see
535 // [`TicketLifecycle::parse`]).
536 "state" => {
537 let v = value.scalar().trim().to_string();
538 if !v.is_empty() {
539 lifecycle = Some(TicketLifecycle::parse(slug, &v)?);
540 }
541 }
542 // Free-text companion to `state:`; additive. Never parsed for
543 // meaning — notes are discussion, not a state channel.
544 "state-note" | "statenote" => {
545 let v = value.scalar().trim().to_string();
546 state_note = if v.is_empty() { None } else { Some(v) };
547 }
548 // Clamped to [`MAX_TICKET_BUDGET_USD`]: the value raises the
549 // orchestrator's own spend cap and the ticket that carries
550 // it is unauthenticated tree data (audit M10). A negative,
551 // zero, or non-finite budget is not a lower cap, it is
552 // nonsense — dropped, so the configured default stands.
553 "maxbudgetusd" | "max-budget-usd" => match value.scalar().parse::<f64>() {
554 Ok(b) if b.is_finite() && b > 0.0 => {
555 if b > MAX_TICKET_BUDGET_USD {
556 tracing::warn!(
557 slug,
558 requested = b,
559 ceiling = MAX_TICKET_BUDGET_USD,
560 "ticket maxBudgetUsd exceeds the ceiling; clamping"
561 );
562 }
563 max_budget_usd = Some(b.min(MAX_TICKET_BUDGET_USD));
564 }
565 _ => {
566 tracing::warn!(slug, value = %value.scalar(), "invalid ticket maxBudgetUsd; ignoring");
567 }
568 },
569 // Unknown keys are ignored (forward compatibility) but
570 // named in the log: an authority-bearing key that is a
571 // typo away from a real one must not read as accepted.
572 other => {
573 debug_assert!(!is_known_frontmatter_key(other));
574 tracing::warn!(slug, key = %other, "unknown ticket frontmatter key; ignoring");
575 }
576 }
577 }
578
579 let sections = parse_sections(&body);
580
581 let goal = sections.goal.unwrap_or_default();
582 let context = sections.context.unwrap_or_default();
583
584 // Title fallback chain: frontmatter → first heading → slug.
585 let title = title
586 .filter(|t| !t.trim().is_empty())
587 .or(sections.first_heading)
588 .unwrap_or_else(|| slug.to_string());
589 let review_contract = crate::review_artifact::from_ticket_fields(
590 slug,
591 task_class.as_deref(),
592 review_artifact.as_deref(),
593 review_output.as_deref(),
594 )?;
595 let (review_artifact, review_output) = review_contract
596 .map(|contract| (Some(contract.input_path), Some(contract.output_path)))
597 .unwrap_or((None, None));
598
599 Ok(Ticket {
600 slug: slug.to_string(),
601 title,
602 priority,
603 repo_refs,
604 schedule,
605 max_budget_usd,
606 goal,
607 context,
608 scoping_answers: sections.scoping_answers,
609 acceptance_hints: sections.acceptance_hints,
610 blocked_by,
611 task_class,
612 review_artifact,
613 review_output,
614 trigger,
615 traced_from_mission,
616 defer_until,
617 lifecycle,
618 state_note,
619 raw_body: body,
620 })
621 }
622
623 /// Load and parse a ticket file; the slug is the file stem.
624 ///
625 /// The read is NO-FOLLOW ([`crate::paths::open_read_nofollow`]): tickets
626 /// live in a worker-writable tree and this loader runs unsandboxed in
627 /// both the CLI and `kranz serve`, so a symlinked `<slug>.md` would
628 /// otherwise hand a reader any file the process can open (audit: server
629 /// leaf reads follow symlinks).
630 pub fn load(path: &Path) -> Result<Ticket> {
631 let slug = path
632 .file_stem()
633 .and_then(|s| s.to_str())
634 .ok_or_else(|| {
635 EngineError::Config(format!("ticket path has no file stem: {}", path.display()))
636 })?
637 .to_string();
638 let mut text = String::new();
639 crate::paths::open_read_nofollow(path)?.read_to_string(&mut text)?;
640 Ticket::parse(&slug, &text)
641 }
642
643 /// Parse every `*.md` under `.kranz/tickets/`, skipping (with a warning) any
644 /// file that fails to parse. Sorted by `(priority, slug)`.
645 pub fn list(repo_root: &Path) -> Vec<Ticket> {
646 let dir = Self::tickets_dir(repo_root);
647 let mut out = Vec::new();
648 let Ok(rd) = std::fs::read_dir(&dir) else {
649 return out;
650 };
651 for entry in rd.flatten() {
652 let path = entry.path();
653 if path.extension().and_then(|e| e.to_str()) != Some("md") {
654 continue;
655 }
656 match Ticket::load(&path) {
657 Ok(t) => out.push(t),
658 Err(e) => {
659 tracing::warn!(path = %path.display(), error = %e, "skipping unparseable ticket");
660 }
661 }
662 }
663 out.sort_by(|a, b| {
664 a.priority
665 .cmp(&b.priority)
666 .then_with(|| a.slug.cmp(&b.slug))
667 });
668 out
669 }
670
671 /// Whether the ticket is ready at `now`: a `defer-until` timestamp in the
672 /// future parks it (D-BW-3); anything else — absent, or past — is ready.
673 /// Callers supply the clock so the check is explicit at each listing /
674 /// admission site (there is no scheduler flipping state).
675 pub fn is_ready_at(&self, now: chrono::DateTime<chrono::Utc>) -> bool {
676 self.defer_until.is_none_or(|until| until <= now)
677 }
678
679 /// Fold the whole ticket into one readable-markdown message: the goal plus
680 /// a compact appendix carrying scoping answers, acceptance hints, context,
681 /// and (when set) the task class — enough for a draft driver to seed the
682 /// orchestrator in one go, and the one channel that carries the task
683 /// class into [`crate::orchestrator::MissionEngine::create`] (which
684 /// recovers it via [`parse_task_class_from_goal`]) since every seed path —
685 /// CLI, REST, Slack — creates the mission from this folded string, not
686 /// the `Ticket` itself.
687 pub fn mission_goal(&self) -> String {
688 let mut out = String::new();
689 if self.goal.trim().is_empty() {
690 out.push_str(&self.title);
691 } else {
692 out.push_str(self.goal.trim());
693 }
694
695 if !self.scoping_answers.is_empty() {
696 out.push_str("\n\n## Scoping answers\n");
697 for item in &self.scoping_answers {
698 out.push_str("- ");
699 out.push_str(item);
700 out.push('\n');
701 }
702 }
703
704 if !self.acceptance_hints.is_empty() {
705 out.push_str("\n## Acceptance hints\n");
706 for item in &self.acceptance_hints {
707 out.push_str("- ");
708 out.push_str(item);
709 out.push('\n');
710 }
711 }
712
713 if !self.context.trim().is_empty() {
714 out.push_str("\n## Context\n");
715 out.push_str(self.context.trim());
716 out.push('\n');
717 }
718
719 let review_contract = crate::review_artifact::from_ticket_fields(
720 &self.slug,
721 self.task_class.as_deref(),
722 self.review_artifact.as_deref(),
723 self.review_output.as_deref(),
724 )
725 .expect("parsed ticket keeps a valid review-artifact contract");
726 if let Some(contract) = review_contract {
727 out.push_str(&crate::review_artifact::render_goal_section(&contract));
728 }
729
730 // The engine's block is appended UNCONDITIONALLY, empty when the
731 // ticket declares no class (audit H10). Skipping it for an unset
732 // class left `parse_task_class_from_goal`'s `rfind` to land on a
733 // lookalike heading in ticket prose — which a PR comment can write
734 // through the webhook — and a forged class drops every
735 // task-class-scoped enforced rule from the approval pin. With the
736 // block always last, the recovery reads engine bytes or nothing.
737 let task_class = self
738 .task_class
739 .as_deref()
740 .map(str::trim)
741 .filter(|class| !class.is_empty())
742 .unwrap_or_default();
743 out.push('\n');
744 out.push_str(TASK_CLASS_HEADING);
745 out.push_str(task_class);
746 out.push('\n');
747
748 out
749 }
750
751 // -- status file ------------------------------------------------------
752
753 /// Path of the sibling status file for a slug.
754 fn status_path(repo_root: &Path, slug: &str) -> PathBuf {
755 Self::tickets_dir(repo_root).join(format!("{slug}.status"))
756 }
757
758 /// Path of the ticket markdown for a slug.
759 pub(crate) fn md_path(repo_root: &Path, slug: &str) -> PathBuf {
760 Self::tickets_dir(repo_root).join(format!("{slug}.md"))
761 }
762
763 /// The `state:` frontmatter lifecycle of a ticket, scanned without
764 /// parsing body sections. `None` when the .md is missing, has no
765 /// frontmatter block, or carries no `state:` key — all cases where the
766 /// sidecar governs exactly as before the schema existed.
767 fn frontmatter_lifecycle(repo_root: &Path, slug: &str) -> Option<TicketLifecycle> {
768 let text = std::fs::read_to_string(Self::md_path(repo_root, slug)).ok()?;
769 let (front, _) = split_frontmatter(slug, &text).ok()?;
770 for (key, value) in front {
771 if key != "state" {
772 continue;
773 }
774 let v = value.scalar().trim().to_string();
775 if v.is_empty() {
776 return None;
777 }
778 return match TicketLifecycle::parse(slug, &v) {
779 Ok(lifecycle) => Some(lifecycle),
780 // [`Ticket::parse`] hard-errors on the same value, so the
781 // ticket is already dropped (loudly) from every listing; the
782 // read path stays total and lets the sidecar govern.
783 Err(e) => {
784 tracing::warn!(slug, error = %e, "invalid frontmatter state; sidecar governs");
785 None
786 }
787 };
788 }
789 None
790 }
791
792 /// The sidecar's pipeline state, or `None` when no readable `.status`
793 /// exists. Distinguishing absent from [`TicketState::New`] matters for
794 /// divergence reporting: a cold cache (fresh clone) cannot disagree.
795 fn sidecar_state(repo_root: &Path, slug: &str) -> Option<TicketState> {
796 Self::read_status_file(repo_root, slug).map(|sf| sf.state)
797 }
798
799 /// Resolve the effective ticket state under FRONTMATTER PRECEDENCE
800 /// (design ticket-state-frontmatter): a terminal `state:` key in the
801 /// committed .md wins over the `.status` sidecar — the sidecar is a
802 /// write-through cache, never the truth. A PRESENT sidecar that
803 /// disagrees is reported as a [`StateDivergence`] (a missing sidecar is
804 /// a cold cache, not a divergence). An absent key or an explicit `open`
805 /// defers to the sidecar; no sidecar at all is [`TicketState::New`].
806 pub fn resolve_state(repo_root: &Path, slug: &str) -> ResolvedTicketState {
807 if !Self::valid_slug(slug) {
808 return ResolvedTicketState {
809 state: TicketState::New,
810 divergence: None,
811 };
812 }
813 let sidecar = Self::sidecar_state(repo_root, slug);
814 let defer = |state: TicketState| ResolvedTicketState {
815 state,
816 divergence: None,
817 };
818 let Some(lifecycle) = Self::frontmatter_lifecycle(repo_root, slug) else {
819 return defer(sidecar.unwrap_or(TicketState::New));
820 };
821 let Some(terminal) = lifecycle.terminal_pipeline_state() else {
822 // Explicit `open`: no lifecycle claim — the pipeline governs.
823 return defer(sidecar.unwrap_or(TicketState::New));
824 };
825 let divergence = match sidecar {
826 Some(sidecar) if sidecar != terminal => Some(StateDivergence {
827 frontmatter: terminal,
828 sidecar,
829 }),
830 _ => None,
831 };
832 ResolvedTicketState {
833 state: terminal,
834 divergence,
835 }
836 }
837
838 /// Read the effective pipeline state; a missing or unreadable status file
839 /// is [`TicketState::New`], and an invalid slug never touches the
840 /// filesystem. Frontmatter precedence per [`Self::resolve_state`]: a
841 /// terminal `state:` key wins, and a diverging sidecar cache is logged —
842 /// never a silent divergence (design rule 2).
843 pub fn read_state(repo_root: &Path, slug: &str) -> TicketState {
844 let resolved = Self::resolve_state(repo_root, slug);
845 if let Some(divergence) = &resolved.divergence {
846 tracing::warn!(
847 slug,
848 frontmatter = ?divergence.frontmatter,
849 sidecar = ?divergence.sidecar,
850 "ticket frontmatter state overrides diverging .status cache"
851 );
852 }
853 resolved.state
854 }
855
856 /// Write the pipeline state (plus an optional note) as JSON. Nothing
857 /// time-based is recorded, so the file is a pure function of its inputs.
858 pub fn write_state(
859 repo_root: &Path,
860 slug: &str,
861 state: TicketState,
862 note: Option<String>,
863 ) -> Result<()> {
864 Self::ensure_valid_slug(slug)?;
865 let dir = Self::tickets_dir(repo_root);
866 std::fs::create_dir_all(&dir)?;
867 // Preserve an existing mission link: state flips (Review→Queued→Done)
868 // must never erase which mission the draft created.
869 let mission_id = Self::read_status_file(repo_root, slug).and_then(|sf| sf.mission_id);
870 let sf = StatusFile {
871 state,
872 note,
873 mission_id,
874 };
875 let json = serde_json::to_string_pretty(&sf)?;
876 atomic_write(&Self::status_path(repo_root, slug), json.as_bytes())?;
877 Ok(())
878 }
879
880 fn read_status_file(repo_root: &Path, slug: &str) -> Option<StatusFile> {
881 if !Self::valid_slug(slug) {
882 return None;
883 }
884 let path = Self::status_path(repo_root, slug);
885 // A missing file is the normal "never drafted/approved" case (no log);
886 // a PRESENT but unparseable file is corruption worth surfacing, so the
887 // reverse lookup and mission-link preservation don't fail silently.
888 let text = std::fs::read_to_string(&path).ok()?;
889 match serde_json::from_str(&text) {
890 Ok(sf) => Some(sf),
891 Err(e) => {
892 tracing::warn!(path = %path.display(), error = %e, "unreadable ticket status; ignoring");
893 None
894 }
895 }
896 }
897
898 /// The raw sidecar record — pipeline state plus note — or `None` when no
899 /// readable `.status` exists. Crate-internal: the state fold
900 /// ([`crate::migrate_state`]) needs the note to carry it into the
901 /// frontmatter `state-note:`, and only the fold should be reading sidecar
902 /// notes at all (notes are never parsed for state).
903 pub(crate) fn sidecar_record(
904 repo_root: &Path,
905 slug: &str,
906 ) -> Option<(TicketState, Option<String>)> {
907 Self::read_status_file(repo_root, slug).map(|sf| (sf.state, sf.note))
908 }
909
910 /// The ONE lifecycle write path (design ticket-state-frontmatter, rule 2:
911 /// the `.status` sidecar is a write-through cache of the committed
912 /// frontmatter `state:` — every lifecycle change writes BOTH, so no
913 /// reader of either file can observe them apart, and a fresh clone loses
914 /// only the cache, never the verdict).
915 ///
916 /// Upserts the `state:` (and `state-note:`, replacing or removing it)
917 /// lines inside the ticket .md's frontmatter block — every other byte
918 /// preserved — then mirrors the terminal pipeline projection into the
919 /// sidecar via [`Self::write_state`].
920 ///
921 /// Terminal states only: `Open` is the ABSENCE of a terminal claim, so
922 /// there is nothing to cache — un-close a ticket by removing the `state:`
923 /// key and resetting the pipeline by hand. Pipeline transitions
924 /// (Drafting/Review/Queued/…) keep using [`Self::write_state`], which
925 /// never touches the committed .md.
926 pub fn write_lifecycle(
927 repo_root: &Path,
928 slug: &str,
929 state: TicketLifecycle,
930 note: Option<String>,
931 ) -> Result<()> {
932 Self::ensure_valid_slug(slug)?;
933 let terminal = state.terminal_pipeline_state().ok_or_else(|| {
934 EngineError::Config(format!(
935 "write_lifecycle takes a terminal state (done, superseded, wontfix); \
936 'open' is the absence of a `state:` key (ticket {slug})"
937 ))
938 })?;
939 let note = note.map(|n| bound_state_note(&n)).filter(|n| !n.is_empty());
940 let md = Self::md_path(repo_root, slug);
941 let text = std::fs::read_to_string(&md)?;
942 let updated = upsert_frontmatter_state(slug, &text, state, note.as_deref())?;
943 atomic_write(&md, updated.as_bytes())?;
944 Self::write_state(repo_root, slug, terminal, note)?;
945 Ok(())
946 }
947
948 /// Durably link the ticket to the mission `kranz draft` created for it.
949 /// `kranz ticket approve <slug>` resolves through this link — goal-text
950 /// matching breaks the moment `plan.approved` rewrites the mission goal
951 /// to the orchestrator's refined phrasing (observed live on the first
952 /// drafted batch).
953 pub fn record_mission(repo_root: &Path, slug: &str, mission_id: &str) -> Result<()> {
954 Self::ensure_valid_slug(slug)?;
955 let dir = Self::tickets_dir(repo_root);
956 std::fs::create_dir_all(&dir)?;
957 let (state, note) = match Self::read_status_file(repo_root, slug) {
958 Some(sf) => (sf.state, sf.note),
959 None => (TicketState::Drafting, None),
960 };
961 let sf = StatusFile {
962 state,
963 note,
964 mission_id: Some(mission_id.to_string()),
965 };
966 let json = serde_json::to_string_pretty(&sf)?;
967 atomic_write(&Self::status_path(repo_root, slug), json.as_bytes())?;
968 Ok(())
969 }
970
971 /// The mission recorded by [`Self::record_mission`], if any.
972 pub fn mission_for(repo_root: &Path, slug: &str) -> Option<String> {
973 Self::read_status_file(repo_root, slug).and_then(|sf| sf.mission_id)
974 }
975
976 /// Reverse of [`Self::mission_for`]: find the ticket whose `.status`
977 /// records this mission id. Used when a mission-id approve/queue path
978 /// (e.g. Slack `/kranz approve m-…`) must still advance the linked
979 /// ticket's pipeline state. Tickets without a mission link are skipped.
980 ///
981 /// Slugs are scanned in sorted order so the result is deterministic (the
982 /// event-sourced engine must not depend on `read_dir` order) if two
983 /// tickets ever record the same mission id — an unexpected state, so a
984 /// duplicate link is also logged.
985 pub fn slug_for_mission(repo_root: &Path, mission_id: &str) -> Option<String> {
986 if mission_id.is_empty() {
987 return None;
988 }
989 let dir = Self::tickets_dir(repo_root);
990 let rd = std::fs::read_dir(&dir).ok()?;
991 let mut slugs: Vec<String> = rd
992 .flatten()
993 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("status"))
994 .filter_map(|e| {
995 e.path()
996 .file_stem()
997 .and_then(|s| s.to_str())
998 .filter(|s| Self::valid_slug(s))
999 .map(str::to_string)
1000 })
1001 .collect();
1002 slugs.sort();
1003 let mut found: Option<String> = None;
1004 for slug in slugs {
1005 if Self::mission_for(repo_root, &slug).as_deref() == Some(mission_id) {
1006 match &found {
1007 None => found = Some(slug),
1008 Some(first) => {
1009 tracing::warn!(
1010 mission_id,
1011 resolved = %first,
1012 duplicate = %slug,
1013 "multiple tickets link one mission; using the first by sorted slug"
1014 );
1015 }
1016 }
1017 }
1018 }
1019 found
1020 }
1021
1022 /// Append the orchestrator's verbatim clarifying questions to the ticket
1023 /// `.md` under a `## Needs context (from orchestrator)` heading, and set
1024 /// the state to [`TicketState::NeedsContext`].
1025 pub fn append_needs_context(repo_root: &Path, slug: &str, questions: &[String]) -> Result<()> {
1026 Self::ensure_valid_slug(slug)?;
1027 let md = Self::md_path(repo_root, slug);
1028 let mut text = std::fs::read_to_string(&md)?;
1029 if !text.ends_with('\n') {
1030 text.push('\n');
1031 }
1032 text.push_str("\n## Needs context (from orchestrator)\n");
1033 for q in bound_questions(questions) {
1034 text.push_str("- ");
1035 text.push_str(&crate::scrub::scrub(&q));
1036 text.push('\n');
1037 }
1038 atomic_write(&md, text.as_bytes())?;
1039 Self::write_state(repo_root, slug, TicketState::NeedsContext, None)?;
1040 Ok(())
1041 }
1042
1043 /// Append the planner's wrong-plan escalation reason to the ticket `.md`
1044 /// under a `## Wrong plan (from orchestrator)` heading, and set the state
1045 /// to [`TicketState::WrongPlan`] with the `.status` note carrying the
1046 /// reason prefixed `WRONG-PLAN: `. Mirrors [`Self::append_needs_context`]'s
1047 /// shape (bounded, scrubbed, atomic).
1048 pub fn append_wrong_plan(repo_root: &Path, slug: &str, reason: &str) -> Result<()> {
1049 Self::ensure_valid_slug(slug)?;
1050 let reason = bound_reason(reason);
1051 let md = Self::md_path(repo_root, slug);
1052 let mut text = std::fs::read_to_string(&md)?;
1053 if !text.ends_with('\n') {
1054 text.push('\n');
1055 }
1056 text.push_str("\n## Wrong plan (from orchestrator)\n");
1057 text.push_str(&crate::scrub::scrub(&reason));
1058 text.push('\n');
1059 atomic_write(&md, text.as_bytes())?;
1060 Self::write_state(
1061 repo_root,
1062 slug,
1063 TicketState::WrongPlan,
1064 Some(format!("WRONG-PLAN: {reason}")),
1065 )?;
1066 Ok(())
1067 }
1068
1069 /// Seed or update the ticket's `traced-from-mission` frontmatter link
1070 /// (the flight-surgeon console's defect→mission join). `kranz draft
1071 /// --from-mission` calls this; hand-edited tickets need nothing here —
1072 /// their field parses through [`Ticket::parse`] like any other. Only the
1073 /// frontmatter block is rewritten (an existing link line in place, or a
1074 /// new line right after the opening fence; a frontmatter-less ticket
1075 /// gains a two-line block above its body) — body bytes are preserved.
1076 /// Returns `Ok(true)` when the file changed, `Ok(false)` when the link
1077 /// already named this mission.
1078 pub fn seed_traced_from_mission(
1079 repo_root: &Path,
1080 slug: &str,
1081 mission_id: &str,
1082 ) -> Result<bool> {
1083 Self::ensure_valid_slug(slug)?;
1084 if !crate::paths::MissionPaths::is_safe_id(mission_id) {
1085 return Err(EngineError::Config(format!(
1086 "invalid mission id '{mission_id}' for traced-from-mission"
1087 )));
1088 }
1089 let md = Self::md_path(repo_root, slug);
1090 let text = std::fs::read_to_string(&md)?;
1091 let new_line = format!("traced-from-mission: {mission_id}");
1092
1093 let (bom, source) = match text.strip_prefix('\u{feff}') {
1094 Some(rest) => ("\u{feff}", rest),
1095 None => ("", text.as_str()),
1096 };
1097 let lines: Vec<&str> = source.split_inclusive('\n').collect();
1098 let has_frontmatter = lines
1099 .first()
1100 .map(|line| line.trim_end() == "---")
1101 .unwrap_or(false);
1102
1103 let mut out = String::with_capacity(text.len() + new_line.len() + 8);
1104 out.push_str(bom);
1105
1106 if !has_frontmatter {
1107 out.push_str("---\n");
1108 out.push_str(&new_line);
1109 out.push('\n');
1110 out.push_str("---\n\n");
1111 out.push_str(source);
1112 atomic_write(&md, out.as_bytes())?;
1113 return Ok(true);
1114 }
1115
1116 // Scan the frontmatter block for its closing fence and an existing
1117 // link line (key spelling-tolerant, like the parser).
1118 let mut closing: Option<usize> = None;
1119 let mut existing: Option<(usize, String)> = None;
1120 for (i, line) in lines.iter().enumerate().skip(1) {
1121 if line.trim_end() == "---" {
1122 closing = Some(i);
1123 break;
1124 }
1125 if existing.is_none() && !line.trim_start().starts_with('#') {
1126 if let Some((key, value)) = line.split_once(':') {
1127 let key = normalize_key(key);
1128 if key == "traced-from-mission" || key == "tracedfrommission" {
1129 existing = Some((i, unquote(value.trim())));
1130 }
1131 }
1132 }
1133 }
1134 if closing.is_none() {
1135 return Err(EngineError::Config(format!(
1136 "ticket {slug}: frontmatter opened with `---` but was never closed"
1137 )));
1138 }
1139 if let Some((_, value)) = &existing {
1140 if value == mission_id {
1141 return Ok(false);
1142 }
1143 }
1144 let replace_idx = existing.as_ref().map(|(i, _)| *i);
1145 for (i, line) in lines.iter().enumerate() {
1146 // No existing link: insert one right after the opening fence.
1147 if i == 1 && replace_idx.is_none() {
1148 out.push_str(&new_line);
1149 out.push('\n');
1150 }
1151 if replace_idx == Some(i) {
1152 out.push_str(&new_line);
1153 out.push('\n');
1154 } else {
1155 out.push_str(line);
1156 }
1157 }
1158 atomic_write(&md, out.as_bytes())?;
1159 Ok(true)
1160 }
1161}
1162
1163/// Per-question length cap (in chars) and total-count cap applied before
1164/// writing clarifying questions into a ticket's needs-context section, so a
1165/// multi-KB orchestrator reply cannot blow up the ticket file.
1166const MAX_QUESTION_CHARS: usize = 500;
1167const MAX_QUESTION_COUNT: usize = 20;
1168
1169/// Truncate one question (or the wrong-plan reason) to [`MAX_QUESTION_CHARS`]
1170/// characters, char-boundary safe.
1171fn truncate_one(q: &str) -> String {
1172 if q.chars().count() > MAX_QUESTION_CHARS {
1173 let mut truncated: String = q.chars().take(MAX_QUESTION_CHARS).collect();
1174 truncated.push_str(" … (truncated)");
1175 truncated
1176 } else {
1177 q.to_string()
1178 }
1179}
1180
1181/// Bound the wrong-plan reason before it lands in the ticket body and the
1182/// `.status` note: trimmed, single-paragraph, length-capped like a question.
1183fn bound_reason(reason: &str) -> String {
1184 truncate_one(reason.trim())
1185}
1186
1187/// Bound a `state-note` for ONE frontmatter line: whitespace-flattened (a raw
1188/// newline would split the frontmatter record across lines), trimmed, and
1189/// length-capped like a needs-context question. Written unquoted — the
1190/// frontmatter parser reads a scalar back verbatim (same as `title:`).
1191fn bound_state_note(note: &str) -> String {
1192 truncate_one(¬e.split_whitespace().collect::<Vec<_>>().join(" "))
1193}
1194
1195/// Truncate each question to [`MAX_QUESTION_CHARS`] characters (char-boundary
1196/// safe) and cap the total number of questions to [`MAX_QUESTION_COUNT`],
1197/// appending a single "N more omitted" marker when truncated.
1198fn bound_questions(questions: &[String]) -> Vec<String> {
1199 if questions.len() <= MAX_QUESTION_COUNT {
1200 return questions.iter().map(|q| truncate_one(q)).collect();
1201 }
1202
1203 let mut out: Vec<String> = questions[..MAX_QUESTION_COUNT]
1204 .iter()
1205 .map(|q| truncate_one(q))
1206 .collect();
1207 let omitted = questions.len() - MAX_QUESTION_COUNT;
1208 out.push(format!("… ({omitted} more omitted)"));
1209 out
1210}
1211
1212/// Atomic write via a sibling temp file + rename (POSIX rename is atomic; on
1213/// Windows the target is removed first when present).
1214fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
1215 let dir = path.parent().unwrap_or_else(|| Path::new("."));
1216 std::fs::create_dir_all(dir)?;
1217 let file_name = path
1218 .file_name()
1219 .and_then(|n| n.to_str())
1220 .unwrap_or("ticket");
1221 let tmp = dir.join(format!(".{file_name}.{}.tmp", std::process::id()));
1222 std::fs::write(&tmp, bytes)?;
1223 match std::fs::rename(&tmp, path) {
1224 Ok(()) => Ok(()),
1225 Err(_) if cfg!(windows) => {
1226 // Windows rename fails when the destination exists.
1227 let _ = std::fs::remove_file(path);
1228 std::fs::rename(&tmp, path)?;
1229 Ok(())
1230 }
1231 Err(e) => {
1232 let _ = std::fs::remove_file(&tmp);
1233 Err(e.into())
1234 }
1235 }
1236}
1237
1238// ---------------------------------------------------------------------------
1239// Frontmatter
1240// ---------------------------------------------------------------------------
1241
1242/// A frontmatter value: either a scalar string or a bracketed `[a, b]` list.
1243enum FrontValue {
1244 Scalar(String),
1245 List(Vec<String>),
1246}
1247
1248impl FrontValue {
1249 fn scalar(&self) -> String {
1250 match self {
1251 FrontValue::Scalar(s) => s.clone(),
1252 // A list where a scalar was expected: join for a best-effort string.
1253 FrontValue::List(items) => items.join(", "),
1254 }
1255 }
1256
1257 fn list(&self) -> Vec<String> {
1258 match self {
1259 FrontValue::List(items) => items.clone(),
1260 // A bare scalar where a list was expected becomes a one-item list.
1261 FrontValue::Scalar(s) if !s.is_empty() => vec![s.clone()],
1262 FrontValue::Scalar(_) => Vec::new(),
1263 }
1264 }
1265}
1266
1267/// Split a leading `---` frontmatter block from the body. Returns the parsed
1268/// `key: value` pairs and the remaining markdown body. Missing frontmatter is
1269/// allowed (empty pairs, whole input is the body). An opening `---` with no
1270/// closing fence is malformed.
1271fn split_frontmatter(slug: &str, markdown: &str) -> Result<(Vec<(String, FrontValue)>, String)> {
1272 // Strip a leading BOM only; the body we return is the untouched remainder
1273 // after the closing fence so its whitespace/newlines are preserved.
1274 let source = markdown.trim_start_matches('\u{feff}');
1275
1276 // The first line must be exactly `---` (trailing whitespace tolerated).
1277 let first_line_end = source.find('\n').map(|i| i + 1).unwrap_or(source.len());
1278 let first_line = source[..first_line_end].trim_end();
1279 if first_line != "---" {
1280 // No frontmatter: the whole (BOM-stripped) input is the body.
1281 return Ok((Vec::new(), source.to_string()));
1282 }
1283
1284 let mut pairs: Vec<(String, FrontValue)> = Vec::new();
1285
1286 // Walk the remaining lines tracking byte offsets so we can slice the exact
1287 // body once the closing fence is found.
1288 let mut offset = first_line_end;
1289 while offset < source.len() {
1290 let rest = &source[offset..];
1291 let line_len = rest.find('\n').map(|i| i + 1).unwrap_or(rest.len());
1292 let raw = &rest[..line_len];
1293
1294 if raw.trim_end() == "---" {
1295 // Body is everything after this closing fence line.
1296 let body = source[offset + line_len..].to_string();
1297 return Ok((pairs, body));
1298 }
1299
1300 let line = raw.trim();
1301 if !line.is_empty() && !line.starts_with('#') {
1302 if let Some((key, value)) = line.split_once(':') {
1303 let key = normalize_key(key);
1304 if !key.is_empty() {
1305 pairs.push((key, parse_front_value(value.trim())));
1306 }
1307 }
1308 // Lines without a colon inside frontmatter are ignored.
1309 }
1310
1311 offset += line_len;
1312 }
1313
1314 Err(EngineError::Config(format!(
1315 "ticket {slug}: frontmatter opened with `---` but was never closed"
1316 )))
1317}
1318
1319/// Lower-case a frontmatter key and strip whitespace; hyphens/underscores are
1320/// preserved in the lower-cased form so the match arms can normalize spellings.
1321fn normalize_key(key: &str) -> String {
1322 key.trim().to_ascii_lowercase().replace('_', "")
1323}
1324
1325/// Parse a scalar or a bracketed `[a, b, c]` list from a raw value string.
1326fn parse_front_value(raw: &str) -> FrontValue {
1327 let raw = raw.trim();
1328 if let Some(inner) = raw.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
1329 let items = inner
1330 .split(',')
1331 .map(|item| unquote(item.trim()))
1332 .filter(|item| !item.is_empty())
1333 .collect();
1334 FrontValue::List(items)
1335 } else {
1336 FrontValue::Scalar(unquote(raw))
1337 }
1338}
1339
1340/// Parse a `defer-until` frontmatter value as RFC 3339. A malformed value is
1341/// a hard [`EngineError::Config`] naming the ticket (a silently-dropped
1342/// deferral would queue parked work), unlike the warn-and-default scalars.
1343fn parse_defer_until(slug: &str, raw: &str) -> Result<chrono::DateTime<chrono::Utc>> {
1344 chrono::DateTime::parse_from_rfc3339(raw)
1345 .map(|ts| ts.with_timezone(&chrono::Utc))
1346 .map_err(|e| {
1347 EngineError::Config(format!(
1348 "ticket {slug}: invalid defer-until '{raw}' (expected an RFC 3339 \
1349 timestamp, e.g. 2026-08-01T09:00:00Z): {e}"
1350 ))
1351 })
1352}
1353
1354/// Strip a single pair of matching surrounding quotes, if present.
1355fn unquote(s: &str) -> String {
1356 let bytes = s.as_bytes();
1357 if bytes.len() >= 2 {
1358 let (first, last) = (bytes[0], bytes[bytes.len() - 1]);
1359 if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1360 return s[1..s.len() - 1].to_string();
1361 }
1362 }
1363 s.to_string()
1364}
1365
1366/// Upsert the `state:` / `state-note:` lines inside a ticket's frontmatter
1367/// block, preserving every other byte (BOM, key order, body). An existing
1368/// line is replaced in place; a missing one is inserted right after the
1369/// opening fence (`state:` first, then `state-note:`); a `None` note REMOVES
1370/// the `state-note:` line so a stale note can never describe a state it no
1371/// longer belongs to. A frontmatter-less ticket gains a fresh block above its
1372/// body. An unclosed frontmatter block is a hard error (same as the parser).
1373/// Same line-level idiom as [`Ticket::seed_traced_from_mission`].
1374fn upsert_frontmatter_state(
1375 slug: &str,
1376 text: &str,
1377 state: TicketLifecycle,
1378 note: Option<&str>,
1379) -> Result<String> {
1380 let state_line = format!("state: {}", state.as_str());
1381 let note_line = note.map(|n| format!("state-note: {n}"));
1382
1383 let (bom, source) = match text.strip_prefix('\u{feff}') {
1384 Some(rest) => ("\u{feff}", rest),
1385 None => ("", text),
1386 };
1387 let lines: Vec<&str> = source.split_inclusive('\n').collect();
1388 let has_frontmatter = lines
1389 .first()
1390 .map(|line| line.trim_end() == "---")
1391 .unwrap_or(false);
1392
1393 let mut out = String::with_capacity(
1394 text.len() + state_line.len() + note_line.as_deref().map_or(0, str::len) + 8,
1395 );
1396 out.push_str(bom);
1397
1398 if !has_frontmatter {
1399 out.push_str("---\n");
1400 out.push_str(&state_line);
1401 out.push('\n');
1402 if let Some(line) = ¬e_line {
1403 out.push_str(line);
1404 out.push('\n');
1405 }
1406 out.push_str("---\n\n");
1407 out.push_str(source);
1408 return Ok(out);
1409 }
1410
1411 // Scan the frontmatter block for its closing fence and any existing
1412 // state lines (key spelling-tolerant, like the parser).
1413 let mut closing: Option<usize> = None;
1414 let mut state_idx: Option<usize> = None;
1415 let mut note_idx: Option<usize> = None;
1416 for (i, line) in lines.iter().enumerate().skip(1) {
1417 if line.trim_end() == "---" {
1418 closing = Some(i);
1419 break;
1420 }
1421 if !line.trim_start().starts_with('#') {
1422 if let Some((key, _)) = line.split_once(':') {
1423 match normalize_key(key).as_str() {
1424 "state" if state_idx.is_none() => state_idx = Some(i),
1425 "state-note" | "statenote" if note_idx.is_none() => note_idx = Some(i),
1426 _ => {}
1427 }
1428 }
1429 }
1430 }
1431 if closing.is_none() {
1432 return Err(EngineError::Config(format!(
1433 "ticket {slug}: frontmatter opened with `---` but was never closed"
1434 )));
1435 }
1436
1437 for (i, line) in lines.iter().enumerate() {
1438 // Missing keys insert right after the opening fence, state first.
1439 if i == 1 {
1440 if state_idx.is_none() {
1441 out.push_str(&state_line);
1442 out.push('\n');
1443 }
1444 if note_idx.is_none() {
1445 if let Some(line) = ¬e_line {
1446 out.push_str(line);
1447 out.push('\n');
1448 }
1449 }
1450 }
1451 if state_idx == Some(i) {
1452 out.push_str(&state_line);
1453 out.push('\n');
1454 continue;
1455 }
1456 if note_idx == Some(i) {
1457 // Replace in place, or drop the line entirely when no note
1458 // remains — a stale note must not outlive its state.
1459 if let Some(line) = ¬e_line {
1460 out.push_str(line);
1461 out.push('\n');
1462 }
1463 continue;
1464 }
1465 out.push_str(line);
1466 }
1467 Ok(out)
1468}
1469
1470// ---------------------------------------------------------------------------
1471// Body sections
1472// ---------------------------------------------------------------------------
1473
1474#[derive(Default)]
1475struct Sections {
1476 goal: Option<String>,
1477 context: Option<String>,
1478 scoping_answers: Vec<String>,
1479 acceptance_hints: Vec<String>,
1480 /// First `#`/`##`… heading seen anywhere in the body (title fallback).
1481 first_heading: Option<String>,
1482}
1483
1484/// Which known section a `## Heading` maps to (case-insensitive).
1485enum SectionKind {
1486 Goal,
1487 Context,
1488 ScopingAnswers,
1489 AcceptanceHints,
1490 Other,
1491}
1492
1493fn classify_heading(text: &str) -> SectionKind {
1494 match text.trim().to_ascii_lowercase().as_str() {
1495 "goal" => SectionKind::Goal,
1496 "context" => SectionKind::Context,
1497 "scoping answers" => SectionKind::ScopingAnswers,
1498 "acceptance hints" => SectionKind::AcceptanceHints,
1499 _ => SectionKind::Other,
1500 }
1501}
1502
1503/// Parse the body into known sections. Text before the first `##` section
1504/// (with no explicit `## Goal`) becomes the goal.
1505fn parse_sections(body: &str) -> Sections {
1506 let mut sections = Sections::default();
1507
1508 // Current accumulation target: None = preamble (implicit goal), Some(kind).
1509 let mut current: Option<SectionKind> = None;
1510 let mut preamble: Vec<&str> = Vec::new();
1511 let mut text_buf: Vec<&str> = Vec::new();
1512 let mut bullets: Vec<String> = Vec::new();
1513
1514 // Commit the buffer accumulated for `current` into `sections`.
1515 fn flush(
1516 current: &Option<SectionKind>,
1517 text_buf: &mut Vec<&str>,
1518 bullets: &mut Vec<String>,
1519 sections: &mut Sections,
1520 ) {
1521 match current {
1522 Some(SectionKind::Goal) => {
1523 let joined = text_buf.join("\n").trim().to_string();
1524 if !joined.is_empty() {
1525 sections.goal = Some(joined);
1526 }
1527 }
1528 Some(SectionKind::Context) => {
1529 let joined = text_buf.join("\n").trim().to_string();
1530 if !joined.is_empty() {
1531 sections.context = Some(joined);
1532 }
1533 }
1534 Some(SectionKind::ScopingAnswers) => {
1535 sections.scoping_answers.append(bullets);
1536 }
1537 Some(SectionKind::AcceptanceHints) => {
1538 sections.acceptance_hints.append(bullets);
1539 }
1540 Some(SectionKind::Other) | None => {}
1541 }
1542 text_buf.clear();
1543 bullets.clear();
1544 }
1545
1546 for raw in body.lines() {
1547 if let Some(heading) = heading_text(raw) {
1548 if sections.first_heading.is_none() {
1549 sections.first_heading = Some(heading.to_string());
1550 }
1551 }
1552
1553 // A `##`-level (or deeper) heading starts a body section and closes the
1554 // previous one. A single `#` document title is not a section: it is
1555 // dropped here (not folded into the preamble goal), having already
1556 // served as the title fallback above.
1557 if let Some(heading) = section_heading_text(raw) {
1558 flush(¤t, &mut text_buf, &mut bullets, &mut sections);
1559 current = Some(classify_heading(heading));
1560 continue;
1561 }
1562 if heading_text(raw).is_some() {
1563 // A `#` title line while still in the preamble: skip it.
1564 continue;
1565 }
1566
1567 match current {
1568 None => preamble.push(raw),
1569 Some(SectionKind::ScopingAnswers) | Some(SectionKind::AcceptanceHints) => {
1570 if let Some(item) = bullet_item(raw) {
1571 bullets.push(item);
1572 }
1573 }
1574 Some(_) => text_buf.push(raw),
1575 }
1576 }
1577 flush(¤t, &mut text_buf, &mut bullets, &mut sections);
1578
1579 // Preamble (text before the first `##`) becomes the goal only when no
1580 // explicit `## Goal` section supplied one.
1581 if sections.goal.is_none() {
1582 let joined = preamble.join("\n").trim().to_string();
1583 if !joined.is_empty() {
1584 sections.goal = Some(joined);
1585 }
1586 }
1587
1588 sections
1589}
1590
1591/// Text of any markdown heading line (`#`, `##`, …), else None.
1592fn heading_text(line: &str) -> Option<&str> {
1593 let t = line.trim_start();
1594 if t.starts_with('#') {
1595 Some(t.trim_start_matches('#').trim())
1596 } else {
1597 None
1598 }
1599}
1600
1601/// Text of a section-level heading (`##` or deeper), else None. A single `#`
1602/// (document title) does not start a body section.
1603fn section_heading_text(line: &str) -> Option<&str> {
1604 let t = line.trim_start();
1605 if t.starts_with("##") {
1606 Some(t.trim_start_matches('#').trim())
1607 } else {
1608 None
1609 }
1610}
1611
1612/// The content of a dash bullet (`- item`), trimmed, else None.
1613fn bullet_item(line: &str) -> Option<String> {
1614 let t = line.trim_start();
1615 for marker in ["- ", "* ", "+ "] {
1616 if let Some(rest) = t.strip_prefix(marker) {
1617 let item = rest.trim().to_string();
1618 if !item.is_empty() {
1619 return Some(item);
1620 }
1621 }
1622 }
1623 None
1624}