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 found_env: crate::format::no_found_env,
22 authoring_refusal,
23 judged,
24 plan: "design.md",
25};
26
27fn judged(rel: &str) -> bool {
29 matches!(rel, "proposal.md" | "tasks.md") || rel == "specs" || rel.starts_with("specs/")
30}
31
32fn 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
43fn 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
68fn 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
94pub 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
121pub 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
148pub static PLANNING: Planning = Planning {
150 prefix: "openspec-",
151 core: &[],
152 refused,
153 creates: &["propose"],
156 current_env: None,
157 toward_tasks,
158 revises: None,
159 checks: None,
160 explores: Some("explore"),
163 explore_paths: crate::format::writes_nothing,
164 explore_commands: &[
165 &["openspec", "list"],
166 &["openspec", "show"],
167 &["openspec", "status"],
168 &["openspec", "instructions"],
169 &["openspec", "schemas"],
170 &["openspec", "context"],
171 ],
172};
173
174fn toward_tasks(_has_plan: bool) -> &'static [&'static str] {
175 &["propose"]
176}
177
178fn refused(name: &str) -> Option<String> {
180 match name {
181 "openspec-apply-change" => {
182 Some("speccoder is the apply step, with evidence: use `speccoder run`".to_owned())
183 }
184 "openspec-sync-specs" | "openspec-archive-change" => Some(
185 "merging a change into the main specs is the person's: use `speccoder spec archive`"
186 .to_owned(),
187 ),
188 _ => None,
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn argv(s: &str) -> Vec<String> {
197 s.split_whitespace().map(str::to_owned).collect()
198 }
199
200 #[test]
201 fn openspec_commands_by_subcommand() {
202 for (line, implementing, planning) in [
203 ("openspec list --json", Some(true), Some(true)),
204 (
205 "openspec validate x --strict --json",
206 Some(true),
207 Some(true),
208 ),
209 (
210 "npx @fission-ai/openspec@1.14.3 show x",
211 Some(true),
212 Some(true),
213 ),
214 ("npx -y openspec status", Some(true), Some(true)),
215 ("openspec new change add-x", Some(false), Some(true)),
216 ("openspec archive x --yes", Some(false), Some(false)),
217 ("/usr/local/bin/openspec sync", Some(false), Some(false)),
218 ("openspec init", Some(false), Some(false)),
219 ("openspec frobnicate", None, None),
220 ("cargo test", None, None),
221 ] {
222 let a = argv(line);
223 assert_eq!(
224 command(&a, false).map(|r| r.is_ok()),
225 implementing,
226 "{line}"
227 );
228 assert_eq!(command(&a, true).map(|r| r.is_ok()), planning, "{line}");
229 }
230 let why = command(&argv("openspec archive x"), false)
231 .unwrap()
232 .unwrap_err();
233 assert!(why.contains("speccoder spec archive"), "{why}");
234 assert!(is_authoring_path("openspec/changes/add-x/proposal.md"));
235 assert!(!is_authoring_path(
236 "openspec/changes/archive/2026-x/tasks.md"
237 ));
238 assert!(!is_authoring_path("openspec/specs/greeting/spec.md"));
239 }
240}