Skip to main content

speccoder_runtime/format/openspec/
texts.rs

1//! OpenSpec's paths, rules and words: intent is all of `openspec/`;
2//! planning writes active changes only; archiving is the person's.
3
4use std::path::Path;
5
6use crate::format::{Assess, Messages, Planning, Rules, Wording};
7
8/// OpenSpec's intent and planning rules.
9pub static RULES: Rules = Rules {
10    intent: &["openspec", "openspec/**"],
11    intent_reason: "it is OpenSpec intent (openspec/): the spec is not edited during implementation",
12    authoring_path: is_authoring_path,
13    command,
14    // OpenSpec's CLI asks no questions of a sandboxed child and phones home
15    // unless told not to.
16    env: &[
17        ("OPENSPEC_TELEMETRY", "0"),
18        ("DO_NOT_TRACK", "1"),
19        ("OPENSPEC_NO_UPDATE_CHECK", "1"),
20    ],
21    found_env: crate::format::no_found_env,
22    authoring_refusal,
23    judged,
24    plan: "design.md",
25};
26
27/// A change's judged files: its proposal, its task list and its spec deltas.
28fn judged(rel: &str) -> bool {
29    matches!(rel, "proposal.md" | "tasks.md") || rel == "specs" || rel.starts_with("specs/")
30}
31
32/// What a hosted planning skill writes: active changes, never the archive.
33fn is_authoring_path(p: &str) -> bool {
34    p.starts_with("openspec/changes/") && !p.starts_with("openspec/changes/archive/")
35}
36
37fn authoring_refusal(p: &str) -> String {
38    format!(
39        "a planning skill writes OpenSpec changes (openspec/changes/, not its archive); `{p}` is not one"
40    )
41}
42
43/// The OpenSpec subcommand of `argv`: `openspec …`, `npx openspec …` or
44/// `npx @fission-ai/openspec[@version] …`.
45fn subcommand(argv: &[String]) -> Option<&[String]> {
46    let program = argv.first()?;
47    let name = program.rsplit('/').next().unwrap_or(program);
48    if name == "openspec" {
49        return Some(&argv[1..]);
50    }
51    if name == "npx" {
52        let rest: Vec<&String> = argv[1..].iter().filter(|a| !a.starts_with('-')).collect();
53        let package = rest.first()?;
54        let bare = package
55            .strip_prefix("@fission-ai/")
56            .unwrap_or(package)
57            .split('@')
58            .next()
59            .unwrap_or_default();
60        if bare == "openspec" {
61            let at = argv.iter().position(|a| a == *package)?;
62            return Some(&argv[at + 1..]);
63        }
64    }
65    None
66}
67
68/// OpenSpec's CLI (contracts/policy.md): reading is free, creating a change
69/// is planning, everything that rewrites intent or the installation is
70/// refused — archiving above all, which is the person's.
71fn command(argv: &[String], authoring: bool) -> Option<Result<(), String>> {
72    let sub = subcommand(argv)?;
73    let first = sub.first().map_or("", String::as_str);
74    match first {
75        "" | "--version" | "-V" | "--help" | "-h" | "list" | "show" | "status"
76        | "instructions" | "validate" | "schemas" | "templates" | "context" | "view" => {
77            Some(Ok(()))
78        }
79        "new" if authoring => Some(Ok(())),
80        "new" => Some(Err(
81            "creating an OpenSpec change is planning, not implementation".to_owned(),
82        )),
83        "archive" => Some(Err(
84            "archiving rewrites the main specs and is the person's: they run `speccoder spec archive`"
85                .to_owned(),
86        )),
87        "sync" | "init" | "update" | "config" | "store" | "schema" => Some(Err(format!(
88            "`openspec {first}` changes OpenSpec's intent or installation; speccoder never runs it"
89        ))),
90        _ => None,
91    }
92}
93
94/// What the model is told about OpenSpec.
95pub static WORDING: Wording = Wording {
96    product: "OpenSpec",
97    name: "OpenSpec",
98    unit: "change",
99    intent_paths: "openspec/",
100    intent_code: "`openspec/`",
101    documents: "proposal, design, spec deltas and tasks.md",
102    authoring: "write OpenSpec changes (openspec/changes/, never its archive) and run OpenSpec's read commands and `openspec new change`",
103    authoring_tool: "an OpenSpec command",
104    story: "Spec deltas",
105    constitution: "Project context",
106    plan: "Change proposal and design",
107    plan_short: "Proposal and design",
108    examples: "the scenarios of the change's spec deltas that this task concerns (name the capability, requirement and scenario each test covers in a comment above it)",
109    convergence_task: "A task in a Convergence group",
110    files: ["spec deltas", "proposal and design", "tasks.md"],
111    assess: Assess {
112        rules: "the project context",
113        targets: "ADDED or MODIFIED requirement and each of its scenarios, design decision and project context constraint",
114        must: "a SHALL or MUST of the deltas",
115        sources: "proposal, design, deltas and tasks",
116        critical: "a SHALL or MUST not met, or missing/contradicts on a MODIFIED requirement",
117        refs: "greeting: Farewell / Farewell with name, design: argv parsing",
118    },
119};
120
121/// What a person is told about an OpenSpec project.
122pub static MESSAGES: Messages = Messages {
123    tasks_file: "tasks.md",
124    make_tasks: "create it with `speccoder spec propose`",
125    not_a_project,
126    no_project: "not an OpenSpec project (no openspec/ found)",
127    no_project_in_worktree: "the worktree has no OpenSpec project",
128    not_in_git: "the OpenSpec project is not inside a git repository",
129    no_skills: "no skills installed (OpenSpec installs them with `openspec init`, under .claude/skills/)",
130    checklists,
131    unit: "change",
132    no_current: "OpenSpec has no current change: name one with --change",
133    current_shared: "",
134    make_project: ("openspec init", "make it an OpenSpec project"),
135};
136
137fn not_a_project(path: &Path) -> String {
138    format!(
139        "no OpenSpec project (openspec/) at or above {}; run `openspec init` first",
140        path.display()
141    )
142}
143
144fn checklists(open: &str) -> String {
145    format!("unfinished checklists: {open}")
146}
147
148/// OpenSpec's planning skills.
149pub static PLANNING: Planning = Planning {
150    prefix: "openspec-",
151    core: &[],
152    refused,
153    // `propose` creates a change (and writes its proposal, deltas and
154    // tasks in one pass): the first step of a project without one.
155    creates: &["propose"],
156    current_env: None,
157    toward_tasks,
158    revises: None,
159    checks: None,
160};
161
162fn toward_tasks(_has_plan: bool) -> &'static [&'static str] {
163    &["propose"]
164}
165
166/// Skills speccoder does not host, and what to use instead.
167fn refused(name: &str) -> Option<String> {
168    match name {
169        "openspec-apply-change" => {
170            Some("speccoder is the apply step, with evidence: use `speccoder run`".to_owned())
171        }
172        "openspec-sync-specs" | "openspec-archive-change" => Some(
173            "merging a change into the main specs is the person's: use `speccoder spec archive`"
174                .to_owned(),
175        ),
176        _ => None,
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    fn argv(s: &str) -> Vec<String> {
185        s.split_whitespace().map(str::to_owned).collect()
186    }
187
188    #[test]
189    fn openspec_commands_by_subcommand() {
190        for (line, implementing, planning) in [
191            ("openspec list --json", Some(true), Some(true)),
192            (
193                "openspec validate x --strict --json",
194                Some(true),
195                Some(true),
196            ),
197            (
198                "npx @fission-ai/openspec@1.14.3 show x",
199                Some(true),
200                Some(true),
201            ),
202            ("npx -y openspec status", Some(true), Some(true)),
203            ("openspec new change add-x", Some(false), Some(true)),
204            ("openspec archive x --yes", Some(false), Some(false)),
205            ("/usr/local/bin/openspec sync", Some(false), Some(false)),
206            ("openspec init", Some(false), Some(false)),
207            ("openspec frobnicate", None, None),
208            ("cargo test", None, None),
209        ] {
210            let a = argv(line);
211            assert_eq!(
212                command(&a, false).map(|r| r.is_ok()),
213                implementing,
214                "{line}"
215            );
216            assert_eq!(command(&a, true).map(|r| r.is_ok()), planning, "{line}");
217        }
218        let why = command(&argv("openspec archive x"), false)
219            .unwrap()
220            .unwrap_err();
221        assert!(why.contains("speccoder spec archive"), "{why}");
222        assert!(is_authoring_path("openspec/changes/add-x/proposal.md"));
223        assert!(!is_authoring_path(
224            "openspec/changes/archive/2026-x/tasks.md"
225        ));
226        assert!(!is_authoring_path("openspec/specs/greeting/spec.md"));
227    }
228}