Skip to main content

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(&note.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) = &note_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) = &note_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) = &note_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(&current, &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(&current, &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}