@fission-ai/openspec 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +31 -2
  2. package/dist/cli/index.js +79 -8
  3. package/dist/commands/change.d.ts +0 -1
  4. package/dist/commands/change.js +105 -51
  5. package/dist/commands/config.js +14 -8
  6. package/dist/commands/doctor.js +9 -4
  7. package/dist/commands/feedback.js +74 -23
  8. package/dist/commands/schema.js +110 -31
  9. package/dist/commands/show.js +5 -2
  10. package/dist/commands/spec.js +49 -29
  11. package/dist/commands/validate.js +20 -5
  12. package/dist/commands/workflow/index.d.ts +1 -1
  13. package/dist/commands/workflow/index.js +1 -1
  14. package/dist/commands/workflow/instructions.d.ts +7 -1
  15. package/dist/commands/workflow/instructions.js +121 -33
  16. package/dist/commands/workflow/shared.d.ts +13 -2
  17. package/dist/commands/workflow/shared.js +34 -4
  18. package/dist/commands/workflow/status.js +9 -4
  19. package/dist/commands/workflow/templates.js +15 -5
  20. package/dist/core/archive.d.ts +14 -1
  21. package/dist/core/archive.js +1332 -155
  22. package/dist/core/artifact-graph/graph.d.ts +16 -0
  23. package/dist/core/artifact-graph/graph.js +30 -6
  24. package/dist/core/artifact-graph/index.d.ts +1 -1
  25. package/dist/core/artifact-graph/index.js +1 -1
  26. package/dist/core/artifact-graph/instruction-loader.d.ts +30 -3
  27. package/dist/core/artifact-graph/instruction-loader.js +70 -12
  28. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  29. package/dist/core/artifact-graph/outputs.js +67 -5
  30. package/dist/core/artifact-graph/resolver.d.ts +14 -0
  31. package/dist/core/artifact-graph/resolver.js +69 -15
  32. package/dist/core/artifact-graph/types.js +21 -3
  33. package/dist/core/available-tools.d.ts +2 -2
  34. package/dist/core/available-tools.js +14 -3
  35. package/dist/core/change-metadata/schema.d.ts +2 -0
  36. package/dist/core/change-metadata/schema.js +13 -0
  37. package/dist/core/change-status-policy.d.ts +1 -1
  38. package/dist/core/change-status-policy.js +2 -2
  39. package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
  40. package/dist/core/command-generation/adapters/amazon-q.js +7 -1
  41. package/dist/core/command-generation/adapters/antigravity.js +2 -1
  42. package/dist/core/command-generation/adapters/auggie.js +2 -1
  43. package/dist/core/command-generation/adapters/bob.d.ts +5 -1
  44. package/dist/core/command-generation/adapters/bob.js +6 -5
  45. package/dist/core/command-generation/adapters/claude.js +1 -8
  46. package/dist/core/command-generation/adapters/codebuddy.js +3 -2
  47. package/dist/core/command-generation/adapters/continue.js +3 -2
  48. package/dist/core/command-generation/adapters/costrict.js +2 -1
  49. package/dist/core/command-generation/adapters/crush.js +5 -5
  50. package/dist/core/command-generation/adapters/cursor.js +2 -2
  51. package/dist/core/command-generation/adapters/devin.d.ts +19 -0
  52. package/dist/core/command-generation/adapters/devin.js +36 -0
  53. package/dist/core/command-generation/adapters/factory.js +2 -1
  54. package/dist/core/command-generation/adapters/gemini.js +37 -2
  55. package/dist/core/command-generation/adapters/github-copilot.js +2 -1
  56. package/dist/core/command-generation/adapters/iflow.js +5 -4
  57. package/dist/core/command-generation/adapters/index.d.ts +2 -2
  58. package/dist/core/command-generation/adapters/index.js +2 -2
  59. package/dist/core/command-generation/adapters/junie.js +2 -1
  60. package/dist/core/command-generation/adapters/kiro.js +2 -1
  61. package/dist/core/command-generation/adapters/lingma.js +5 -5
  62. package/dist/core/command-generation/adapters/oh-my-pi.d.ts +2 -2
  63. package/dist/core/command-generation/adapters/oh-my-pi.js +3 -5
  64. package/dist/core/command-generation/adapters/opencode.js +3 -5
  65. package/dist/core/command-generation/adapters/pi.d.ts +2 -2
  66. package/dist/core/command-generation/adapters/pi.js +3 -6
  67. package/dist/core/command-generation/adapters/qoder.js +5 -5
  68. package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
  69. package/dist/core/command-generation/adapters/qwen.js +12 -7
  70. package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
  71. package/dist/core/command-generation/adapters/roocode.js +4 -4
  72. package/dist/core/command-generation/adapters/trae.js +1 -21
  73. package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
  74. package/dist/core/command-generation/adapters/zcode.js +33 -0
  75. package/dist/core/command-generation/generator.d.ts +8 -0
  76. package/dist/core/command-generation/generator.js +15 -1
  77. package/dist/core/command-generation/index.d.ts +1 -1
  78. package/dist/core/command-generation/index.js +1 -1
  79. package/dist/core/command-generation/invocation.d.ts +73 -0
  80. package/dist/core/command-generation/invocation.js +77 -0
  81. package/dist/core/command-generation/registry.js +4 -4
  82. package/dist/core/command-generation/types.d.ts +9 -2
  83. package/dist/core/command-generation/yaml.d.ts +20 -6
  84. package/dist/core/command-generation/yaml.js +31 -21
  85. package/dist/core/command-surface.d.ts +15 -0
  86. package/dist/core/command-surface.js +33 -0
  87. package/dist/core/completions/command-registry.js +16 -2
  88. package/dist/core/completions/generators/powershell-generator.js +15 -8
  89. package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
  90. package/dist/core/completions/installers/zsh-installer.js +20 -4
  91. package/dist/core/config-prompts.d.ts +1 -1
  92. package/dist/core/config-prompts.js +14 -1
  93. package/dist/core/config-schema.d.ts +10 -1
  94. package/dist/core/config-schema.js +67 -1
  95. package/dist/core/config.d.ts +15 -0
  96. package/dist/core/config.js +45 -5
  97. package/dist/core/file-state.js +56 -24
  98. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  99. package/dist/core/github-copilot/cloud-agent.js +497 -0
  100. package/dist/core/global-config.d.ts +16 -0
  101. package/dist/core/init.d.ts +58 -0
  102. package/dist/core/init.js +450 -69
  103. package/dist/core/legacy-cleanup.d.ts +59 -0
  104. package/dist/core/legacy-cleanup.js +253 -24
  105. package/dist/core/list.js +5 -7
  106. package/dist/core/migration.d.ts +73 -1
  107. package/dist/core/migration.js +360 -11
  108. package/dist/core/onboarding-commands.d.ts +30 -0
  109. package/dist/core/onboarding-commands.js +38 -0
  110. package/dist/core/parsers/change-parser.d.ts +17 -2
  111. package/dist/core/parsers/change-parser.js +33 -20
  112. package/dist/core/parsers/code-fence.d.ts +15 -0
  113. package/dist/core/parsers/code-fence.js +49 -0
  114. package/dist/core/parsers/markdown-parser.js +2 -1
  115. package/dist/core/parsers/requirement-blocks.d.ts +18 -0
  116. package/dist/core/parsers/requirement-blocks.js +120 -35
  117. package/dist/core/parsers/requirement-text.d.ts +1 -8
  118. package/dist/core/parsers/requirement-text.js +4 -42
  119. package/dist/core/parsers/spec-structure.d.ts +1 -1
  120. package/dist/core/parsers/spec-structure.js +20 -30
  121. package/dist/core/profile-sync-drift.d.ts +0 -8
  122. package/dist/core/profile-sync-drift.js +48 -49
  123. package/dist/core/project-config.d.ts +28 -6
  124. package/dist/core/project-config.js +108 -8
  125. package/dist/core/references.js +42 -4
  126. package/dist/core/relationship-health.d.ts +8 -0
  127. package/dist/core/relationship-health.js +13 -0
  128. package/dist/core/root-selection.d.ts +9 -6
  129. package/dist/core/root-selection.js +42 -5
  130. package/dist/core/shared/index.d.ts +1 -0
  131. package/dist/core/shared/index.js +1 -0
  132. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  133. package/dist/core/shared/skill-content-equivalence.js +41 -0
  134. package/dist/core/shared/skill-paths.d.ts +13 -0
  135. package/dist/core/shared/skill-paths.js +23 -0
  136. package/dist/core/shared/tool-detection.d.ts +25 -5
  137. package/dist/core/shared/tool-detection.js +166 -36
  138. package/dist/core/shared-skill-target.d.ts +16 -0
  139. package/dist/core/shared-skill-target.js +156 -0
  140. package/dist/core/specs-apply.d.ts +80 -33
  141. package/dist/core/specs-apply.js +659 -171
  142. package/dist/core/store/git.d.ts +13 -0
  143. package/dist/core/store/git.js +24 -0
  144. package/dist/core/templates/skill-templates.d.ts +1 -1
  145. package/dist/core/templates/skill-templates.js +1 -1
  146. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  147. package/dist/core/templates/workflows/apply-change.js +48 -158
  148. package/dist/core/templates/workflows/archive-change.js +170 -42
  149. package/dist/core/templates/workflows/bulk-archive-change.js +256 -75
  150. package/dist/core/templates/workflows/continue-change.js +38 -46
  151. package/dist/core/templates/workflows/explore.js +54 -18
  152. package/dist/core/templates/workflows/ff-change.js +46 -30
  153. package/dist/core/templates/workflows/new-change.js +2 -2
  154. package/dist/core/templates/workflows/onboard.js +13 -7
  155. package/dist/core/templates/workflows/propose.js +116 -46
  156. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  157. package/dist/core/templates/workflows/store-selection.js +1 -1
  158. package/dist/core/templates/workflows/sync-specs.js +260 -32
  159. package/dist/core/templates/workflows/update-change.js +26 -16
  160. package/dist/core/templates/workflows/verify-change.js +16 -10
  161. package/dist/core/update.d.ts +40 -5
  162. package/dist/core/update.js +389 -93
  163. package/dist/core/validation/constants.d.ts +4 -1
  164. package/dist/core/validation/constants.js +4 -1
  165. package/dist/core/validation/validator.d.ts +22 -8
  166. package/dist/core/validation/validator.js +222 -50
  167. package/dist/core/version-check.d.ts +152 -0
  168. package/dist/core/version-check.js +731 -0
  169. package/dist/core/view.js +12 -17
  170. package/dist/prompts/searchable-multi-select.js +1 -1
  171. package/dist/telemetry/config.d.ts +3 -4
  172. package/dist/telemetry/index.d.ts +10 -5
  173. package/dist/telemetry/index.js +75 -41
  174. package/dist/ui/welcome-screen.d.ts +9 -1
  175. package/dist/ui/welcome-screen.js +91 -38
  176. package/dist/utils/change-metadata.d.ts +43 -0
  177. package/dist/utils/change-metadata.js +123 -7
  178. package/dist/utils/change-utils.d.ts +10 -4
  179. package/dist/utils/change-utils.js +20 -13
  180. package/dist/utils/ci.d.ts +12 -0
  181. package/dist/utils/ci.js +16 -0
  182. package/dist/utils/command-references.d.ts +92 -6
  183. package/dist/utils/command-references.js +180 -7
  184. package/dist/utils/date.d.ts +8 -0
  185. package/dist/utils/date.js +13 -0
  186. package/dist/utils/file-system.d.ts +10 -0
  187. package/dist/utils/file-system.js +70 -0
  188. package/dist/utils/index.d.ts +1 -1
  189. package/dist/utils/index.js +1 -1
  190. package/dist/utils/interactive.d.ts +23 -0
  191. package/dist/utils/interactive.js +32 -0
  192. package/dist/utils/item-discovery.d.ts +19 -0
  193. package/dist/utils/item-discovery.js +30 -48
  194. package/dist/utils/shell-detection.d.ts +1 -1
  195. package/dist/utils/shell-detection.js +64 -11
  196. package/dist/utils/spec-discovery.d.ts +40 -0
  197. package/dist/utils/spec-discovery.js +115 -0
  198. package/dist/utils/task-progress.d.ts +17 -0
  199. package/dist/utils/task-progress.js +41 -13
  200. package/package.json +14 -12
  201. package/schemas/spec-driven/schema.yaml +67 -10
  202. package/schemas/spec-driven/templates/design.md +2 -2
  203. package/schemas/spec-driven/templates/proposal.md +10 -4
  204. package/schemas/spec-driven/templates/spec.md +3 -0
  205. package/dist/core/command-generation/adapters/codex.d.ts +0 -16
  206. package/dist/core/command-generation/adapters/codex.js +0 -39
  207. package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
  208. package/dist/core/command-generation/adapters/windsurf.js +0 -38
@@ -105,37 +105,88 @@ function displayFormattedFeedback(title, body) {
105
105
  console.log('--- END FEEDBACK ---\n');
106
106
  }
107
107
  /**
108
- * Submit feedback via gh CLI
108
+ * Check whether gh refused the issue because the repository does not define
109
+ * the label. gh resolves label names before creating the issue, so this
110
+ * failure means no issue was created.
111
+ *
112
+ * Only gh's stderr is inspected. The error message also embeds the command
113
+ * line, which carries the user's own feedback text.
114
+ */
115
+ function isMissingLabelError(error) {
116
+ return /could not add label/i.test(error?.stderr?.toString() ?? '');
117
+ }
118
+ /**
119
+ * Report a gh CLI failure and exit, preserving gh's exit code.
120
+ *
121
+ * gh failed after the user already typed their feedback (issues disabled,
122
+ * network, rate limit, ...), so show the same manual-submission path the
123
+ * missing-gh and unauthenticated flows get instead of discarding the text.
124
+ */
125
+ function reportGhFailure(error, title, body) {
126
+ // Display the error output from gh CLI
127
+ if (error.stderr) {
128
+ console.error(error.stderr.toString());
129
+ }
130
+ else if (error.message) {
131
+ console.error(error.message);
132
+ }
133
+ displayFormattedFeedback(title, body);
134
+ const manualUrl = generateManualSubmissionUrl(title, body);
135
+ console.log('Please submit your feedback manually:');
136
+ console.log(manualUrl);
137
+ // Exit with the same code as gh CLI
138
+ process.exit(error.status ?? 1);
139
+ }
140
+ /**
141
+ * Create the feedback issue via gh CLI
109
142
  * Uses execFileSync to prevent shell injection vulnerabilities
110
143
  */
144
+ function createIssue(title, body, labels) {
145
+ const args = [
146
+ 'issue',
147
+ 'create',
148
+ '--repo',
149
+ 'Fission-AI/OpenSpec',
150
+ '--title',
151
+ title,
152
+ '--body',
153
+ body,
154
+ ];
155
+ for (const label of labels) {
156
+ args.push('--label', label);
157
+ }
158
+ const result = execFileSync('gh', args, { encoding: 'utf-8', stdio: 'pipe' });
159
+ return result.trim();
160
+ }
161
+ /**
162
+ * Submit feedback via gh CLI
163
+ */
111
164
  function submitViaGhCli(title, body) {
165
+ let issueUrl;
166
+ let labelApplied = true;
112
167
  try {
113
- const result = execFileSync('gh', [
114
- 'issue',
115
- 'create',
116
- '--repo',
117
- 'Fission-AI/OpenSpec',
118
- '--title',
119
- title,
120
- '--body',
121
- body,
122
- '--label',
123
- 'feedback',
124
- ], { encoding: 'utf-8', stdio: 'pipe' });
125
- const issueUrl = result.trim();
126
- console.log(`\n✓ Feedback submitted successfully!`);
127
- console.log(`Issue URL: ${issueUrl}\n`);
168
+ issueUrl = createIssue(title, body, ['feedback']);
128
169
  }
129
170
  catch (error) {
130
- // Display the error output from gh CLI
131
- if (error.stderr) {
132
- console.error(error.stderr.toString());
171
+ if (!isMissingLabelError(error)) {
172
+ reportGhFailure(error, title, body);
173
+ return;
133
174
  }
134
- else if (error.message) {
135
- console.error(error.message);
175
+ // The repository does not define the 'feedback' label. Nothing was
176
+ // created, so retry unlabeled rather than dropping the feedback.
177
+ try {
178
+ issueUrl = createIssue(title, body, []);
179
+ labelApplied = false;
136
180
  }
137
- // Exit with the same code as gh CLI
138
- process.exit(error.status ?? 1);
181
+ catch (retryError) {
182
+ reportGhFailure(retryError, title, body);
183
+ return;
184
+ }
185
+ }
186
+ console.log(`\n✓ Feedback submitted successfully!`);
187
+ console.log(`Issue URL: ${issueUrl}\n`);
188
+ if (!labelApplied) {
189
+ console.log("Note: created without the 'feedback' label because the repository does not define it.\n");
139
190
  }
140
191
  }
141
192
  /**
@@ -2,8 +2,9 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import ora from 'ora';
4
4
  import { stringify as stringifyYaml } from 'yaml';
5
- import { getSchemaDir, getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, listSchemas, } from '../core/artifact-graph/resolver.js';
5
+ import { getSchemaDir, getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, isSchemaDir, listSchemas, } from '../core/artifact-graph/resolver.js';
6
6
  import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';
7
+ import { FileSystemUtils } from '../utils/file-system.js';
7
8
  /**
8
9
  * Check all three locations for a schema and return which ones exist.
9
10
  */
@@ -129,21 +130,30 @@ function validateSchema(schemaDir, verbose = false) {
129
130
  }
130
131
  return { valid: false, issues };
131
132
  }
132
- // Check template files exist
133
- // Templates can be in schemaDir directly or in a templates/ subdirectory
133
+ // Check template files exist in the same directory used at runtime.
134
134
  if (verbose) {
135
135
  console.log(' Checking template files...');
136
136
  }
137
137
  for (const artifact of schema.artifacts) {
138
- // Try templates subdirectory first (standard location), then root
139
- const templatePathInTemplates = path.join(schemaDir, 'templates', artifact.template);
140
- const templatePathInRoot = path.join(schemaDir, artifact.template);
141
- if (!fs.existsSync(templatePathInTemplates) && !fs.existsSync(templatePathInRoot)) {
138
+ const templatesDir = path.join(schemaDir, 'templates');
139
+ const existingTemplatePath = path.join(templatesDir, artifact.template);
140
+ if (!fs.existsSync(existingTemplatePath)) {
142
141
  issues.push({
143
142
  level: 'error',
144
143
  path: `artifacts.${artifact.id}.template`,
145
144
  message: `Template file '${artifact.template}' not found for artifact '${artifact.id}'`,
146
145
  });
146
+ continue;
147
+ }
148
+ try {
149
+ FileSystemUtils.assertPathWithin(templatesDir, existingTemplatePath);
150
+ }
151
+ catch {
152
+ issues.push({
153
+ level: 'error',
154
+ path: `artifacts.${artifact.id}.template`,
155
+ message: `Template file '${artifact.template}' points outside the schema templates directory`,
156
+ });
147
157
  }
148
158
  }
149
159
  // Dependency graph validation is already done by parseSchema
@@ -162,19 +172,73 @@ function isValidSchemaName(name) {
162
172
  /**
163
173
  * Copy a directory recursively.
164
174
  */
165
- function copyDirRecursive(src, dest) {
175
+ function resolveSchemaCopyPath(allowedRoot, sourcePath) {
176
+ try {
177
+ const canonicalRoot = fs.realpathSync(allowedRoot);
178
+ const canonicalPath = fs.realpathSync(sourcePath);
179
+ FileSystemUtils.assertPathWithin(canonicalRoot, canonicalPath);
180
+ return canonicalPath;
181
+ }
182
+ catch (error) {
183
+ const detail = error instanceof Error ? error.message : String(error);
184
+ throw new Error(`Cannot fork schema with linked or unsupported entry: ${sourcePath}: ${detail}`, { cause: error });
185
+ }
186
+ }
187
+ function copyDirRecursive(src, dest, allowedRoot = src, ancestors = new Set()) {
188
+ const canonicalSrc = resolveSchemaCopyPath(allowedRoot, src);
189
+ if (ancestors.has(canonicalSrc)) {
190
+ throw new Error(`Cannot fork schema with a linked directory cycle: ${src}`);
191
+ }
192
+ ancestors.add(canonicalSrc);
166
193
  fs.mkdirSync(dest, { recursive: true });
167
- const entries = fs.readdirSync(src, { withFileTypes: true });
168
- for (const entry of entries) {
169
- const srcPath = path.join(src, entry.name);
170
- const destPath = path.join(dest, entry.name);
171
- if (entry.isDirectory()) {
172
- copyDirRecursive(srcPath, destPath);
194
+ try {
195
+ const entries = fs.readdirSync(src, { withFileTypes: true });
196
+ for (const entry of entries) {
197
+ const srcPath = path.join(src, entry.name);
198
+ const destPath = path.join(dest, entry.name);
199
+ const canonicalEntry = resolveSchemaCopyPath(allowedRoot, srcPath);
200
+ const stats = fs.statSync(canonicalEntry);
201
+ if (stats.isDirectory()) {
202
+ copyDirRecursive(canonicalEntry, destPath, allowedRoot, ancestors);
203
+ }
204
+ else if (stats.isFile()) {
205
+ // Dereference confined links so the fork is an independent schema.
206
+ fs.copyFileSync(canonicalEntry, destPath);
207
+ }
208
+ else {
209
+ throw new Error(`Cannot fork schema with linked or unsupported entry: ${srcPath}`);
210
+ }
173
211
  }
174
- else {
175
- fs.copyFileSync(srcPath, destPath);
212
+ }
213
+ finally {
214
+ ancestors.delete(canonicalSrc);
215
+ }
216
+ }
217
+ /**
218
+ * Verifies a schema tree before replacing or creating the fork destination.
219
+ */
220
+ function assertSchemaTreeCanBeCopied(src, allowedRoot = src, ancestors = new Set()) {
221
+ const canonicalSrc = resolveSchemaCopyPath(allowedRoot, src);
222
+ if (ancestors.has(canonicalSrc)) {
223
+ throw new Error(`Cannot fork schema with a linked directory cycle: ${src}`);
224
+ }
225
+ ancestors.add(canonicalSrc);
226
+ try {
227
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
228
+ const entryPath = path.join(src, entry.name);
229
+ const canonicalEntry = resolveSchemaCopyPath(allowedRoot, entryPath);
230
+ const stats = fs.statSync(canonicalEntry);
231
+ if (stats.isDirectory()) {
232
+ assertSchemaTreeCanBeCopied(canonicalEntry, allowedRoot, ancestors);
233
+ }
234
+ else if (!stats.isFile()) {
235
+ throw new Error(`Cannot fork schema with linked or unsupported entry: ${entryPath}`);
236
+ }
176
237
  }
177
238
  }
239
+ finally {
240
+ ancestors.delete(canonicalSrc);
241
+ }
178
242
  }
179
243
  /**
180
244
  * Default artifacts with descriptions for schema init.
@@ -339,7 +403,7 @@ export function registerSchemaCommand(program) {
339
403
  const schemaResults = [];
340
404
  let anyInvalid = false;
341
405
  for (const entry of entries) {
342
- if (!entry.isDirectory())
406
+ if (!isSchemaDir(projectSchemasDir, entry))
343
407
  continue;
344
408
  const schemaDir = path.join(projectSchemasDir, entry.name);
345
409
  const schemaPath = path.join(schemaDir, 'schema.yaml');
@@ -378,9 +442,9 @@ export function registerSchemaCommand(program) {
378
442
  console.log(` ${issue.level}: ${issue.message}`);
379
443
  }
380
444
  }
381
- if (anyInvalid) {
382
- process.exitCode = 1;
383
- }
445
+ }
446
+ if (anyInvalid) {
447
+ process.exitCode = 1;
384
448
  }
385
449
  return;
386
450
  }
@@ -423,9 +487,11 @@ export function registerSchemaCommand(program) {
423
487
  for (const issue of result.issues) {
424
488
  console.log(` ${issue.level}: ${issue.message}`);
425
489
  }
426
- process.exitCode = 1;
427
490
  }
428
491
  }
492
+ if (!result.valid) {
493
+ process.exitCode = 1;
494
+ }
429
495
  }
430
496
  catch (error) {
431
497
  if (options?.json) {
@@ -487,6 +553,9 @@ export function registerSchemaCommand(program) {
487
553
  // Determine source location
488
554
  const sourceResolution = getSchemaResolution(source, projectRoot);
489
555
  const sourceLocation = sourceResolution?.source || 'package';
556
+ // Validate the complete source before a forced fork removes anything.
557
+ const trustedSourceDir = fs.realpathSync(sourceDir);
558
+ assertSchemaTreeCanBeCopied(trustedSourceDir);
490
559
  // Check destination
491
560
  const destinationDir = path.join(getProjectSchemasDir(projectRoot), destinationName);
492
561
  if (fs.existsSync(destinationDir)) {
@@ -513,7 +582,7 @@ export function registerSchemaCommand(program) {
513
582
  // Copy schema
514
583
  if (spinner)
515
584
  spinner.start(`Forking '${source}' to '${destinationName}'...`);
516
- copyDirRecursive(sourceDir, destinationDir);
585
+ copyDirRecursive(trustedSourceDir, destinationDir);
517
586
  // Update name in schema.yaml
518
587
  const destSchemaPath = path.join(destinationDir, 'schema.yaml');
519
588
  const schemaContent = fs.readFileSync(destSchemaPath, 'utf-8');
@@ -584,8 +653,9 @@ export function registerSchemaCommand(program) {
584
653
  return;
585
654
  }
586
655
  const schemaDir = path.join(getProjectSchemasDir(projectRoot), name);
587
- // Check if exists
588
- if (fs.existsSync(schemaDir)) {
656
+ // Check overwrite permission without mutating the destination
657
+ const schemaExists = fs.existsSync(schemaDir);
658
+ if (schemaExists) {
589
659
  if (!options?.force) {
590
660
  if (options?.json) {
591
661
  console.log(JSON.stringify({
@@ -601,9 +671,6 @@ export function registerSchemaCommand(program) {
601
671
  process.exitCode = 1;
602
672
  return;
603
673
  }
604
- if (spinner)
605
- spinner.start(`Removing existing schema '${name}'...`);
606
- fs.rmSync(schemaDir, { recursive: true });
607
674
  }
608
675
  // Determine artifacts and description
609
676
  let description;
@@ -625,6 +692,12 @@ export function registerSchemaCommand(program) {
625
692
  }));
626
693
  selectedArtifactIds = await checkbox({
627
694
  message: 'Select artifacts to include:',
695
+ theme: {
696
+ icon: {
697
+ checked: '[x]',
698
+ unchecked: '[ ]',
699
+ },
700
+ },
628
701
  choices: artifactChoices,
629
702
  });
630
703
  if (selectedArtifactIds.length === 0) {
@@ -673,10 +746,6 @@ export function registerSchemaCommand(program) {
673
746
  selectedArtifactIds = DEFAULT_ARTIFACTS.map((a) => a.id);
674
747
  }
675
748
  }
676
- // Create schema directory
677
- if (spinner)
678
- spinner.start(`Creating schema '${name}'...`);
679
- fs.mkdirSync(schemaDir, { recursive: true });
680
749
  // Build artifacts array with proper dependencies
681
750
  const selectedArtifacts = selectedArtifactIds.map((id) => {
682
751
  const template = DEFAULT_ARTIFACTS.find((a) => a.id === id);
@@ -718,6 +787,16 @@ export function registerSchemaCommand(program) {
718
787
  tracks: 'tasks.md',
719
788
  };
720
789
  }
790
+ // Replace only after all inputs have been collected and validated
791
+ if (schemaExists) {
792
+ if (spinner)
793
+ spinner.start(`Removing existing schema '${name}'...`);
794
+ fs.rmSync(schemaDir, { recursive: true });
795
+ }
796
+ // Create schema directory
797
+ if (spinner)
798
+ spinner.start(`Creating schema '${name}'...`);
799
+ fs.mkdirSync(schemaDir, { recursive: true });
721
800
  fs.writeFileSync(path.join(schemaDir, 'schema.yaml'), stringifyYaml(schema));
722
801
  // Create template files in templates/ subdirectory (standard location)
723
802
  const templatesDir = path.join(schemaDir, 'templates');
@@ -157,14 +157,17 @@ export class ShowCommand {
157
157
  }
158
158
  warnIrrelevantFlags(type, options) {
159
159
  const irrelevant = [];
160
+ // --no-scenarios makes commander default `scenarios` to true, so its
161
+ // presence alone does not mean the user passed it — only false does.
162
+ const isUserProvided = (k) => k === 'scenarios' ? options[k] === false : k in options;
160
163
  if (type === 'change') {
161
164
  for (const k of SPEC_FLAG_KEYS)
162
- if (k in options)
165
+ if (isUserProvided(k))
163
166
  irrelevant.push(k);
164
167
  }
165
168
  else {
166
169
  for (const k of CHANGE_FLAG_KEYS)
167
- if (k in options)
170
+ if (isUserProvided(k))
168
171
  irrelevant.push(k);
169
172
  }
170
173
  if (irrelevant.length > 0) {
@@ -1,11 +1,31 @@
1
- import { existsSync, readdirSync, readFileSync } from 'fs';
2
- import { join } from 'path';
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import path, { join } from 'path';
3
3
  import { MarkdownParser } from '../core/parsers/markdown-parser.js';
4
4
  import { Validator } from '../core/validation/validator.js';
5
5
  import { isInteractive } from '../utils/interactive.js';
6
6
  import { getSpecIds } from '../utils/item-discovery.js';
7
+ import { discoverSpecFiles } from '../utils/spec-discovery.js';
8
+ import { FileSystemUtils } from '../utils/file-system.js';
7
9
  const SPECS_DIR = 'openspec/specs';
8
- function parseSpecFromFile(specPath, specId) {
10
+ function assertSpecPath(specsDir, specPath) {
11
+ const relativePath = path.relative(path.resolve(specsDir), path.resolve(specPath));
12
+ if (relativePath === '..' ||
13
+ relativePath.startsWith(`..${path.sep}`) ||
14
+ path.isAbsolute(relativePath)) {
15
+ throw new Error(`Path is outside the allowed directory: ${specPath}`);
16
+ }
17
+ try {
18
+ // Preserve confined spec.md links, including links to a sibling capability.
19
+ FileSystemUtils.assertPathWithin(specsDir, specPath);
20
+ }
21
+ catch {
22
+ // A capability directory may intentionally be a monorepo symlink. Treat it
23
+ // as the trust root while still rejecting a link outside that capability.
24
+ FileSystemUtils.assertPathWithin(path.dirname(specPath), specPath);
25
+ }
26
+ }
27
+ function parseSpecFromFile(specsDir, specPath, specId) {
28
+ assertSpecPath(specsDir, specPath);
9
29
  const content = readFileSync(specPath, 'utf-8');
10
30
  const parser = new MarkdownParser(content);
11
31
  return parser.parseSpec(specId);
@@ -40,7 +60,8 @@ function filterSpec(spec, options) {
40
60
  * Print the raw markdown content for a spec file without any formatting.
41
61
  * Raw-first behavior ensures text mode is a passthrough for deterministic output.
42
62
  */
43
- function printSpecTextRaw(specPath) {
63
+ function printSpecTextRaw(specsDir, specPath) {
64
+ assertSpecPath(specsDir, specPath);
44
65
  const content = readFileSync(specPath, 'utf-8');
45
66
  console.log(content);
46
67
  }
@@ -69,6 +90,7 @@ export class SpecCommand {
69
90
  }
70
91
  }
71
92
  const specPath = join(this.specsDir, specId, 'spec.md');
93
+ assertSpecPath(this.specsDir, specPath);
72
94
  if (!existsSync(specPath)) {
73
95
  // Root-aware callers get the absolute path; the cwd-based noun form
74
96
  // keeps its historical forward-slash relative message on all platforms.
@@ -79,7 +101,7 @@ export class SpecCommand {
79
101
  if (options.requirements && options.requirement) {
80
102
  throw new Error('Options --requirements and --requirement cannot be used together');
81
103
  }
82
- const parsed = parseSpecFromFile(specPath, specId);
104
+ const parsed = parseSpecFromFile(this.specsDir, specPath, specId);
83
105
  const filtered = filterSpec(parsed, options);
84
106
  const output = {
85
107
  id: specId,
@@ -93,7 +115,7 @@ export class SpecCommand {
93
115
  console.log(JSON.stringify(output, null, 2));
94
116
  return;
95
117
  }
96
- printSpecTextRaw(specPath);
118
+ printSpecTextRaw(this.specsDir, specPath);
97
119
  }
98
120
  }
99
121
  export function registerSpecCommand(rootProgram) {
@@ -127,36 +149,32 @@ export function registerSpecCommand(rootProgram) {
127
149
  .description('List all available specifications')
128
150
  .option('--json', 'Output as JSON')
129
151
  .option('--long', 'Show id and title with counts')
130
- .action((options) => {
152
+ .action(async (options) => {
131
153
  try {
132
154
  if (!existsSync(SPECS_DIR)) {
133
155
  console.log('No items found');
134
156
  return;
135
157
  }
136
- const specs = readdirSync(SPECS_DIR, { withFileTypes: true })
137
- .filter(dirent => dirent.isDirectory())
138
- .map(dirent => {
139
- const specPath = join(SPECS_DIR, dirent.name, 'spec.md');
140
- if (existsSync(specPath)) {
141
- try {
142
- const spec = parseSpecFromFile(specPath, dirent.name);
143
- return {
144
- id: dirent.name,
145
- title: spec.name,
146
- requirementCount: spec.requirements.length
147
- };
148
- }
149
- catch {
150
- return {
151
- id: dirent.name,
152
- title: dirent.name,
153
- requirementCount: 0
154
- };
155
- }
158
+ const discovered = await discoverSpecFiles(SPECS_DIR);
159
+ const specs = discovered
160
+ .map(({ id, specFile }) => {
161
+ try {
162
+ assertSpecPath(SPECS_DIR, specFile);
163
+ const spec = parseSpecFromFile(SPECS_DIR, specFile, id);
164
+ return {
165
+ id,
166
+ title: spec.name,
167
+ requirementCount: spec.requirements.length
168
+ };
169
+ }
170
+ catch {
171
+ return {
172
+ id,
173
+ title: id,
174
+ requirementCount: 0
175
+ };
156
176
  }
157
- return null;
158
177
  })
159
- .filter((spec) => spec !== null)
160
178
  .sort((a, b) => a.id.localeCompare(b.id));
161
179
  if (options.json) {
162
180
  console.log(JSON.stringify(specs, null, 2));
@@ -203,10 +221,12 @@ export function registerSpecCommand(rootProgram) {
203
221
  }
204
222
  }
205
223
  const specPath = join(SPECS_DIR, specId, 'spec.md');
224
+ assertSpecPath(SPECS_DIR, specPath);
206
225
  if (!existsSync(specPath)) {
207
226
  throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);
208
227
  }
209
228
  const validator = new Validator(options.strict);
229
+ assertSpecPath(SPECS_DIR, specPath);
210
230
  const report = await validator.validateSpec(specPath);
211
231
  if (options.json) {
212
232
  console.log(JSON.stringify(report, null, 2));
@@ -1,6 +1,7 @@
1
1
  import ora from 'ora';
2
2
  import path from 'path';
3
3
  import { Validator } from '../core/validation/validator.js';
4
+ import { VALIDATION_MESSAGES } from '../core/validation/constants.js';
4
5
  import { resolveRootForCommand, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from '../core/root-selection.js';
5
6
  import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
6
7
  import { getSpecIds } from '../utils/item-discovery.js';
@@ -144,7 +145,7 @@ export class ValidateCommand {
144
145
  if (type === 'change') {
145
146
  const changeDir = path.join(root.changesDir, id);
146
147
  const start = Date.now();
147
- const report = await validator.validateChangeDeltaSpecs(changeDir);
148
+ const report = await validator.validateChangeDeltaSpecs(changeDir, { mainSpecsDir: root.specsDir });
148
149
  const durationMs = Date.now() - start;
149
150
  this.printReport('change', id, report, durationMs, opts.json, root);
150
151
  // Non-zero exit if invalid (keeps enriched output test semantics)
@@ -174,12 +175,26 @@ export class ValidateCommand {
174
175
  const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
175
176
  console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
176
177
  }
177
- this.printNextSteps(type, id, root);
178
+ this.printNextSteps(type, id, root, report.issues);
178
179
  }
179
180
  }
180
- printNextSteps(type, id, root) {
181
+ printNextSteps(type, id, root, issues = []) {
181
182
  const bullets = [];
182
- if (type === 'change') {
183
+ // The delta-authoring bullets contradict a marker-related error ("add
184
+ // deltas" vs "remove skip_specs or the files"), so branch on the exact
185
+ // marker messages - the generic no-deltas guidance also mentions
186
+ // skip_specs, which must not trigger this.
187
+ const conflictIssue = issues.some(i => i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT));
188
+ const invalidMarkerIssue = issues.some(i => i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA));
189
+ if (type === 'change' && conflictIssue) {
190
+ bullets.push('- This change declares skip_specs (no spec deltas): delete the files under specs/, or remove skip_specs from .openspec.yaml if requirements do change');
191
+ bullets.push('- skip_specs is only honored when .openspec.yaml is valid change metadata (schema: <name> naming a known schema is required)');
192
+ }
193
+ else if (type === 'change' && invalidMarkerIssue) {
194
+ bullets.push('- Fix .openspec.yaml so the skip_specs marker can be honored (schema: <name> naming a known schema is required)');
195
+ bullets.push('- Or remove skip_specs from .openspec.yaml and add delta specs instead');
196
+ }
197
+ else if (type === 'change') {
183
198
  bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements');
184
199
  bullets.push('- Each requirement MUST include at least one #### Scenario: block');
185
200
  bullets.push(`- Debug parsed deltas: ${withStoreFlag(root, `openspec show ${id} --json --deltas-only`)}`);
@@ -207,7 +222,7 @@ export class ValidateCommand {
207
222
  queue.push(async () => {
208
223
  const start = Date.now();
209
224
  const changeDir = path.join(root.changesDir, id);
210
- const report = await validator.validateChangeDeltaSpecs(changeDir);
225
+ const report = await validator.validateChangeDeltaSpecs(changeDir, { mainSpecsDir: root.specsDir });
211
226
  const durationMs = Date.now() - start;
212
227
  return { id, type: 'change', valid: report.valid, issues: report.issues, durationMs };
213
228
  });
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export { statusCommand } from './status.js';
7
7
  export type { StatusOptions } from './status.js';
8
- export { instructionsCommand, applyInstructionsCommand } from './instructions.js';
8
+ export { instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, } from './instructions.js';
9
9
  export type { InstructionsOptions } from './instructions.js';
10
10
  export { templatesCommand } from './templates.js';
11
11
  export type { TemplatesOptions } from './templates.js';
@@ -4,7 +4,7 @@
4
4
  * Commands for the artifact-driven workflow: status, instructions, templates, schemas, new change.
5
5
  */
6
6
  export { statusCommand } from './status.js';
7
- export { instructionsCommand, applyInstructionsCommand } from './instructions.js';
7
+ export { instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, } from './instructions.js';
8
8
  export { templatesCommand } from './templates.js';
9
9
  export { schemasCommand } from './schemas.js';
10
10
  export { newChangeCommand } from './new-change.js';
@@ -7,7 +7,8 @@
7
7
  import { type ArtifactInstructions } from '../../core/artifact-graph/index.js';
8
8
  import { type PlanningHome } from '../../core/planning-home.js';
9
9
  import { type ReferenceIndexEntry } from '../../core/references.js';
10
- import { type ApplyInstructions } from './shared.js';
10
+ import { type ProjectConfig } from '../../core/project-config.js';
11
+ import { type ApplyInstructions, type ArchiveInstructions } from './shared.js';
11
12
  export interface InstructionsOptions {
12
13
  change?: string;
13
14
  schema?: string;
@@ -22,11 +23,13 @@ export interface ApplyInstructionsOptions {
22
23
  storePath?: string;
23
24
  json?: boolean;
24
25
  }
26
+ export type ArchiveInstructionsOptions = ApplyInstructionsOptions;
25
27
  export declare function instructionsCommand(artifactId: string | undefined, options: InstructionsOptions): Promise<void>;
26
28
  export declare function printInstructionsText(instructions: ArtifactInstructions, isBlocked: boolean): void;
27
29
  export interface GenerateApplyInstructionsOptions {
28
30
  planningHome?: PlanningHome;
29
31
  references?: ReferenceIndexEntry[];
32
+ projectConfig?: ProjectConfig | null;
30
33
  }
31
34
  /**
32
35
  * Generates apply instructions for implementing tasks from a change.
@@ -36,4 +39,7 @@ export interface GenerateApplyInstructionsOptions {
36
39
  export declare function generateApplyInstructions(projectRoot: string, changeName: string, schemaName?: string, options?: GenerateApplyInstructionsOptions): Promise<ApplyInstructions>;
37
40
  export declare function applyInstructionsCommand(options: ApplyInstructionsOptions): Promise<void>;
38
41
  export declare function printApplyInstructionsText(instructions: ApplyInstructions): void;
42
+ export declare function generateArchiveInstructions(changeName: string, projectConfig: ProjectConfig | null): ArchiveInstructions;
43
+ export declare function archiveInstructionsCommand(options: ArchiveInstructionsOptions): Promise<void>;
44
+ export declare function printArchiveInstructionsText(instructions: ArchiveInstructions): void;
39
45
  //# sourceMappingURL=instructions.d.ts.map