@fission-ai/openspec 1.6.0-beta.1 → 1.7.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 (175) hide show
  1. package/README.md +28 -1
  2. package/dist/cli/index.js +73 -7
  3. package/dist/commands/change.d.ts +0 -1
  4. package/dist/commands/change.js +90 -50
  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 +21 -11
  9. package/dist/commands/show.js +5 -2
  10. package/dist/commands/spec.js +20 -24
  11. package/dist/commands/validate.js +18 -3
  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 +89 -6
  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 +7 -2
  19. package/dist/core/archive.d.ts +0 -1
  20. package/dist/core/archive.js +96 -33
  21. package/dist/core/artifact-graph/graph.d.ts +16 -0
  22. package/dist/core/artifact-graph/graph.js +30 -6
  23. package/dist/core/artifact-graph/instruction-loader.d.ts +27 -2
  24. package/dist/core/artifact-graph/instruction-loader.js +55 -7
  25. package/dist/core/artifact-graph/resolver.d.ts +14 -0
  26. package/dist/core/artifact-graph/resolver.js +34 -6
  27. package/dist/core/change-metadata/schema.d.ts +1 -0
  28. package/dist/core/change-metadata/schema.js +6 -0
  29. package/dist/core/change-status-policy.d.ts +1 -1
  30. package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
  31. package/dist/core/command-generation/adapters/amazon-q.js +7 -1
  32. package/dist/core/command-generation/adapters/antigravity.js +2 -1
  33. package/dist/core/command-generation/adapters/auggie.js +2 -1
  34. package/dist/core/command-generation/adapters/bob.d.ts +5 -1
  35. package/dist/core/command-generation/adapters/bob.js +6 -5
  36. package/dist/core/command-generation/adapters/claude.js +1 -8
  37. package/dist/core/command-generation/adapters/codebuddy.js +3 -2
  38. package/dist/core/command-generation/adapters/continue.js +3 -2
  39. package/dist/core/command-generation/adapters/costrict.js +2 -1
  40. package/dist/core/command-generation/adapters/crush.js +5 -5
  41. package/dist/core/command-generation/adapters/cursor.js +2 -2
  42. package/dist/core/command-generation/adapters/devin.d.ts +19 -0
  43. package/dist/core/command-generation/adapters/devin.js +36 -0
  44. package/dist/core/command-generation/adapters/factory.js +2 -1
  45. package/dist/core/command-generation/adapters/gemini.js +37 -2
  46. package/dist/core/command-generation/adapters/github-copilot.js +2 -1
  47. package/dist/core/command-generation/adapters/iflow.js +5 -4
  48. package/dist/core/command-generation/adapters/index.d.ts +2 -2
  49. package/dist/core/command-generation/adapters/index.js +2 -2
  50. package/dist/core/command-generation/adapters/junie.js +2 -1
  51. package/dist/core/command-generation/adapters/kiro.js +2 -1
  52. package/dist/core/command-generation/adapters/lingma.js +5 -5
  53. package/dist/core/command-generation/adapters/oh-my-pi.d.ts +2 -2
  54. package/dist/core/command-generation/adapters/oh-my-pi.js +3 -5
  55. package/dist/core/command-generation/adapters/opencode.js +3 -5
  56. package/dist/core/command-generation/adapters/pi.d.ts +2 -2
  57. package/dist/core/command-generation/adapters/pi.js +3 -6
  58. package/dist/core/command-generation/adapters/qoder.js +5 -5
  59. package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
  60. package/dist/core/command-generation/adapters/qwen.js +12 -7
  61. package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
  62. package/dist/core/command-generation/adapters/roocode.js +4 -4
  63. package/dist/core/command-generation/adapters/trae.js +1 -21
  64. package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
  65. package/dist/core/command-generation/adapters/zcode.js +33 -0
  66. package/dist/core/command-generation/generator.d.ts +8 -0
  67. package/dist/core/command-generation/generator.js +15 -1
  68. package/dist/core/command-generation/index.d.ts +1 -1
  69. package/dist/core/command-generation/index.js +1 -1
  70. package/dist/core/command-generation/invocation.d.ts +73 -0
  71. package/dist/core/command-generation/invocation.js +77 -0
  72. package/dist/core/command-generation/registry.js +4 -4
  73. package/dist/core/command-generation/types.d.ts +9 -2
  74. package/dist/core/command-generation/yaml.d.ts +20 -6
  75. package/dist/core/command-generation/yaml.js +31 -21
  76. package/dist/core/command-surface.d.ts +15 -0
  77. package/dist/core/command-surface.js +33 -0
  78. package/dist/core/completions/command-registry.js +8 -2
  79. package/dist/core/completions/generators/powershell-generator.js +15 -8
  80. package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
  81. package/dist/core/completions/installers/zsh-installer.js +20 -4
  82. package/dist/core/config-prompts.d.ts +1 -1
  83. package/dist/core/config-prompts.js +14 -1
  84. package/dist/core/config-schema.d.ts +7 -1
  85. package/dist/core/config-schema.js +40 -1
  86. package/dist/core/config.d.ts +12 -0
  87. package/dist/core/config.js +21 -3
  88. package/dist/core/global-config.d.ts +5 -0
  89. package/dist/core/init.d.ts +35 -0
  90. package/dist/core/init.js +225 -39
  91. package/dist/core/legacy-cleanup.d.ts +59 -0
  92. package/dist/core/legacy-cleanup.js +249 -22
  93. package/dist/core/list.js +5 -7
  94. package/dist/core/migration.d.ts +71 -1
  95. package/dist/core/migration.js +294 -1
  96. package/dist/core/onboarding-commands.d.ts +30 -0
  97. package/dist/core/onboarding-commands.js +38 -0
  98. package/dist/core/parsers/change-parser.d.ts +17 -2
  99. package/dist/core/parsers/change-parser.js +33 -20
  100. package/dist/core/parsers/code-fence.d.ts +15 -0
  101. package/dist/core/parsers/code-fence.js +49 -0
  102. package/dist/core/parsers/markdown-parser.js +2 -1
  103. package/dist/core/parsers/requirement-blocks.d.ts +8 -0
  104. package/dist/core/parsers/requirement-blocks.js +60 -35
  105. package/dist/core/parsers/requirement-text.d.ts +1 -8
  106. package/dist/core/parsers/requirement-text.js +4 -42
  107. package/dist/core/parsers/spec-structure.js +3 -29
  108. package/dist/core/profile-sync-drift.d.ts +0 -8
  109. package/dist/core/profile-sync-drift.js +9 -45
  110. package/dist/core/project-config.d.ts +25 -6
  111. package/dist/core/project-config.js +77 -7
  112. package/dist/core/references.js +42 -4
  113. package/dist/core/relationship-health.d.ts +8 -0
  114. package/dist/core/relationship-health.js +13 -0
  115. package/dist/core/root-selection.d.ts +9 -6
  116. package/dist/core/root-selection.js +42 -5
  117. package/dist/core/shared/tool-detection.d.ts +25 -5
  118. package/dist/core/shared/tool-detection.js +105 -6
  119. package/dist/core/specs-apply.d.ts +8 -33
  120. package/dist/core/specs-apply.js +252 -157
  121. package/dist/core/store/git.d.ts +13 -0
  122. package/dist/core/store/git.js +24 -0
  123. package/dist/core/templates/workflows/apply-change.js +54 -4
  124. package/dist/core/templates/workflows/archive-change.js +166 -42
  125. package/dist/core/templates/workflows/bulk-archive-change.js +240 -63
  126. package/dist/core/templates/workflows/continue-change.js +32 -40
  127. package/dist/core/templates/workflows/explore.js +12 -0
  128. package/dist/core/templates/workflows/ff-change.js +46 -30
  129. package/dist/core/templates/workflows/new-change.js +2 -2
  130. package/dist/core/templates/workflows/onboard.js +4 -4
  131. package/dist/core/templates/workflows/propose.js +50 -32
  132. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  133. package/dist/core/templates/workflows/store-selection.js +1 -1
  134. package/dist/core/templates/workflows/sync-specs.js +172 -22
  135. package/dist/core/templates/workflows/update-change.js +20 -12
  136. package/dist/core/templates/workflows/verify-change.js +16 -10
  137. package/dist/core/update.d.ts +39 -5
  138. package/dist/core/update.js +288 -71
  139. package/dist/core/validation/constants.d.ts +4 -1
  140. package/dist/core/validation/constants.js +4 -1
  141. package/dist/core/validation/validator.d.ts +1 -7
  142. package/dist/core/validation/validator.js +92 -36
  143. package/dist/core/version-check.d.ts +152 -0
  144. package/dist/core/version-check.js +736 -0
  145. package/dist/core/view.js +12 -17
  146. package/dist/prompts/searchable-multi-select.js +1 -1
  147. package/dist/telemetry/index.d.ts +1 -1
  148. package/dist/telemetry/index.js +55 -33
  149. package/dist/ui/welcome-screen.d.ts +9 -1
  150. package/dist/ui/welcome-screen.js +91 -38
  151. package/dist/utils/change-metadata.d.ts +30 -0
  152. package/dist/utils/change-metadata.js +101 -7
  153. package/dist/utils/change-utils.d.ts +10 -4
  154. package/dist/utils/change-utils.js +20 -13
  155. package/dist/utils/command-references.d.ts +80 -6
  156. package/dist/utils/command-references.js +138 -7
  157. package/dist/utils/date.d.ts +8 -0
  158. package/dist/utils/date.js +13 -0
  159. package/dist/utils/index.d.ts +1 -1
  160. package/dist/utils/index.js +1 -1
  161. package/dist/utils/item-discovery.d.ts +19 -0
  162. package/dist/utils/item-discovery.js +30 -48
  163. package/dist/utils/shell-detection.d.ts +1 -1
  164. package/dist/utils/shell-detection.js +64 -11
  165. package/dist/utils/spec-discovery.d.ts +40 -0
  166. package/dist/utils/spec-discovery.js +102 -0
  167. package/package.json +29 -30
  168. package/schemas/spec-driven/schema.yaml +60 -5
  169. package/schemas/spec-driven/templates/design.md +2 -2
  170. package/schemas/spec-driven/templates/proposal.md +5 -1
  171. package/schemas/spec-driven/templates/spec.md +3 -0
  172. package/dist/core/command-generation/adapters/codex.d.ts +0 -16
  173. package/dist/core/command-generation/adapters/codex.js +0 -39
  174. package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
  175. 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
 
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,
@@ -96,13 +98,17 @@ program.hook('postAction', async () => {
96
98
  await shutdown();
97
99
  });
98
100
  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(', ')}`;
101
+ const toolAliasNote = Object.entries(TOOL_ID_ALIASES)
102
+ .map(([retired, current]) => `${retired} (now ${current})`)
103
+ .join(', ');
104
+ const toolsOptionDescription = `Configure AI tools non-interactively. Use "all", "none", or a comma-separated list of: ${availableToolIds.join(', ')}. Also accepted: ${toolAliasNote}`;
100
105
  program
101
106
  .command('init [path]')
102
107
  .description('Initialize OpenSpec in your project')
103
108
  .option('--tools <tools>', toolsOptionDescription)
104
109
  .option('--force', 'Auto-cleanup legacy files without prompting')
105
110
  .option('--profile <profile>', 'Override global config profile (core or custom)')
111
+ .option('--no-animation', 'Show a static welcome screen instead of the animated one')
106
112
  .action(async (targetPath = '.', options) => {
107
113
  try {
108
114
  // Validate that the path is a valid directory
@@ -130,6 +136,7 @@ program
130
136
  tools: options?.tools,
131
137
  force: options?.force,
132
138
  profile: options?.profile,
139
+ animation: options?.animation,
133
140
  });
134
141
  await initCommand.execute(targetPath);
135
142
  }
@@ -165,8 +172,55 @@ program
165
172
  .option('--force', 'Force update even when tools are up to date')
166
173
  .action(async (targetPath = '.', options) => {
167
174
  try {
175
+ const installDir = getInstallDir();
176
+ // Running from a clone: the version is whatever the branch says, so any
177
+ // upgrade advice would be noise. Decided before the request, so a
178
+ // contributor never waits on an answer that gets thrown away.
179
+ const latestVersion = isSourceCheckout(installDir) ? null : await getAvailableCliUpdate();
180
+ const announce = latestVersion !== null;
181
+ // Offer to upgrade first: this process generates files from its own
182
+ // templates, so upgrading afterwards would leave the old ones on disk.
183
+ // Both streams must be a terminal — with stdout redirected the question
184
+ // lands in the file and the user waits at a blank screen forever.
185
+ const canOffer = announce &&
186
+ shouldOfferUpgrade({
187
+ installDir,
188
+ projectPath: targetPath,
189
+ interactive: isInteractive(),
190
+ stdoutIsTty: Boolean(process.stdout.isTTY),
191
+ });
192
+ let declined = false;
193
+ if (latestVersion && canOffer) {
194
+ displayCliUpdateNote(latestVersion, targetPath, { withCommand: false });
195
+ const outcome = await offerCliUpgrade(latestVersion);
196
+ // Set the code and return rather than process.exit: exiting here would
197
+ // skip commander's postAction hook, killing the telemetry flush
198
+ // mid-request.
199
+ if (outcome === 'cancelled') {
200
+ // Ctrl-C means stop the command, not fall through to more prompts.
201
+ process.exitCode = 130;
202
+ return;
203
+ }
204
+ if (outcome === 'upgraded') {
205
+ process.exitCode = await rerunUpdateWithUpgradedCli(targetPath, {
206
+ force: options?.force,
207
+ });
208
+ return;
209
+ }
210
+ // Declined, failed, or upgraded-but-unreachable: fall through to the
211
+ // update, then leave the command on screen underneath it.
212
+ declined = true;
213
+ }
168
214
  const updateCommand = new UpdateCommand({ force: options?.force });
169
215
  await updateCommand.execute(targetPath);
216
+ if (declined) {
217
+ // The headline was printed before the prompt; only the manual route is
218
+ // still owed, and it belongs where the user is looking now.
219
+ displayUpgradeCommand(targetPath);
220
+ }
221
+ else if (latestVersion) {
222
+ displayCliUpdateNote(latestVersion, targetPath);
223
+ }
170
224
  }
171
225
  catch (error) {
172
226
  failWithError(error);
@@ -212,10 +266,19 @@ program
212
266
  program
213
267
  .command('view')
214
268
  .description('Display an interactive dashboard of specs and changes')
215
- .action(async () => {
269
+ .option('--store <id>', STORE_OPTION_DESCRIPTION)
270
+ .addOption(hiddenStorePathOption())
271
+ .action(async (options) => {
216
272
  try {
273
+ // Implicit cwd fallback stays enabled so `view` keeps accepting the same
274
+ // directories as `list`/`status` — notably pre-config.yaml `openspec/`
275
+ // dirs. ViewCommand still reports a missing openspec/ directory itself.
276
+ const root = await resolveRootForCommand(options ?? {});
277
+ if (!root) {
278
+ return;
279
+ }
217
280
  const viewCommand = new ViewCommand();
218
- await viewCommand.execute('.');
281
+ await viewCommand.execute(root.path);
219
282
  }
220
283
  catch (error) {
221
284
  failWithError(error);
@@ -460,7 +523,7 @@ program
460
523
  // Instructions command
461
524
  program
462
525
  .command('instructions [artifact]')
463
- .description('Output enriched instructions for creating an artifact or applying tasks')
526
+ .description('Output enriched instructions for artifacts, apply, or archive')
464
527
  .option('--change <id>', 'Change name')
465
528
  .option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
466
529
  .option('--json', 'Output as JSON')
@@ -468,10 +531,13 @@ program
468
531
  .addOption(hiddenStorePathOption())
469
532
  .action(async (artifactId, options) => {
470
533
  try {
471
- // Special case: "apply" is not an artifact, but a command to get apply instructions
534
+ // Workflow instruction surfaces are reserved command branches, not artifacts.
472
535
  if (artifactId === 'apply') {
473
536
  await applyInstructionsCommand(options);
474
537
  }
538
+ else if (artifactId === 'archive') {
539
+ await archiveInstructionsCommand(options);
540
+ }
475
541
  else {
476
542
  await instructionsCommand(artifactId, options);
477
543
  }
@@ -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,30 @@ 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
+ /**
11
+ * True only when `target` is definitively absent. An EACCES or I/O failure
12
+ * means existence cannot be determined, so callers fall through to their
13
+ * read-error path rather than claim the file was never written.
14
+ */
15
+ async function isDefinitelyMissing(target) {
16
+ return fs
17
+ .access(target)
18
+ .then(() => false)
19
+ .catch((error) => error?.code === 'ENOENT');
20
+ }
21
+ /**
22
+ * A change is a directory directly under changes/. Rejecting anything else up
23
+ * front keeps a traversing name (`../..`) from reading a proposal outside the
24
+ * changes directory, and keeps the missing-proposal message honest.
25
+ */
26
+ function isChangeDirectoryName(changesPath, changeDir) {
27
+ return path.dirname(path.resolve(changeDir)) === path.resolve(changesPath);
28
+ }
11
29
  export class ChangeCommand {
12
30
  converter;
13
31
  rootPath;
@@ -30,7 +48,8 @@ export class ChangeCommand {
30
48
  const changesPath = this.getChangesPath();
31
49
  if (!changeName) {
32
50
  const canPrompt = isInteractive(options);
33
- const changes = await this.getActiveChanges(changesPath);
51
+ // Offer exactly the changes `show <name>` can resolve.
52
+ const changes = await getActiveChangeIds(this.rootPath ?? process.cwd());
34
53
  if (canPrompt && changes.length > 0) {
35
54
  const { select } = await import('@inquirer/prompts');
36
55
  const selected = await select({
@@ -51,11 +70,29 @@ export class ChangeCommand {
51
70
  return;
52
71
  }
53
72
  }
54
- const proposalPath = path.join(changesPath, changeName, 'proposal.md');
73
+ const changeDir = path.join(changesPath, changeName);
74
+ const proposalPath = path.join(changeDir, 'proposal.md');
75
+ if (!isChangeDirectoryName(changesPath, changeDir)) {
76
+ throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
77
+ }
55
78
  try {
56
79
  await fs.access(proposalPath);
57
80
  }
58
81
  catch {
82
+ // A change can exist without a proposal: `openspec new change` scaffolds
83
+ // only .openspec.yaml, and a custom schema need not define a proposal
84
+ // artifact. Say which of the two cases this is instead of reporting a
85
+ // change that does exist as missing. A stray file under changes/ is not a
86
+ // change, and naming it one would point the user at a `status --change`
87
+ // call that cannot work.
88
+ const isChangeDirectory = await fs
89
+ .stat(changeDir)
90
+ .then((stats) => stats.isDirectory())
91
+ .catch(() => false);
92
+ if (isChangeDirectory) {
93
+ throw new Error(`Change "${changeName}" has no proposal.md yet. ` +
94
+ `Run "openspec status --change ${changeName}" to see which artifact comes next.`);
95
+ }
59
96
  throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
60
97
  }
61
98
  if (options?.json) {
@@ -89,18 +126,30 @@ export class ChangeCommand {
89
126
  */
90
127
  async list(options) {
91
128
  const changesPath = path.join(process.cwd(), 'openspec', 'changes');
92
- const changes = await this.getActiveChanges(changesPath);
129
+ // Same directory-based resolution as `openspec list`, the command this
130
+ // deprecated alias points users at. Every output path below already
131
+ // tolerates a change whose proposal.md is missing or unreadable.
132
+ const changes = await getActiveChangeIds();
93
133
  if (options?.json) {
94
134
  const changeDetails = await Promise.all(changes.map(async (changeName) => {
95
- const proposalPath = path.join(changesPath, changeName, 'proposal.md');
135
+ const changeDir = path.join(changesPath, changeName);
136
+ const proposalPath = path.join(changeDir, 'proposal.md');
137
+ // Resolve task progress through the shared tracked-tasks helper so
138
+ // this deprecated noun-form list cannot re-fork the resolution
139
+ // (#1202). Tasks are independent of the proposal: a change can carry
140
+ // tasks before, or without, a proposal.md.
141
+ const taskStatus = await getTaskProgressForChange(changesPath, changeName, process.cwd());
142
+ // No proposal yet is an ordinary state (scaffolded change, or a
143
+ // schema with no proposal artifact), so name the change rather than
144
+ // labelling it Unknown. Unknown stays for a proposal that exists but
145
+ // cannot be read or parsed.
146
+ if (await isDefinitelyMissing(proposalPath)) {
147
+ return { id: changeName, title: changeName, deltaCount: 0, taskStatus };
148
+ }
96
149
  try {
97
150
  const content = await fs.readFile(proposalPath, 'utf-8');
98
- const changeDir = path.join(changesPath, changeName);
99
151
  const parser = new ChangeParser(content, changeDir);
100
152
  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
153
  return {
105
154
  id: changeName,
106
155
  title: this.extractTitle(content, changeName),
@@ -108,13 +157,8 @@ export class ChangeCommand {
108
157
  taskStatus,
109
158
  };
110
159
  }
111
- catch (error) {
112
- return {
113
- id: changeName,
114
- title: 'Unknown',
115
- deltaCount: 0,
116
- taskStatus: { total: 0, completed: 0 },
117
- };
160
+ catch {
161
+ return { id: changeName, title: 'Unknown', deltaCount: 0, taskStatus };
118
162
  }
119
163
  }));
120
164
  const sorted = changeDetails.sort((a, b) => a.id.localeCompare(b.id));
@@ -133,20 +177,24 @@ export class ChangeCommand {
133
177
  }
134
178
  // Long format: id: title and minimal counts
135
179
  for (const changeName of sorted) {
136
- const proposalPath = path.join(changesPath, changeName, 'proposal.md');
180
+ const changeDir = path.join(changesPath, changeName);
181
+ const proposalPath = path.join(changeDir, 'proposal.md');
182
+ const { total, completed } = await getTaskProgressForChange(changesPath, changeName, process.cwd());
183
+ const taskStatusText = total > 0 ? ` [tasks ${completed}/${total}]` : '';
184
+ if (await isDefinitelyMissing(proposalPath)) {
185
+ console.log(`${changeName}: (no proposal.md yet)${taskStatusText}`);
186
+ continue;
187
+ }
137
188
  try {
138
189
  const content = await fs.readFile(proposalPath, 'utf-8');
139
190
  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);
191
+ const parser = new ChangeParser(content, changeDir);
144
192
  const change = await parser.parseChangeWithDeltas(changeName);
145
193
  const deltaCountText = ` [deltas ${change.deltas.length}]`;
146
194
  console.log(`${changeName}: ${title}${deltaCountText}${taskStatusText}`);
147
195
  }
148
196
  catch {
149
- console.log(`${changeName}: (unable to read)`);
197
+ console.log(`${changeName}: (unable to read)${taskStatusText}`);
150
198
  }
151
199
  }
152
200
  }
@@ -200,44 +248,36 @@ export class ChangeCommand {
200
248
  console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
201
249
  });
202
250
  // Next steps footer to guide fixing issues
203
- this.printNextSteps();
251
+ this.printNextSteps(report.issues);
204
252
  if (!options?.json) {
205
253
  process.exitCode = 1;
206
254
  }
207
255
  }
208
256
  }
209
257
  }
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
258
  extractTitle(content, changeName) {
233
259
  const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im);
234
260
  return match ? match[1].trim() : changeName;
235
261
  }
236
- printNextSteps() {
262
+ printNextSteps(issues = []) {
237
263
  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');
264
+ // Branch on the exact marker messages: the generic no-deltas guidance
265
+ // also mentions skip_specs and must not trigger the marker bullets.
266
+ const conflictIssue = issues.some(i => i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT));
267
+ const invalidMarkerIssue = issues.some(i => i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA));
268
+ if (conflictIssue) {
269
+ 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');
270
+ bullets.push('- skip_specs is only honored when .openspec.yaml is valid change metadata (schema: <name> is required)');
271
+ }
272
+ else if (invalidMarkerIssue) {
273
+ bullets.push('- Fix .openspec.yaml so the skip_specs marker can be honored (schema: <name> is required)');
274
+ bullets.push('- Or remove skip_specs from .openspec.yaml and add delta specs instead');
275
+ }
276
+ else {
277
+ bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements');
278
+ bullets.push('- Each requirement MUST include at least one #### Scenario: block');
279
+ bullets.push('- Debug parsed deltas: openspec change show <id> --json --deltas-only');
280
+ }
241
281
  console.error('Next steps:');
242
282
  bullets.forEach(b => console.error(` ${b}`));
243
283
  }
@@ -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
@@ -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
  /**