Skip to main content

kranz_cli/
openspec.rs

1//! Import an OpenSpec change folder as a kranz ticket.
2//!
3//! OpenSpec (github.com/Fission-AI/OpenSpec) authors each change as
4//! `openspec/changes/<name>/` holding `proposal.md` (rationale and scope),
5//! `specs/` (requirements as SHALL-style scenarios), `design.md` (technical
6//! approach), and `tasks.md` (an implementation checklist). Its README states
7//! that it deliberately avoids phase gates and does not enforce a workflow.
8//!
9//! That non-goal is kranz's goal, so the two compose: OpenSpec produces
10//! intent, kranz makes intent binding. This importer is the seam, and it runs
11//! ONE WAY. `openspec/changes/` explains why work exists; the approved plan is
12//! what the validator judges. Syncing back would leave two sources of truth to
13//! drift the moment someone edits a spec mid-mission.
14//!
15//! Two mappings carry the whole risk, and both are deliberate omissions.
16//!
17//! `tasks.md` is not imported. It is the assistant's own decomposition,
18//! self-reported and ungraded, and importing it would slip an unvalidated plan
19//! past the orchestrator — the one step that should be doing that thinking.
20//!
21//! Spec scenarios never become acceptance hints. `docs/tickets.md` requires
22//! hints to be concrete and testable with a passed-count guard, because a bare
23//! test-name filter exits 0 on zero matches. "The app SHALL default to the
24//! system preference" cannot fail, so copying scenarios into hints would ship
25//! a vacuous assertion with every imported mission. They arrive as intent
26//! instead, and the orchestrator still has to propose commands that can fail.
27
28use std::path::{Path, PathBuf};
29
30use anyhow::{bail, Context, Result};
31use cap_fs_ext::DirExt as _;
32use cap_std::{ambient_authority, fs::Dir};
33use kranz_engine::error::EngineError;
34use kranz_engine::ticket::Ticket;
35
36const MAX_IMPORT_BYTES: u64 = 8 * 1024 * 1024;
37const MAX_SPEC_ENTRIES: usize = 1024;
38const MAX_SPEC_DEPTH: usize = 32;
39
40/// The parts of an OpenSpec change that survive the crossing.
41#[derive(Debug, PartialEq, Eq)]
42pub struct OpenSpecChange {
43    /// Ticket slug, from the change directory's name unless overridden.
44    pub slug: String,
45    /// Ticket title, from the proposal's first heading, else the directory.
46    pub title: String,
47    /// `## Goal` body: the proposal, minus its own title heading.
48    pub goal: String,
49    /// `## Context` body: design notes, requirements as intent, provenance.
50    pub context: String,
51}
52
53/// What the generated `## Acceptance hints` says instead of scenarios.
54///
55/// An imported ticket must not look finished when its criteria are still
56/// prose. This states the gap in the artifact itself, where the orchestrator
57/// and a human reviewer both see it.
58pub const ACCEPTANCE_PLACEHOLDER: &str = "\
59The requirements imported from this change are INTENT, not criteria: a SHALL \
60sentence cannot fail, so none of them were copied here. Replace this with \
61commands that can fail, each guarded by a passed count \
62(`grep -qE 'result: ok\\. [1-9][0-9]* passed'`) so a zero-match filter cannot \
63report success.";
64
65/// Read an OpenSpec change directory.
66///
67/// `proposal.md` is required. A change folder without one is either not an
68/// OpenSpec change or is half-written, and guessing which would import an
69/// empty goal.
70pub fn read_change(dir: &Path, slug_override: Option<&str>) -> Result<OpenSpecChange> {
71    read_change_pinned(open_change_root(None, dir)?, dir, slug_override)
72}
73
74fn open_change_root(repo: Option<&Path>, dir: &Path) -> Result<Dir> {
75    if let Some(repo) = repo {
76        let absolute_dir = std::path::absolute(dir)?;
77        let canonical_repo = repo.canonicalize()?;
78        // Locate the trusted root by identity, not just spelling (/var and
79        // /private/var, or a checkout alias, may name the same repository).
80        // Canonicalization only locates that anchor; the untrusted suffix is
81        // always opened from the pinned repository, never its resolved path.
82        if let Some(anchor) = absolute_dir.ancestors().find(|ancestor| {
83            ancestor
84                .canonicalize()
85                .is_ok_and(|path| path == canonical_repo)
86        }) {
87            let relative = absolute_dir.strip_prefix(anchor)?;
88            let mut root = Dir::open_ambient_dir(&canonical_repo, ambient_authority())?;
89            for component in relative.components() {
90                let std::path::Component::Normal(name) = component else {
91                    bail!("import path must stay beneath its repository anchor");
92                };
93                root = root.open_dir_nofollow(name).with_context(|| {
94                    format!(
95                        "could not open change directory {} without following links",
96                        dir.display()
97                    )
98                })?;
99            }
100            return Ok(root);
101        }
102    }
103    // An explicitly selected external change has its own trusted parent.
104    // Repository-local imports instead pin every source ancestor above.
105    let parent = dir
106        .parent()
107        .filter(|path| !path.as_os_str().is_empty())
108        .unwrap_or(Path::new("."));
109    let name = dir
110        .file_name()
111        .context("change directory must have a name")?;
112    Dir::open_ambient_dir(parent, ambient_authority())?
113        .open_dir_nofollow(name)
114        .with_context(|| {
115            format!(
116                "could not open change directory {} without following links",
117                dir.display()
118            )
119        })
120}
121
122fn read_change_pinned(
123    root: Dir,
124    dir: &Path,
125    slug_override: Option<&str>,
126) -> Result<OpenSpecChange> {
127    let mut remaining = MAX_IMPORT_BYTES;
128    let proposal_path = dir.join("proposal.md");
129    let proposal =
130        read_text(&root, Path::new("proposal.md"), &mut remaining).with_context(|| {
131            format!(
132                "no OpenSpec proposal at {}; an OpenSpec change directory holds proposal.md \
133             (plus optional design.md, specs/, tasks.md)",
134                proposal_path.display()
135            )
136        })?;
137
138    let dir_name = dir
139        .file_name()
140        .map(|name| name.to_string_lossy().into_owned())
141        .unwrap_or_default();
142    if dir_name.is_empty() {
143        bail!("could not read a change name from {}", dir.display());
144    }
145    let slug = slug_override
146        .map(str::to_string)
147        .unwrap_or_else(|| slugify(&dir_name));
148
149    let (heading, body) = split_leading_heading(&proposal);
150    let title = heading.unwrap_or_else(|| dir_name.clone());
151
152    let mut context = String::new();
153    context.push_str(&format!(
154        "Imported from the OpenSpec change `{}`. The proposal and requirements below are \
155         the authored intent; this ticket is what kranz executes, and the approved plan is \
156         what the validator judges. `tasks.md` was deliberately not imported: it is the \
157         authoring assistant's own decomposition, and the orchestrator re-plans this work \
158         rather than inheriting an ungraded checklist.\n",
159        dir.display()
160    ));
161
162    if let Some(design) = read_optional(&root, Path::new("design.md"), &mut remaining)? {
163        context.push_str(&format!("\n### Design notes (design.md)\n\n{design}\n"));
164    }
165
166    for (path, spec) in read_specs(&root, &dir.join("specs"), &mut remaining)? {
167        context.push_str(&format!(
168            "\n### Requirements as intent ({})\n\n{spec}\n",
169            path.display()
170        ));
171    }
172
173    Ok(OpenSpecChange {
174        slug,
175        title,
176        goal: body.trim().to_string(),
177        context: context.trim_end().to_string(),
178    })
179}
180
181/// Import a change directory as `.kranz/tickets/<slug>.md`.
182///
183/// Reuses [`Ticket::create_markdown`], so slug validation and the refusal to
184/// overwrite an existing ticket stay in one place: an operator who has
185/// already edited an imported ticket does not lose that work to a re-import.
186pub fn import_change(repo: &Path, dir: &Path, slug_override: Option<&str>) -> Result<PathBuf> {
187    let change = read_change_pinned(open_change_root(Some(repo), dir)?, dir, slug_override)?;
188    let mut body =
189        Ticket::ticket_template(&change.title, Some(&change.goal), Some(&change.context));
190    body.push_str(&format!("\n{ACCEPTANCE_PLACEHOLDER}\n"));
191    Ok(Ticket::create_markdown(repo, &change.slug, &body)?)
192}
193
194fn read_text(dir: &Dir, name: &Path, remaining: &mut u64) -> kranz_engine::error::Result<String> {
195    let text = kranz_engine::paths::read_regular_file_under(dir, name, *remaining)?;
196    *remaining -= text.len() as u64;
197    Ok(text)
198}
199
200fn read_optional(dir: &Dir, name: &Path, remaining: &mut u64) -> Result<Option<String>> {
201    match read_text(dir, name, remaining) {
202        Ok(text) if text.trim().is_empty() => Ok(None),
203        Ok(text) => Ok(Some(text.trim().to_string())),
204        Err(EngineError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
205        Err(error) => Err(anyhow::Error::from(error))
206            .with_context(|| format!("could not read {}", name.display())),
207    }
208}
209
210/// Every `.md` under `specs/`, sorted, so an import is reproducible rather
211/// than ordered by whatever the filesystem returns.
212fn read_specs(
213    root: &Dir,
214    specs_path: &Path,
215    remaining: &mut u64,
216) -> Result<Vec<(PathBuf, String)>> {
217    let mut found = Vec::new();
218    let specs = match root.open_dir_nofollow("specs") {
219        Ok(dir) => dir,
220        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(found),
221        Err(error) => return Err(error).context("could not open specs without following links"),
222    };
223    let mut remaining_entries = MAX_SPEC_ENTRIES;
224    collect_markdown(
225        &specs,
226        specs_path,
227        &mut found,
228        remaining,
229        &mut remaining_entries,
230        0,
231    )?;
232    found.sort_by(|(left, _), (right, _)| left.cmp(right));
233    Ok(found)
234}
235
236fn collect_markdown(
237    dir: &Dir,
238    display_path: &Path,
239    out: &mut Vec<(PathBuf, String)>,
240    remaining_bytes: &mut u64,
241    remaining_entries: &mut usize,
242    depth: usize,
243) -> Result<()> {
244    if depth > MAX_SPEC_DEPTH {
245        bail!("OpenSpec specs exceed the directory depth limit ({MAX_SPEC_DEPTH})");
246    }
247    for entry in dir.entries()? {
248        let entry = entry?;
249        if *remaining_entries == 0 {
250            bail!("OpenSpec specs exceed the entry limit ({MAX_SPEC_ENTRIES})");
251        }
252        *remaining_entries -= 1;
253        let name = PathBuf::from(entry.file_name());
254        let path = display_path.join(&name);
255        let kind = entry.file_type()?;
256        if kind.is_symlink() {
257            bail!("refusing linked OpenSpec input {}", path.display());
258        }
259        if kind.is_dir() {
260            let child = dir.open_dir_nofollow(&name).with_context(|| {
261                format!("could not open {} without following links", path.display())
262            })?;
263            collect_markdown(
264                &child,
265                &path,
266                out,
267                remaining_bytes,
268                remaining_entries,
269                depth + 1,
270            )?;
271        } else if name.extension().is_some_and(|ext| ext == "md") {
272            // A removed or unreadable entry is an incomplete import, not an
273            // optional file: propagate the error instead of silently omitting it.
274            let text = read_text(dir, &name, remaining_bytes)?;
275            if !text.trim().is_empty() {
276                let text = text.trim().to_owned();
277                out.push((path, text));
278            }
279        }
280    }
281    Ok(())
282}
283
284/// Split a leading `# Heading` from the body it titles.
285fn split_leading_heading(markdown: &str) -> (Option<String>, String) {
286    let trimmed = markdown.trim_start();
287    let Some(rest) = trimmed.strip_prefix("# ") else {
288        return (None, markdown.to_string());
289    };
290    let (heading, body) = rest.split_once('\n').unwrap_or((rest, ""));
291    (Some(heading.trim().to_string()), body.to_string())
292}
293
294/// Ticket slugs allow ascii alphanumerics, `-`, `_`, and `.`; a change name
295/// can carry anything a directory can.
296fn slugify(name: &str) -> String {
297    let slug: String = name
298        .to_ascii_lowercase()
299        .chars()
300        .map(|c| {
301            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
302                c
303            } else {
304                '-'
305            }
306        })
307        .collect();
308    slug.trim_matches('-').to_string()
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[cfg(unix)]
316    #[test]
317    fn imports_refuse_links_at_every_untrusted_component() {
318        use std::os::unix::fs::symlink;
319        for component in [
320            "root",
321            "proposal.md",
322            "design.md",
323            "specs",
324            "specs/theme.md",
325            "specs/nested",
326        ] {
327            let repo = tempfile::tempdir().unwrap();
328            let dir = write_change(repo.path());
329            let outside = tempfile::tempdir().unwrap();
330            let sentinel = outside.path().join("sentinel.md");
331            std::fs::write(&sentinel, "synthetic outside content").unwrap();
332            let selected = if component == "root" {
333                let linked = repo.path().join("linked-change");
334                symlink(&dir, &linked).unwrap();
335                linked
336            } else {
337                let path = dir.join(component);
338                if path.is_dir() {
339                    std::fs::remove_dir_all(&path).unwrap();
340                } else if path.exists() {
341                    std::fs::remove_file(&path).unwrap();
342                }
343                let target = if component == "specs" || component == "specs/nested" {
344                    outside.path()
345                } else {
346                    &sentinel
347                };
348                symlink(target, path).unwrap();
349                dir
350            };
351            assert!(
352                import_change(repo.path(), &selected, Some("example")).is_err(),
353                "{component}"
354            );
355            assert!(
356                !repo.path().join(".kranz/tickets/example.md").exists(),
357                "{component}"
358            );
359            assert_eq!(
360                std::fs::read_to_string(&sentinel).unwrap(),
361                "synthetic outside content"
362            );
363        }
364    }
365
366    #[cfg(unix)]
367    #[test]
368    fn repository_source_ancestors_cannot_redirect_imports() {
369        let repo = tempfile::tempdir().unwrap();
370        let external = tempfile::tempdir().unwrap();
371        let change = write_change(external.path());
372        std::os::unix::fs::symlink(
373            external.path().join("openspec"),
374            repo.path().join("openspec"),
375        )
376        .unwrap();
377        let indirect = repo.path().join("openspec/changes/dark mode");
378        assert!(import_change(repo.path(), &indirect, Some("indirect")).is_err());
379        assert!(!repo.path().join(".kranz/tickets/indirect.md").exists());
380        // Explicitly naming an external change remains supported.
381        assert!(import_change(repo.path(), &change, Some("explicit")).is_ok());
382    }
383
384    #[cfg(unix)]
385    #[test]
386    fn checkout_alias_does_not_turn_repository_inputs_into_external_authority() {
387        let repo = tempfile::tempdir().unwrap();
388        let external = tempfile::tempdir().unwrap();
389        let aliases = tempfile::tempdir().unwrap();
390        write_change(external.path());
391        let alias = aliases.path().join("checkout");
392        std::os::unix::fs::symlink(repo.path(), &alias).unwrap();
393        write_change(repo.path());
394        let source = alias.join("openspec/changes/dark mode");
395        assert!(import_change(repo.path(), &source, Some("local")).is_ok());
396        std::fs::remove_dir_all(repo.path().join("openspec")).unwrap();
397        std::os::unix::fs::symlink(
398            external.path().join("openspec"),
399            repo.path().join("openspec"),
400        )
401        .unwrap();
402        assert!(import_change(repo.path(), &source, Some("indirect")).is_err());
403        assert!(!repo.path().join(".kranz/tickets/indirect.md").exists());
404    }
405
406    #[test]
407    fn imports_bound_aggregate_bytes_and_directory_depth() {
408        let repo = tempfile::tempdir().unwrap();
409        let dir = write_change(repo.path());
410        let file = std::fs::File::create(dir.join("design.md")).unwrap();
411        file.set_len(MAX_IMPORT_BYTES).unwrap();
412        assert!(
413            read_change(&dir, None).is_err(),
414            "proposal and design together exceed the budget"
415        );
416        std::fs::remove_file(dir.join("design.md")).unwrap();
417        let mut nested = dir.join("specs");
418        for _ in 0..=MAX_SPEC_DEPTH {
419            nested.push("nested");
420        }
421        std::fs::create_dir_all(nested).unwrap();
422        assert!(read_change(&dir, None)
423            .unwrap_err()
424            .to_string()
425            .contains("depth limit"));
426    }
427
428    #[test]
429    fn imports_bound_entry_count_even_for_empty_directories() {
430        let repo = tempfile::tempdir().unwrap();
431        let dir = write_change(repo.path());
432        for index in 0..MAX_SPEC_ENTRIES {
433            std::fs::create_dir(dir.join("specs").join(index.to_string())).unwrap();
434        }
435        assert!(read_change(&dir, None)
436            .unwrap_err()
437            .to_string()
438            .contains("entry limit"));
439    }
440
441    #[test]
442    fn nested_regular_specs_are_imported_in_path_order() {
443        let repo = tempfile::tempdir().unwrap();
444        let dir = write_change(repo.path());
445        std::fs::create_dir(dir.join("specs/aaa")).unwrap();
446        std::fs::write(dir.join("specs/aaa/first.md"), "First nested requirement").unwrap();
447        let change = read_change(&dir, None).unwrap();
448        assert!(
449            change.context.find("First nested requirement").unwrap()
450                < change.context.find("SHALL").unwrap()
451        );
452    }
453
454    fn write_change(root: &Path) -> PathBuf {
455        let dir = root.join("openspec").join("changes").join("dark mode");
456        std::fs::create_dir_all(dir.join("specs")).unwrap();
457        std::fs::write(
458            dir.join("proposal.md"),
459            "# Add a dark theme\n\nUsers on night shift ask for it weekly.\n",
460        )
461        .unwrap();
462        std::fs::write(
463            dir.join("design.md"),
464            "A CSS custom property per surface colour.\n",
465        )
466        .unwrap();
467        std::fs::write(
468            dir.join("specs").join("theme.md"),
469            "The app SHALL default to the system preference.\n",
470        )
471        .unwrap();
472        std::fs::write(
473            dir.join("tasks.md"),
474            "- [x] Add the toggle\n- [ ] Ship it\n",
475        )
476        .unwrap();
477        dir
478    }
479
480    #[test]
481    fn import_carries_proposal_and_specs_but_never_the_task_checklist() {
482        let repo = tempfile::tempdir().unwrap();
483        let dir = write_change(repo.path());
484
485        let path = import_change(repo.path(), &dir, None).unwrap();
486        let ticket = std::fs::read_to_string(&path).unwrap();
487
488        assert!(path.ends_with(".kranz/tickets/dark-mode.md"), "{path:?}");
489        assert!(ticket.contains("title: Add a dark theme"), "{ticket}");
490        assert!(ticket.contains("night shift"), "{ticket}");
491        assert!(ticket.contains("CSS custom property"), "{ticket}");
492        assert!(
493            ticket.contains("SHALL default to the system preference"),
494            "{ticket}"
495        );
496
497        // The checklist is the authoring assistant's own decomposition. If it
498        // arrived here the orchestrator would inherit an ungraded plan.
499        assert!(!ticket.contains("Add the toggle"), "{ticket}");
500        assert!(!ticket.contains("Ship it"), "{ticket}");
501    }
502
503    #[test]
504    fn acceptance_hints_refuse_to_pass_off_scenarios_as_criteria() {
505        let repo = tempfile::tempdir().unwrap();
506        let dir = write_change(repo.path());
507
508        let path = import_change(repo.path(), &dir, None).unwrap();
509        let ticket = std::fs::read_to_string(&path).unwrap();
510        let hints = ticket
511            .split_once("## Acceptance hints")
512            .expect("the template carries an acceptance section")
513            .1;
514
515        // The scenario is present as intent, but not as a criterion: a SHALL
516        // sentence cannot fail, and a hint that cannot fail is a vacuous pass.
517        // (The placeholder itself says the word "SHALL" while explaining why,
518        // so assert on the imported sentence rather than the word.)
519        assert!(
520            !hints.contains("default to the system preference"),
521            "{hints}"
522        );
523        assert!(hints.contains("cannot fail"), "{hints}");
524        assert!(hints.contains("[1-9][0-9]* passed"), "{hints}");
525    }
526
527    #[test]
528    fn a_directory_without_a_proposal_fails_closed_naming_the_path() {
529        let repo = tempfile::tempdir().unwrap();
530        let dir = repo.path().join("openspec").join("changes").join("empty");
531        std::fs::create_dir_all(&dir).unwrap();
532
533        let error = import_change(repo.path(), &dir, None)
534            .unwrap_err()
535            .to_string();
536        assert!(error.contains("proposal.md"), "{error}");
537        assert!(error.contains(&dir.display().to_string()), "{error}");
538    }
539
540    #[test]
541    fn re_importing_refuses_rather_than_overwriting_operator_edits() {
542        let repo = tempfile::tempdir().unwrap();
543        let dir = write_change(repo.path());
544        let path = import_change(repo.path(), &dir, None).unwrap();
545        std::fs::write(&path, "operator rewrote this ticket").unwrap();
546
547        let error = import_change(repo.path(), &dir, None)
548            .unwrap_err()
549            .to_string();
550        assert!(error.contains("already exists"), "{error}");
551        assert_eq!(
552            std::fs::read_to_string(&path).unwrap(),
553            "operator rewrote this ticket"
554        );
555    }
556}