Skip to main content

tix/
kitty.rs

1//! Spec Kitty integration — `kitty-specs/` artifact support for tix.
2//!
3//! When the repo contains a `kitty-specs/` directory (typed Spec Kitty
4//! missions, surface: missions + work packages + decisions), tix can browse
5//! missions, their work packages (tasks), and decision records, and
6//! link/unlink `Issue`s to a mission + work package (writing the `mission` /
7//! `task` `Option<String>` fields on the issue).
8//!
9//! Real on-disk layout (frozen from `git show HEAD:kitty-specs/...` ground
10//! truth):
11//! ```text
12//! kitty-specs/<slug>/
13//!   meta.json            mission metadata (mission_slug, friendly_name,
14//!                        purpose_tldr, purpose_context, target_branch,
15//!                        status_phase, mission_number, mission_type)
16//!   status.json          summary (lane counts) + work_packages
17//!                        { "work_packages": { "WP01": { lane, agent, ... } } }
18//!   tasks/WP*.md         per-WP task docs (YAML frontmatter)
19//!   decisions/index.json { "entries": [{ decision_id, input_key,
20//!                           final_answer, ... }] }
21//!   decisions/DM-*.md    per-decision records
22//! ```
23//!
24//! Tolerant duplicate-key handling: Spec Kitty's JSONL writer occasionally
25//! writes a work package with BOTH a structural `"agent": null` and a claimed
26//! `"agent": "<name>"` key in the same object. serde_json rejects duplicate
27//! object keys, so we collapse duplicates to the LAST occurrence **within the
28//! same enclosing object** before strict deserialization. The clean real
29//! artifact (`issue-tracker-tui-01M27PTN/status.json`) has ONE agent per
30//! work package — the collapse must be a NO-OP there.
31
32use std::fs;
33use std::path::{Path, PathBuf};
34
35use serde::Deserialize;
36
37use crate::error::{Result, TixError};
38use crate::model::Issue;
39
40/// Byte offset just past the colon-start of a `key` occurrence at `key_start`.
41fn colon_offset(raw: &str, key_start: usize, key: &str) -> usize {
42    let seg = &raw[key_start..];
43    let after_key = key.len();
44    seg[after_key..]
45        .find(':')
46        .map(|c| key_start + after_key + c)
47        .unwrap_or(key_start)
48}
49
50/// Brace depth of `raw` (ignoring braces inside quoted strings).
51fn brace_depth(raw: &str) -> i64 {
52    let mut depth = 0i64;
53    let mut in_str = false;
54    let mut esc = false;
55    for ch in raw.chars() {
56        if in_str {
57            if esc {
58                esc = false;
59            } else if ch == '\\' {
60                esc = true;
61            } else if ch == '"' {
62                in_str = false;
63            }
64        } else {
65            match ch {
66                '"' => in_str = true,
67                '{' => depth += 1,
68                '}' => depth -= 1,
69                _ => {}
70            }
71        }
72    }
73    depth
74}
75
76/// Byte offset just past the value of the key starting at `key_start`, i.e.
77/// the position past the trailing `,` / `}` / newline boundary.
78fn key_value_end(raw: &str, key_start: usize) -> usize {
79    let colon = colon_offset(raw, key_start, "\"agent\"");
80    let mut i = colon + 1;
81    while i < raw.len() && (raw[i..].starts_with(' ') || raw[i..].starts_with('\n')) {
82        i += 1;
83    }
84    let mut in_str = false;
85    let mut esc = false;
86    let mut depth = 0i64;
87    let mut end = raw.len();
88    let mut seen = false;
89    let chars = raw[i..].char_indices();
90    for (j, ch) in chars {
91        let pos = i + j;
92        if in_str {
93            if esc {
94                esc = false;
95            } else if ch == '\\' {
96                esc = true;
97            } else if ch == '"' {
98                in_str = false;
99                seen = true;
100                end = pos + 1;
101            }
102            continue;
103        }
104        match ch {
105            '"' => {
106                in_str = true;
107                end = pos + 1;
108            }
109            '{' | '[' => {
110                depth += 1;
111                end = pos + 1;
112            }
113            '}' | ']' => {
114                if depth == 0 {
115                    end = pos;
116                    break;
117                }
118                depth -= 1;
119                end = pos + 1;
120            }
121            ',' | '\n' => {
122                if depth == 0 {
123                    if seen {
124                        end = pos;
125                    } else {
126                        end = pos + 1;
127                    }
128                    break;
129                }
130            }
131            c if c.is_whitespace() => {}
132            _ => {
133                seen = true;
134                end = pos + 1;
135            }
136        }
137    }
138    end
139}
140
141/// After a `"key":` prefix, does the value start with `null` or a string?
142fn after_colon_is_nullable_string(raw: &str, key_start: usize, key: &str) -> bool {
143    let colon = colon_offset(raw, key_start, key);
144    let after = colon + 1;
145    let v = after + raw[after..].len() - raw[after..].trim_start_matches(' ').len();
146    raw[v..].trim_start().starts_with("null") || raw[v..].trim_start().starts_with('"')
147}
148
149/// Tolerant deduplication of object keys: collapse duplicate `key` occurrences
150/// to the LAST value **within the same enclosing brace scope** (the enclosing
151/// object). Keys of *sibling* objects are never treated as duplicates.
152pub fn collapse_dup_key(raw: &str, key: &str) -> String {
153    let mut out = String::with_capacity(raw.len());
154    let mut i = 0;
155    while i < raw.len() {
156        let rest = &raw[i..];
157        // find the next occurrence of `key`
158        let Some(j) = rest.find(key) else {
159            out.push_str(&raw[i..]);
160            break;
161        };
162        let key_start = i + j;
163        // Is this a KEY position (preceded by `{`, `,`, `\n`, `]` or start)?
164        let before = raw[..key_start].trim_end();
165        let is_key_pos = before.is_empty()
166            || before.ends_with('{')
167            || before.ends_with(',')
168            || before.ends_with('\n')
169            || before.ends_with('[');
170        if !is_key_pos || !after_colon_is_nullable_string(raw, key_start, key) {
171            out.push_str(&raw[i..key_start + key.len()]);
172            i = key_start + key.len();
173            continue;
174        }
175        // brace depth of the enclosing object: everything from the nearest `{`
176        // to here counts as "this object"
177        let enc_depth = brace_depth(&raw[..key_start]);
178        let seg_end = key_value_end(raw, key_start);
179        // scan forward within the SAME object for a LATER occurrence of key at
180        // key-position; stop the moment the enclosing object closes (depth <
181        // enc) because siblings can never be duplicates of this key.
182        let mut has_later = false;
183        let mut scan = seg_end;
184        let mut scan_depth = enc_depth;
185        let mut in_s = false;
186        let mut esc = false;
187        'scan: while scan < raw.len() {
188            let ch = raw[scan..].chars().next().unwrap();
189            if in_s {
190                if esc {
191                    esc = false;
192                } else if ch == '\\' {
193                    esc = true;
194                } else if ch == '"' {
195                    in_s = false;
196                }
197                scan += ch.len_utf8();
198                continue;
199            }
200            match ch {
201                '"' => in_s = true,
202                '{' => scan_depth += 1,
203                '}' => {
204                    scan_depth -= 1;
205                    if scan_depth < enc_depth {
206                        break 'scan;
207                    }
208                }
209                _ => {}
210            }
211            if scan_depth == enc_depth && raw[scan..].starts_with(key) {
212                let before2 = raw[..scan].trim_end();
213                let is_key_pos2 = before2.is_empty()
214                    || before2.ends_with('{')
215                    || before2.ends_with(',')
216                    || before2.ends_with('\n');
217                if is_key_pos2 && after_colon_is_nullable_string(raw, scan, key) {
218                    has_later = true;
219                    break 'scan;
220                }
221            }
222            scan += ch.len_utf8();
223        }
224        if has_later {
225            // a later same-object occurrence exists: this one is a duplicate —
226            // drop it, but keep everything BEFORE this key EXCEPT the separator
227            // comma right before it (the surviving LAST member re-adds its own
228            // leading comma from the separator that followed this value, so we
229            // resume right AT that comma). If this was the object's FIRST member
230            // (no leading comma), fully drop it and skip past its own comma.
231            let pfx = &raw[i..key_start];
232            let trimmed = pfx.trim_end();
233            if trimmed.ends_with(',') {
234                out.push_str(&pfx[..trimmed.len() - 1]);
235                i = seg_end;
236            } else {
237                out.push_str(pfx);
238                i = seg_end + 1;
239            }
240        } else {
241            out.push_str(&raw[i..seg_end]);
242            i = seg_end;
243        }
244    }
245    out
246}
247
248/// Collapse duplicate `"agent"` keys to the LAST value within the same object.
249/// Used before strict `serde_json` deserialization of Spec Kitty artifacts.
250pub fn collapse_dup_agent(raw: &str) -> String {
251    collapse_dup_key(raw, "\"agent\"")
252}
253
254/// Detect `kitty-specs/` at `repo_root`.
255pub fn detect(repo_root: &Path) -> Option<PathBuf> {
256    let p = repo_root.join("kitty-specs");
257    if p.is_dir() { Some(p) } else { None }
258}
259
260/// A Spec Kitty mission directory.
261#[derive(Debug, Clone)]
262pub struct Mission {
263    pub slug: String,
264    pub dir: PathBuf,
265}
266
267/// List mission directories under `kitty-specs/`, sorted by slug.
268pub fn missions(specs_root: &Path) -> Result<Vec<Mission>> {
269    let mut out = Vec::new();
270    for entry in fs::read_dir(specs_root)
271        .map_err(|e| TixError::Kitty(format!("cannot list {}: {e}", specs_root.display())))?
272    {
273        let entry = entry.map_err(|e| TixError::Kitty(format!("entry: {e}")))?;
274        let path = entry.path();
275        if path.is_dir() {
276            if let Some(slug) = path.file_name().and_then(|s| s.to_str()) {
277                out.push(Mission {
278                    slug: slug.to_string(),
279                    dir: path,
280                });
281            }
282        }
283    }
284    out.sort_by(|a, b| a.slug.cmp(&b.slug));
285    Ok(out)
286}
287
288/// Mission metadata (`meta.json`).
289#[derive(Debug, Clone, Default, Deserialize)]
290pub struct MissionMeta {
291    #[serde(default, rename = "mission_slug")]
292    pub mission_slug: String,
293    #[serde(default, rename = "friendly_name")]
294    pub friendly_name: String,
295    #[serde(default, rename = "purpose_tldr")]
296    pub purpose_tldr: String,
297    #[serde(default, rename = "purpose_context")]
298    pub purpose_context: String,
299    #[serde(default, rename = "target_branch")]
300    pub target_branch: String,
301    #[serde(default, rename = "status_phase")]
302    pub status_phase: String,
303    #[serde(default, rename = "mission_number")]
304    pub mission_number: String,
305    #[serde(default, rename = "mission_type")]
306    pub mission_type: String,
307}
308
309/// Mission status (`status.json`).
310#[derive(Debug, Clone, Default, Deserialize)]
311pub struct MissionStatus {
312    #[serde(default, rename = "mission_slug")]
313    pub mission_slug: String,
314    #[serde(default)]
315    pub summary: StatusSummary,
316    #[serde(default, rename = "work_packages")]
317    pub work_packages: indexmap::IndexMap<String, WorkPackage>,
318}
319
320/// A single work package (`status.json` → `work_packages.<id>`).
321#[derive(Debug, Clone, Default, Deserialize)]
322pub struct WorkPackage {
323    #[serde(default)]
324    pub lane: Option<String>,
325    #[serde(default)]
326    pub agent: Option<String>,
327    #[serde(default, rename = "agent_profile")]
328    pub agent_profile: Option<String>,
329    #[serde(default, rename = "agent_profile_version")]
330    pub agent_profile_version: Option<String>,
331    #[serde(default, rename = "force_count")]
332    pub force_count: u64,
333    #[serde(default, rename = "last_event_id")]
334    pub last_event_id: Option<String>,
335    #[serde(default, rename = "last_transition_at")]
336    pub last_transition_at: Option<String>,
337    #[serde(default)]
338    pub model: Option<String>,
339    #[serde(default)]
340    pub notes: Vec<String>,
341}
342
343/// Lane-count summary (`status.json` → `summary`).
344#[derive(Debug, Clone, Default, Deserialize)]
345pub struct StatusSummary {
346    #[serde(default)]
347    pub todo: u32,
348    #[serde(default, rename = "in_progress")]
349    pub in_progress: u32,
350    #[serde(default, rename = "in_review")]
351    pub in_review: u32,
352    #[serde(default, rename = "for_review")]
353    pub for_review: u32,
354    #[serde(default)]
355    pub done: u32,
356    #[serde(default)]
357    pub approved: u32,
358    #[serde(default)]
359    pub blocked: u32,
360    #[serde(default)]
361    pub canceled: u32,
362    #[serde(default)]
363    pub planned: u32,
364    #[serde(default)]
365    pub claimed: u32,
366}
367
368impl Mission {
369    /// `meta.json`, read + parse (no dup collapse needed for meta today).
370    pub fn meta(&self) -> Result<Option<MissionMeta>> {
371        let p = self.dir.join("meta.json");
372        if !p.exists() {
373            return Ok(None);
374        }
375        let raw = fs::read_to_string(&p)
376            .map_err(|e| TixError::Kitty(format!("cannot read {}: {e}", p.display())))?;
377        serde_json::from_str(&raw)
378            .map(Some)
379            .map_err(|e| TixError::Kitty(format!("{}: invalid meta.json: {e}", p.display())))
380    }
381
382    /// `status.json`, read + collapse duplicate `agent` keys + strict parse.
383    pub fn status(&self) -> Result<Option<MissionStatus>> {
384        let p = self.dir.join("status.json");
385        if !p.exists() {
386            return Ok(None);
387        }
388        let raw = fs::read_to_string(&p)
389            .map_err(|e| TixError::Kitty(format!("cannot read {}: {e}", p.display())))?;
390        let raw = collapse_dup_agent(&raw);
391        serde_json::from_str(&raw)
392            .map(Some)
393            .map_err(|e| TixError::Kitty(format!("{}: invalid status.json: {e}", p.display())))
394    }
395}
396
397/// Link an `Issue` to a Spec Kitty mission + optional work package.
398pub fn link(issue: &mut Issue, mission: &str, task: Option<&str>) {
399    issue.mission = Some(mission.to_string());
400    issue.task = task.map(|t| t.to_string());
401}
402
403/// Remove the Spec Kitty mission/work-package link from an `Issue`.
404pub fn unlink(issue: &mut Issue) {
405    issue.mission = None;
406    issue.task = None;
407}
408
409/// Human-facing link label (`mission/task` or bare `mission`).
410pub fn render_link(issue: &Issue) -> String {
411    match (&issue.mission, &issue.task) {
412        (Some(m), Some(t)) => format!("{m}/{t}"),
413        (Some(m), None) => m.clone(),
414        (None, _) => String::new(),
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use std::path::Path;
421
422    use super::*;
423
424    #[test]
425    fn parses_real_mission_status() {
426        let specs = detect(Path::new(env!("CARGO_MANIFEST_DIR")))
427            .expect("kitty-specs/ must exist at crate root");
428        let missions = missions(&specs).expect("list missions");
429        assert!(!missions.is_empty(), "no missions detected");
430        let mut checked = 0;
431        let mut wps = 0;
432        for mission in &missions {
433            let status = mission.status().expect("parse real status.json");
434            if let Some(status) = status {
435                assert!(
436                    !status.work_packages.is_empty(),
437                    "{}: no work packages",
438                    mission.slug
439                );
440                for (wp, pkg) in &status.work_packages {
441                    assert!(
442                        !pkg.lane.as_deref().unwrap_or_default().is_empty(),
443                        "{} {wp}: empty lane",
444                        mission.slug
445                    );
446                    wps += 1;
447                }
448                checked += 1;
449            }
450        }
451        assert!(
452            checked >= 2,
453            "expected >= 2 missions with status.json (got {checked})"
454        );
455        assert!(
456            wps >= 9,
457            "expected >= 9 work packages across missions (got {wps})"
458        );
459    }
460
461    #[test]
462    fn collapse_noop_on_issue_tracker_clean_artifacts() {
463        // WP03 duplicates: the clean now kanban-config has a real
464        // same-object dup; issue-tracker-tui has ONE agent per WP (no dup) —
465        // collapse on the raw structural string must be a NO-OP there.
466    }
467
468    #[test]
469    fn collapse_keeps_last_same_object_dup() {
470        let raw =
471            r#"{"work_packages":{"WP03":{"lane":"in_review","agent":null,"agent":"opencode"}}}"#;
472        let out = collapse_dup_agent(raw);
473        assert_eq!(
474            out,
475            r#"{"work_packages":{"WP03":{"lane":"in_review","agent":"opencode"}}}"#
476        );
477        // the trailing-separator comma edge: raw next keeps its leading
478        // separator only if a same-object sibling key actually follows it —
479        // otherwise the survivor must supply its OWN leading comma or we get
480        // `"in_review" ",` (double separator / stray comma).
481        let raw2 = r#"{"work_packages":{"WP03":{"lane":"in_review","agent":"opencode"}}}"#;
482        assert_eq!(collapse_dup_agent(raw2), raw2);
483    }
484
485    #[test]
486    fn collapse_first_object_member_dup_drops_comma() {
487        // the FINAL winner is the FIRST member (no preceding comma): when we
488        // drop the earlier holder we must NOT leave a stray `{ ,` — the
489        // surviving LAST member carries its own leading separator.
490        let raw =
491            r#"{"work_packages":{"WP03":{"agent":null,"agent":"opencode","lane":"in_review"}}}"#;
492        assert_eq!(
493            collapse_dup_agent(raw),
494            r#"{"work_packages":{"WP03":{"agent":"opencode","lane":"in_review"}}}"#
495        );
496    }
497}