speccoder_runtime/format/openspec/
texts.rs1use std::path::Path;
5
6use crate::format::{Assess, Messages, Planning, Rules, Wording};
7
8pub 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 env: &[
17 ("OPENSPEC_TELEMETRY", "0"),
18 ("DO_NOT_TRACK", "1"),
19 ("OPENSPEC_NO_UPDATE_CHECK", "1"),
20 ],
21 authoring_refusal,
22};
23
24fn 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
35fn 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
60fn 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
86pub 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
113pub 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
140pub static PLANNING: Planning = Planning {
142 prefix: "openspec-",
143 core: &[],
144 refused,
145 creates: &[],
146 current_env: None,
147 toward_tasks,
148 revises: None,
149};
150
151fn toward_tasks(_has_plan: bool) -> &'static [&'static str] {
152 &["propose"]
153}
154
155fn refused(name: &str) -> Option<String> {
157 match name {
158 "openspec-apply-change" => {
159 Some("speccoder is the apply step, with evidence: use `speccoder run`".to_owned())
160 }
161 "openspec-sync-specs" | "openspec-archive-change" => Some(
162 "merging a change into the main specs is the person's: use `speccoder spec archive`"
163 .to_owned(),
164 ),
165 _ => None,
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 fn argv(s: &str) -> Vec<String> {
174 s.split_whitespace().map(str::to_owned).collect()
175 }
176
177 #[test]
178 fn openspec_commands_by_subcommand() {
179 for (line, implementing, planning) in [
180 ("openspec list --json", Some(true), Some(true)),
181 (
182 "openspec validate x --strict --json",
183 Some(true),
184 Some(true),
185 ),
186 (
187 "npx @fission-ai/openspec@1.14.3 show x",
188 Some(true),
189 Some(true),
190 ),
191 ("npx -y openspec status", Some(true), Some(true)),
192 ("openspec new change add-x", Some(false), Some(true)),
193 ("openspec archive x --yes", Some(false), Some(false)),
194 ("/usr/local/bin/openspec sync", Some(false), Some(false)),
195 ("openspec init", Some(false), Some(false)),
196 ("openspec frobnicate", None, None),
197 ("cargo test", None, None),
198 ] {
199 let a = argv(line);
200 assert_eq!(
201 command(&a, false).map(|r| r.is_ok()),
202 implementing,
203 "{line}"
204 );
205 assert_eq!(command(&a, true).map(|r| r.is_ok()), planning, "{line}");
206 }
207 let why = command(&argv("openspec archive x"), false)
208 .unwrap()
209 .unwrap_err();
210 assert!(why.contains("speccoder spec archive"), "{why}");
211 assert!(is_authoring_path("openspec/changes/add-x/proposal.md"));
212 assert!(!is_authoring_path(
213 "openspec/changes/archive/2026-x/tasks.md"
214 ));
215 assert!(!is_authoring_path("openspec/specs/greeting/spec.md"));
216 }
217}