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