@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
package/README.md CHANGED
@@ -76,6 +76,29 @@ AI: Archived to openspec/changes/archive/2025-01-23-add-dark-mode/
76
76
  Specs updated. Ready for the next feature.
77
77
  ```
78
78
 
79
+ <details>
80
+ <summary><strong>What do the specs actually look like?</strong></summary>
81
+
82
+ Plain Markdown — requirements with concrete scenarios, no special syntax to learn. Here's what goes in the `specs/` folder created above:
83
+
84
+ ```markdown
85
+ ## ADDED Requirements
86
+
87
+ ### Requirement: Theme selection
88
+ The app SHALL let users switch between light and dark themes,
89
+ defaulting to the system preference.
90
+
91
+ #### Scenario: User toggles dark mode
92
+ - **WHEN** the user clicks the theme toggle
93
+ - **THEN** the app switches to dark mode and persists the choice
94
+ ```
95
+
96
+ Your AI writes these; you review the plan before any code is written.
97
+
98
+ OpenSpec is built with OpenSpec — browse this repo's live [specs](openspec/specs) and in-flight [changes](openspec/changes) for real examples at scale.
99
+
100
+ </details>
101
+
79
102
  <details>
80
103
  <summary><strong>OpenSpec Dashboard</strong></summary>
81
104
 
@@ -114,6 +137,8 @@ cd your-project
114
137
  openspec init
115
138
  ```
116
139
 
140
+ > **Want your AI to do it?** Paste the [setup prompt](docs/installation.md#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
141
+
117
142
  Now talk to your AI:
118
143
 
119
144
  - **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before anything is written. ([Explore guide](docs/explore.md))
@@ -121,8 +146,10 @@ Now talk to your AI:
121
146
 
122
147
  Both are in the default profile. If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`.
123
148
 
149
+ `/opsx:propose` is the canonical name; your tool may spell it `/opsx-propose` (Cursor, GitHub Copilot), `@opsx-propose` (Amazon Q) or `$openspec-propose` (Codex). `openspec init` prints the right form for the tools you picked — see [How To Invoke](docs/supported-tools.md#how-to-invoke).
150
+
124
151
  > [!NOTE]
125
- > Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 25+ tools and growing.
152
+ > Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 30+ tools and growing.
126
153
  >
127
154
  > Also works with pnpm, yarn, bun, and nix. [See installation options](docs/installation.md).
128
155
 
@@ -221,7 +248,9 @@ OpenSpec collects anonymous usage stats.
221
248
 
222
249
  We collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI.
223
250
 
224
- **Opt-out:** `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1`
251
+ **Opt-out (any one is enough):**
252
+ - `openspec config set telemetry.enabled false` (global config; unset means on)
253
+ - `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1` (env overrides config)
225
254
 
226
255
  </details>
227
256
 
package/dist/cli/index.js CHANGED
@@ -5,8 +5,9 @@ import ora from 'ora';
5
5
  import path from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { promises as fs } from 'fs';
8
- import { AI_TOOLS } from '../core/config.js';
8
+ import { AI_TOOLS, TOOL_ID_ALIASES } from '../core/config.js';
9
9
  import { UpdateCommand } from '../core/update.js';
10
+ import { getAvailableCliUpdate, displayCliUpdateNote, shouldOfferUpgrade, getInstallDir, offerCliUpgrade, rerunUpdateWithUpgradedCli, displayUpgradeCommand, isSourceCheckout, } from '../core/version-check.js';
10
11
  import { ListCommand } from '../core/list.js';
11
12
  import { ArchiveCommand } from '../core/archive.js';
12
13
  import { ViewCommand } from '../core/view.js';
@@ -23,9 +24,10 @@ import { registerStoreCommand } from '../commands/store.js';
23
24
  import { registerDoctorCommand } from '../commands/doctor.js';
24
25
  import { registerContextCommand } from '../commands/context.js';
25
26
  import { registerWorksetCommand } from '../commands/workset.js';
26
- import { statusCommand, instructionsCommand, applyInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
27
+ import { statusCommand, instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
27
28
  import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
28
29
  import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
30
+ import { isInteractive } from '../utils/interactive.js';
29
31
  const STORE_OPTION_DESCRIPTION = COMMON_FLAGS.store.description;
30
32
  // Deliberate rejection path: --store-path stays registered (hidden) so the
31
33
  // resolver can explain that registering the path is the supported route,
@@ -95,14 +97,22 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
95
97
  program.hook('postAction', async () => {
96
98
  await shutdown();
97
99
  });
98
- const availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value);
99
- const toolsOptionDescription = `Configure AI tools non-interactively. Use "all", "none", or a comma-separated list of: ${availableToolIds.join(', ')}`;
100
+ const availableToolIds = AI_TOOLS
101
+ .filter((tool) => tool.skillsDir || tool.globalSkillsDir)
102
+ .map((tool) => tool.value);
103
+ const toolAliasNote = Object.entries(TOOL_ID_ALIASES)
104
+ .map(([retired, current]) => `${retired} (now ${current})`)
105
+ .join(', ');
106
+ const toolsOptionDescription = `Configure AI tools non-interactively. Use "all", "none", or a comma-separated list of: ${availableToolIds.join(', ')}. Also accepted: ${toolAliasNote}`;
100
107
  program
101
108
  .command('init [path]')
102
109
  .description('Initialize OpenSpec in your project')
103
110
  .option('--tools <tools>', toolsOptionDescription)
104
111
  .option('--force', 'Auto-cleanup legacy files without prompting')
105
112
  .option('--profile <profile>', 'Override global config profile (core or custom)')
113
+ .option('--no-animation', 'Show a static welcome screen instead of the animated one')
114
+ .option('--copilot-cloud', 'Set up GitHub Copilot cloud coding-agent files without prompting')
115
+ .option('--no-copilot-cloud', 'Skip GitHub Copilot cloud coding-agent files without prompting')
106
116
  .action(async (targetPath = '.', options) => {
107
117
  try {
108
118
  // Validate that the path is a valid directory
@@ -130,6 +140,8 @@ program
130
140
  tools: options?.tools,
131
141
  force: options?.force,
132
142
  profile: options?.profile,
143
+ animation: options?.animation,
144
+ copilotCloud: options?.copilotCloud,
133
145
  });
134
146
  await initCommand.execute(targetPath);
135
147
  }
@@ -165,8 +177,55 @@ program
165
177
  .option('--force', 'Force update even when tools are up to date')
166
178
  .action(async (targetPath = '.', options) => {
167
179
  try {
180
+ const installDir = getInstallDir();
181
+ // Running from a clone: the version is whatever the branch says, so any
182
+ // upgrade advice would be noise. Decided before the request, so a
183
+ // contributor never waits on an answer that gets thrown away.
184
+ const latestVersion = isSourceCheckout(installDir) ? null : await getAvailableCliUpdate();
185
+ const announce = latestVersion !== null;
186
+ // Offer to upgrade first: this process generates files from its own
187
+ // templates, so upgrading afterwards would leave the old ones on disk.
188
+ // Both streams must be a terminal — with stdout redirected the question
189
+ // lands in the file and the user waits at a blank screen forever.
190
+ const canOffer = announce &&
191
+ shouldOfferUpgrade({
192
+ installDir,
193
+ projectPath: targetPath,
194
+ interactive: isInteractive(),
195
+ stdoutIsTty: Boolean(process.stdout.isTTY),
196
+ });
197
+ let declined = false;
198
+ if (latestVersion && canOffer) {
199
+ displayCliUpdateNote(latestVersion, targetPath, { withCommand: false });
200
+ const outcome = await offerCliUpgrade(latestVersion);
201
+ // Set the code and return rather than process.exit: exiting here would
202
+ // skip commander's postAction hook, killing the telemetry flush
203
+ // mid-request.
204
+ if (outcome === 'cancelled') {
205
+ // Ctrl-C means stop the command, not fall through to more prompts.
206
+ process.exitCode = 130;
207
+ return;
208
+ }
209
+ if (outcome === 'upgraded') {
210
+ process.exitCode = await rerunUpdateWithUpgradedCli(targetPath, {
211
+ force: options?.force,
212
+ });
213
+ return;
214
+ }
215
+ // Declined, failed, or upgraded-but-unreachable: fall through to the
216
+ // update, then leave the command on screen underneath it.
217
+ declined = true;
218
+ }
168
219
  const updateCommand = new UpdateCommand({ force: options?.force });
169
220
  await updateCommand.execute(targetPath);
221
+ if (declined) {
222
+ // The headline was printed before the prompt; only the manual route is
223
+ // still owed, and it belongs where the user is looking now.
224
+ displayUpgradeCommand(targetPath);
225
+ }
226
+ else if (latestVersion) {
227
+ displayCliUpdateNote(latestVersion, targetPath);
228
+ }
170
229
  }
171
230
  catch (error) {
172
231
  failWithError(error);
@@ -212,10 +271,19 @@ program
212
271
  program
213
272
  .command('view')
214
273
  .description('Display an interactive dashboard of specs and changes')
215
- .action(async () => {
274
+ .option('--store <id>', STORE_OPTION_DESCRIPTION)
275
+ .addOption(hiddenStorePathOption())
276
+ .action(async (options) => {
216
277
  try {
278
+ // Implicit cwd fallback stays enabled so `view` keeps accepting the same
279
+ // directories as `list`/`status` — notably pre-config.yaml `openspec/`
280
+ // dirs. ViewCommand still reports a missing openspec/ directory itself.
281
+ const root = await resolveRootForCommand(options ?? {});
282
+ if (!root) {
283
+ return;
284
+ }
217
285
  const viewCommand = new ViewCommand();
218
- await viewCommand.execute('.');
286
+ await viewCommand.execute(root.path);
219
287
  }
220
288
  catch (error) {
221
289
  failWithError(error);
@@ -460,7 +528,7 @@ program
460
528
  // Instructions command
461
529
  program
462
530
  .command('instructions [artifact]')
463
- .description('Output enriched instructions for creating an artifact or applying tasks')
531
+ .description('Output enriched instructions for artifacts, apply, or archive')
464
532
  .option('--change <id>', 'Change name')
465
533
  .option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
466
534
  .option('--json', 'Output as JSON')
@@ -468,10 +536,13 @@ program
468
536
  .addOption(hiddenStorePathOption())
469
537
  .action(async (artifactId, options) => {
470
538
  try {
471
- // Special case: "apply" is not an artifact, but a command to get apply instructions
539
+ // Workflow instruction surfaces are reserved command branches, not artifacts.
472
540
  if (artifactId === 'apply') {
473
541
  await applyInstructionsCommand(options);
474
542
  }
543
+ else if (artifactId === 'archive') {
544
+ await archiveInstructionsCommand(options);
545
+ }
475
546
  else {
476
547
  await instructionsCommand(artifactId, options);
477
548
  }
@@ -31,7 +31,6 @@ export declare class ChangeCommand {
31
31
  json?: boolean;
32
32
  noInteractive?: boolean;
33
33
  }): Promise<void>;
34
- private getActiveChanges;
35
34
  private extractTitle;
36
35
  private printNextSteps;
37
36
  }
@@ -2,12 +2,31 @@ import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
3
  import { JsonConverter } from '../core/converters/json-converter.js';
4
4
  import { Validator } from '../core/validation/validator.js';
5
+ import { VALIDATION_MESSAGES } from '../core/validation/constants.js';
5
6
  import { ChangeParser } from '../core/parsers/change-parser.js';
6
7
  import { isInteractive } from '../utils/interactive.js';
7
8
  import { getActiveChangeIds } from '../utils/item-discovery.js';
8
9
  import { getTaskProgressForChange } from '../utils/task-progress.js';
9
- // Constants for better maintainability
10
- const ARCHIVE_DIR = 'archive';
10
+ import { FileSystemUtils } from '../utils/file-system.js';
11
+ /**
12
+ * True only when `target` is definitively absent. An EACCES or I/O failure
13
+ * means existence cannot be determined, so callers fall through to their
14
+ * read-error path rather than claim the file was never written.
15
+ */
16
+ async function isDefinitelyMissing(target) {
17
+ return fs
18
+ .access(target)
19
+ .then(() => false)
20
+ .catch((error) => error?.code === 'ENOENT');
21
+ }
22
+ /**
23
+ * A change is a directory directly under changes/. Rejecting anything else up
24
+ * front keeps a traversing name (`../..`) from reading a proposal outside the
25
+ * changes directory, and keeps the missing-proposal message honest.
26
+ */
27
+ function isChangeDirectoryName(changesPath, changeDir) {
28
+ return path.dirname(path.resolve(changeDir)) === path.resolve(changesPath);
29
+ }
11
30
  export class ChangeCommand {
12
31
  converter;
13
32
  rootPath;
@@ -30,7 +49,8 @@ export class ChangeCommand {
30
49
  const changesPath = this.getChangesPath();
31
50
  if (!changeName) {
32
51
  const canPrompt = isInteractive(options);
33
- const changes = await this.getActiveChanges(changesPath);
52
+ // Offer exactly the changes `show <name>` can resolve.
53
+ const changes = await getActiveChangeIds(this.rootPath ?? process.cwd());
34
54
  if (canPrompt && changes.length > 0) {
35
55
  const { select } = await import('@inquirer/prompts');
36
56
  const selected = await select({
@@ -51,19 +71,40 @@ export class ChangeCommand {
51
71
  return;
52
72
  }
53
73
  }
54
- const proposalPath = path.join(changesPath, changeName, 'proposal.md');
74
+ const changeDir = path.join(changesPath, changeName);
75
+ const proposalPath = path.join(changeDir, 'proposal.md');
76
+ if (!isChangeDirectoryName(changesPath, changeDir)) {
77
+ throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
78
+ }
55
79
  try {
56
80
  await fs.access(proposalPath);
57
81
  }
58
82
  catch {
83
+ // A change can exist without a proposal: `openspec new change` scaffolds
84
+ // only .openspec.yaml, and a custom schema need not define a proposal
85
+ // artifact. Say which of the two cases this is instead of reporting a
86
+ // change that does exist as missing. A stray file under changes/ is not a
87
+ // change, and naming it one would point the user at a `status --change`
88
+ // call that cannot work.
89
+ const isChangeDirectory = await fs
90
+ .stat(changeDir)
91
+ .then((stats) => stats.isDirectory())
92
+ .catch(() => false);
93
+ if (isChangeDirectory) {
94
+ throw new Error(`Change "${changeName}" has no proposal.md yet. ` +
95
+ `Run "openspec status --change ${changeName}" to see which artifact comes next.`);
96
+ }
59
97
  throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
60
98
  }
99
+ FileSystemUtils.assertPathWithin(path.dirname(proposalPath), proposalPath);
61
100
  if (options?.json) {
101
+ FileSystemUtils.assertPathWithin(changeDir, proposalPath);
62
102
  const jsonOutput = await this.converter.convertChangeToJson(proposalPath);
63
103
  if (options.requirementsOnly) {
64
104
  console.error('Flag --requirements-only is deprecated; use --deltas-only instead.');
65
105
  }
66
106
  const parsed = JSON.parse(jsonOutput);
107
+ FileSystemUtils.assertPathWithin(changeDir, proposalPath);
67
108
  const contentForTitle = await fs.readFile(proposalPath, 'utf-8');
68
109
  const title = this.extractTitle(contentForTitle, changeName);
69
110
  const id = parsed.name;
@@ -78,6 +119,7 @@ export class ChangeCommand {
78
119
  console.log(JSON.stringify(output, null, 2));
79
120
  }
80
121
  else {
122
+ FileSystemUtils.assertPathWithin(changeDir, proposalPath);
81
123
  const content = await fs.readFile(proposalPath, 'utf-8');
82
124
  console.log(content);
83
125
  }
@@ -89,18 +131,31 @@ export class ChangeCommand {
89
131
  */
90
132
  async list(options) {
91
133
  const changesPath = path.join(process.cwd(), 'openspec', 'changes');
92
- const changes = await this.getActiveChanges(changesPath);
134
+ // Same directory-based resolution as `openspec list`, the command this
135
+ // deprecated alias points users at. Every output path below already
136
+ // tolerates a change whose proposal.md is missing or unreadable.
137
+ const changes = await getActiveChangeIds();
93
138
  if (options?.json) {
94
139
  const changeDetails = await Promise.all(changes.map(async (changeName) => {
95
- const proposalPath = path.join(changesPath, changeName, 'proposal.md');
140
+ const changeDir = path.join(changesPath, changeName);
141
+ const proposalPath = path.join(changeDir, 'proposal.md');
142
+ // Resolve task progress through the shared tracked-tasks helper so
143
+ // this deprecated noun-form list cannot re-fork the resolution
144
+ // (#1202). Tasks are independent of the proposal: a change can carry
145
+ // tasks before, or without, a proposal.md.
146
+ const taskStatus = await getTaskProgressForChange(changesPath, changeName, process.cwd());
147
+ // No proposal yet is an ordinary state (scaffolded change, or a
148
+ // schema with no proposal artifact), so name the change rather than
149
+ // labelling it Unknown. Unknown stays for a proposal that exists but
150
+ // cannot be read or parsed.
151
+ if (await isDefinitelyMissing(proposalPath)) {
152
+ return { id: changeName, title: changeName, deltaCount: 0, taskStatus };
153
+ }
96
154
  try {
155
+ FileSystemUtils.assertPathWithin(changeDir, proposalPath);
97
156
  const content = await fs.readFile(proposalPath, 'utf-8');
98
- const changeDir = path.join(changesPath, changeName);
99
157
  const parser = new ChangeParser(content, changeDir);
100
158
  const change = await parser.parseChangeWithDeltas(changeName);
101
- // Resolve task progress through the shared tracked-tasks helper so
102
- // this deprecated noun-form list cannot re-fork the resolution (#1202).
103
- const taskStatus = await getTaskProgressForChange(changesPath, changeName, process.cwd());
104
159
  return {
105
160
  id: changeName,
106
161
  title: this.extractTitle(content, changeName),
@@ -108,13 +163,8 @@ export class ChangeCommand {
108
163
  taskStatus,
109
164
  };
110
165
  }
111
- catch (error) {
112
- return {
113
- id: changeName,
114
- title: 'Unknown',
115
- deltaCount: 0,
116
- taskStatus: { total: 0, completed: 0 },
117
- };
166
+ catch {
167
+ return { id: changeName, title: 'Unknown', deltaCount: 0, taskStatus };
118
168
  }
119
169
  }));
120
170
  const sorted = changeDetails.sort((a, b) => a.id.localeCompare(b.id));
@@ -133,20 +183,25 @@ export class ChangeCommand {
133
183
  }
134
184
  // Long format: id: title and minimal counts
135
185
  for (const changeName of sorted) {
136
- const proposalPath = path.join(changesPath, changeName, 'proposal.md');
186
+ const changeDir = path.join(changesPath, changeName);
187
+ const proposalPath = path.join(changeDir, 'proposal.md');
188
+ const { total, completed } = await getTaskProgressForChange(changesPath, changeName, process.cwd());
189
+ const taskStatusText = total > 0 ? ` [tasks ${completed}/${total}]` : '';
190
+ if (await isDefinitelyMissing(proposalPath)) {
191
+ console.log(`${changeName}: (no proposal.md yet)${taskStatusText}`);
192
+ continue;
193
+ }
137
194
  try {
195
+ FileSystemUtils.assertPathWithin(changeDir, proposalPath);
138
196
  const content = await fs.readFile(proposalPath, 'utf-8');
139
197
  const title = this.extractTitle(content, changeName);
140
- const { total, completed } = await getTaskProgressForChange(changesPath, changeName, process.cwd());
141
- const taskStatusText = total > 0 ? ` [tasks ${completed}/${total}]` : '';
142
- const changeDir = path.join(changesPath, changeName);
143
- const parser = new ChangeParser(await fs.readFile(proposalPath, 'utf-8'), changeDir);
198
+ const parser = new ChangeParser(content, changeDir);
144
199
  const change = await parser.parseChangeWithDeltas(changeName);
145
200
  const deltaCountText = ` [deltas ${change.deltas.length}]`;
146
201
  console.log(`${changeName}: ${title}${deltaCountText}${taskStatusText}`);
147
202
  }
148
203
  catch {
149
- console.log(`${changeName}: (unable to read)`);
204
+ console.log(`${changeName}: (unable to read)${taskStatusText}`);
150
205
  }
151
206
  }
152
207
  }
@@ -177,6 +232,9 @@ export class ChangeCommand {
177
232
  }
178
233
  }
179
234
  const changeDir = path.join(changesPath, changeName);
235
+ if (!isChangeDirectoryName(changesPath, changeDir)) {
236
+ throw new Error(`Change "${changeName}" not found at ${changeDir}`);
237
+ }
180
238
  try {
181
239
  await fs.access(changeDir);
182
240
  }
@@ -184,7 +242,11 @@ export class ChangeCommand {
184
242
  throw new Error(`Change "${changeName}" not found at ${changeDir}`);
185
243
  }
186
244
  const validator = new Validator(options?.strict || false);
187
- const report = await validator.validateChangeDeltaSpecs(changeDir);
245
+ const report = await validator.validateChangeDeltaSpecs(changeDir, {
246
+ // Derived from changesPath so the main specs come from the same root the
247
+ // change itself was resolved against.
248
+ mainSpecsDir: path.join(path.dirname(changesPath), 'specs'),
249
+ });
188
250
  if (options?.json) {
189
251
  console.log(JSON.stringify(report, null, 2));
190
252
  }
@@ -200,44 +262,36 @@ export class ChangeCommand {
200
262
  console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
201
263
  });
202
264
  // Next steps footer to guide fixing issues
203
- this.printNextSteps();
265
+ this.printNextSteps(report.issues);
204
266
  if (!options?.json) {
205
267
  process.exitCode = 1;
206
268
  }
207
269
  }
208
270
  }
209
271
  }
210
- async getActiveChanges(changesPath) {
211
- try {
212
- const entries = await fs.readdir(changesPath, { withFileTypes: true });
213
- const result = [];
214
- for (const entry of entries) {
215
- if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === ARCHIVE_DIR)
216
- continue;
217
- const proposalPath = path.join(changesPath, entry.name, 'proposal.md');
218
- try {
219
- await fs.access(proposalPath);
220
- result.push(entry.name);
221
- }
222
- catch {
223
- // skip directories without proposal.md
224
- }
225
- }
226
- return result.sort();
227
- }
228
- catch {
229
- return [];
230
- }
231
- }
232
272
  extractTitle(content, changeName) {
233
273
  const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im);
234
274
  return match ? match[1].trim() : changeName;
235
275
  }
236
- printNextSteps() {
276
+ printNextSteps(issues = []) {
237
277
  const bullets = [];
238
- bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements');
239
- bullets.push('- Each requirement MUST include at least one #### Scenario: block');
240
- bullets.push('- Debug parsed deltas: openspec change show <id> --json --deltas-only');
278
+ // Branch on the exact marker messages: the generic no-deltas guidance
279
+ // also mentions skip_specs and must not trigger the marker bullets.
280
+ const conflictIssue = issues.some(i => i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT));
281
+ const invalidMarkerIssue = issues.some(i => i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA));
282
+ if (conflictIssue) {
283
+ 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');
284
+ bullets.push('- skip_specs is only honored when .openspec.yaml is valid change metadata (schema: <name> is required)');
285
+ }
286
+ else if (invalidMarkerIssue) {
287
+ bullets.push('- Fix .openspec.yaml so the skip_specs marker can be honored (schema: <name> is required)');
288
+ bullets.push('- Or remove skip_specs from .openspec.yaml and add delta specs instead');
289
+ }
290
+ else {
291
+ bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements');
292
+ bullets.push('- Each requirement MUST include at least one #### Scenario: block');
293
+ bullets.push('- Debug parsed deltas: openspec change show <id> --json --deltas-only');
294
+ }
241
295
  console.error('Next steps:');
242
296
  bullets.forEach(b => console.error(` ${b}`));
243
297
  }
@@ -1,12 +1,13 @@
1
- import { spawn, execSync } from 'node:child_process';
1
+ import { spawn } from 'node:child_process';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import { getGlobalConfigPath, getGlobalConfig, saveGlobalConfig, } from '../core/global-config.js';
5
- import { getNestedValue, setNestedValue, deleteNestedValue, coerceValue, formatValueYaml, validateConfigKeyPath, validateConfig, DEFAULT_CONFIG, } from '../core/config-schema.js';
5
+ import { getNestedValue, setNestedValue, deleteNestedValue, coerceValue, formatValueYaml, validateConfigKeyPath, hasUnsafeKeySegment, validateConfig, DEFAULT_CONFIG, } from '../core/config-schema.js';
6
6
  import { CORE_WORKFLOWS, ALL_WORKFLOWS, getProfileWorkflows } from '../core/profiles.js';
7
7
  import { OPENSPEC_DIR_NAME } from '../core/config.js';
8
8
  import { hasProjectConfigDrift } from '../core/profile-sync-drift.js';
9
- import { isPromptCancellationError } from './shared-output.js';
9
+ import { UpdateCommand } from '../core/update.js';
10
+ import { asErrorMessage, isPromptCancellationError } from './shared-output.js';
10
11
  const WORKFLOW_PROMPT_META = {
11
12
  propose: {
12
13
  name: 'Propose change',
@@ -233,11 +234,15 @@ export function registerConfigCommand(program) {
233
234
  .action((key, value, options) => {
234
235
  const allowUnknown = Boolean(options.allowUnknown);
235
236
  const keyValidation = validateConfigKeyPath(key);
236
- if (!keyValidation.valid && !allowUnknown) {
237
+ // --allow-unknown relaxes the known-key check, but never the prototype-safety check.
238
+ const unsafeKey = hasUnsafeKeySegment(key);
239
+ if (!keyValidation.valid && (!allowUnknown || unsafeKey)) {
237
240
  const reason = keyValidation.reason ? ` ${keyValidation.reason}.` : '';
238
241
  console.error(`Error: Invalid configuration key "${key}".${reason}`);
239
242
  console.error('Use "openspec config list" to see available keys.');
240
- console.error('Pass --allow-unknown to bypass this check.');
243
+ if (!allowUnknown && !unsafeKey) {
244
+ console.error('Pass --allow-unknown to bypass this check.');
245
+ }
241
246
  process.exitCode = 1;
242
247
  return;
243
248
  }
@@ -527,11 +532,12 @@ export function registerConfigCommand(program) {
527
532
  });
528
533
  if (applyNow) {
529
534
  try {
530
- execSync('npx openspec update', { stdio: 'inherit', cwd: projectDir });
535
+ await new UpdateCommand().execute(projectDir);
531
536
  console.log('Run `openspec update` in your other projects to apply.');
532
537
  }
533
- catch {
534
- console.error('`openspec update` failed. Please run it manually to apply the profile changes.');
538
+ catch (error) {
539
+ console.error(`\`openspec update\` failed: ${asErrorMessage(error)}`);
540
+ console.error('Please run it manually to apply the profile changes.');
535
541
  process.exitCode = 1;
536
542
  }
537
543
  return;
@@ -6,7 +6,7 @@
6
6
  import { Option } from 'commander';
7
7
  import { resolveRootForCommand, } from '../core/root-selection.js';
8
8
  import { readOptionalStoreMetadataState } from '../core/store/foundation.js';
9
- import { gitOriginUrl, isGitRepositoryAtRoot } from '../core/store/git.js';
9
+ import { gitOriginUrl, gitTrackingDrift, isGitRepositoryAtRoot } from '../core/store/git.js';
10
10
  import { classifyOpenSpecDir, readProjectConfig, resolveConfigFilePath, } from '../core/project-config.js';
11
11
  import { findRepoPlanningRootSync } from '../core/planning-home.js';
12
12
  import { gatherRelationshipData } from './shared-gather.js';
@@ -27,7 +27,8 @@ async function gatherHealth(root) {
27
27
  referenceEntries,
28
28
  registryUnreadable,
29
29
  };
30
- // Store facts for store-backed roots (explicit --store or declared).
30
+ // Store facts for store-backed roots (explicit --store, a declared
31
+ // pointer, or the global default).
31
32
  // Missing/invalid metadata never reaches here: store resolution
32
33
  // verifies identity first and fails with the existing taxonomy
33
34
  // (recorded amendment - corrupt store.yaml is an exit-1 resolution
@@ -35,14 +36,18 @@ async function gatherHealth(root) {
35
36
  if (root.storeId) {
36
37
  const metadata = await readOptionalStoreMetadataState(root.path).catch(() => null);
37
38
  // git -C walks UP the tree: probing a non-repo store nested inside
38
- // another repo would record the ENCLOSING repo's origin.
39
- const originUrl = (await isGitRepositoryAtRoot(root.path)) ? await gitOriginUrl(root.path) : null;
39
+ // another repo would record the ENCLOSING repo's origin (and drift).
40
+ const isRepo = await isGitRepositoryAtRoot(root.path);
41
+ const [originUrl, drift] = isRepo
42
+ ? await Promise.all([gitOriginUrl(root.path), gitTrackingDrift(root.path)])
43
+ : [null, null];
40
44
  input.storeFacts = {
41
45
  id: root.storeId,
42
46
  metadataPresent: metadata !== null,
43
47
  metadataValid: metadata !== null,
44
48
  ...(metadata?.remote ? { canonicalRemote: metadata.remote } : {}),
45
49
  ...(originUrl ? { originUrl } : {}),
50
+ ...(drift ? { drift } : {}),
46
51
  };
47
52
  }
48
53
  // The 3.2 both-shapes wrong turn, structured — including a malformed