@fission-ai/openspec 1.6.0 → 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 +14 -12
  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
@@ -10,20 +10,21 @@ import ora from 'ora';
10
10
  import * as fs from 'fs';
11
11
  import { createRequire } from 'module';
12
12
  import { FileSystemUtils } from '../utils/file-system.js';
13
- import { transformToHyphenCommands } from '../utils/command-references.js';
13
+ import { getSkillReferenceTransformer, getTransformerForTool, transformToSkillReferences } from '../utils/command-references.js';
14
14
  import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
15
15
  import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js';
16
16
  import { getToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, } from './shared/index.js';
17
- import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatCleanupSummary, formatDetectionSummary, getToolsFromLegacyArtifacts, } from './legacy-cleanup.js';
17
+ import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatDeferredGlobalPromptSummary, formatCleanupSummary, formatDetectionSummary, getLegacyGlobalPromptMatches, getLegacyWorkflowIdsForTool, getToolsFromLegacyArtifacts, omitGlobalLegacyPromptFiles, pickGlobalLegacyPromptFiles, } from './legacy-cleanup.js';
18
18
  import { isInteractive } from '../utils/interactive.js';
19
19
  import { getGlobalConfig } from './global-config.js';
20
- import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js';
20
+ import { getProfileWorkflows, ALL_WORKFLOWS, CORE_WORKFLOWS } from './profiles.js';
21
+ import { getOnboardingCommands } from './onboarding-commands.js';
21
22
  import { getAvailableTools } from './available-tools.js';
22
- import { WORKFLOW_TO_SKILL_DIR, getCommandConfiguredTools, getConfiguredToolsForProfileSync, getToolsNeedingProfileSync, } from './profile-sync-drift.js';
23
- import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, } from './migration.js';
23
+ import { WORKFLOW_TO_SKILL_DIR, getConfiguredToolsForProfileSync, getToolsNeedingProfileSync, } from './profile-sync-drift.js';
24
+ import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, findLegacyToolMigrations, migrateLegacyToolDirs, describeLegacyMigration, legacyMigrationNotice, keptInPlaceNotice, hasMovableContent, } from './migration.js';
25
+ import { resolveCommandSurfaceCapability, resolveCommandInvocation, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
24
26
  const require = createRequire(import.meta.url);
25
27
  const { version: OPENSPEC_VERSION } = require('../../package.json');
26
- const OLD_CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'archive'];
27
28
  /**
28
29
  * Scans installed workflow artifacts (skills and managed commands) across all configured tools.
29
30
  * Returns the union of detected workflow IDs that match ALL_WORKFLOWS.
@@ -41,6 +42,12 @@ export class UpdateCommand {
41
42
  constructor(options = {}) {
42
43
  this.force = options.force ?? false;
43
44
  }
45
+ /**
46
+ * Refreshes OpenSpec skills and commands for all configured tools,
47
+ * regenerating artifacts according to the effective profile and delivery mode.
48
+ *
49
+ * @param projectPath - Path to the project root containing the openspec directory
50
+ */
44
51
  async execute(projectPath) {
45
52
  const resolvedProjectPath = path.resolve(projectPath);
46
53
  const openspecPath = path.join(resolvedProjectPath, OPENSPEC_DIR_NAME);
@@ -48,7 +55,17 @@ export class UpdateCommand {
48
55
  if (!await FileSystemUtils.directoryExists(openspecPath)) {
49
56
  throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`);
50
57
  }
51
- // 2. Perform one-time migration if needed before any legacy upgrade generation.
58
+ // 2. Migrate OpenSpec-managed skills left in renamed tool directories
59
+ // (e.g. .kimi -> .kimi-code) so they stay detected and get refreshed,
60
+ // then perform the one-time profile migration if needed before any
61
+ // legacy upgrade generation.
62
+ for (const migration of migrateLegacyToolDirs(resolvedProjectPath)) {
63
+ if (hasMovableContent(migration)) {
64
+ console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
65
+ }
66
+ this.reportKeptInPlace(migration);
67
+ }
68
+ const declinedMigrations = await this.offerConsentedLegacyMigrations(resolvedProjectPath);
52
69
  // Use detected tool directories to preserve existing opsx skills/commands.
53
70
  const detectedTools = getAvailableTools(resolvedProjectPath);
54
71
  migrateIfNeededShared(resolvedProjectPath, detectedTools);
@@ -58,27 +75,35 @@ export class UpdateCommand {
58
75
  const delivery = globalConfig.delivery ?? 'both';
59
76
  const profileWorkflows = getProfileWorkflows(profile, globalConfig.workflows);
60
77
  const desiredWorkflows = profileWorkflows.filter((workflow) => ALL_WORKFLOWS.includes(workflow));
61
- const shouldGenerateSkills = delivery !== 'commands';
62
- const shouldGenerateCommands = delivery !== 'skills';
63
78
  // 4. Detect and handle legacy artifacts + upgrade legacy tools using effective config
64
- const newlyConfiguredTools = await this.handleLegacyCleanup(resolvedProjectPath, desiredWorkflows, delivery);
79
+ const legacyUpgrade = await this.handleLegacyCleanup(resolvedProjectPath, desiredWorkflows, delivery);
80
+ const { newlyConfiguredTools, workflowOverrides: legacyWorkflowOverrides, deferredGlobalCleanup, } = legacyUpgrade;
65
81
  // 5. Find configured tools
66
82
  const configuredTools = getConfiguredToolsForProfileSync(resolvedProjectPath);
67
83
  if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) {
84
+ if (deferredGlobalCleanup) {
85
+ await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup);
86
+ }
87
+ if (declinedMigrations.length > 0) {
88
+ // Not an unconfigured project — a configured one the user chose to
89
+ // leave in its former directory. Saying "run init" would be wrong.
90
+ for (const migration of declinedMigrations) {
91
+ console.log(chalk.yellow(`Nothing to update: this project's OpenSpec files are still in ${migration.from}/, ` +
92
+ `which OpenSpec no longer writes.`));
93
+ console.log(chalk.dim(`Re-run "openspec update" and accept the move to ${migration.to}/ to resume updates.`));
94
+ }
95
+ return;
96
+ }
68
97
  console.log(chalk.yellow('No configured tools found.'));
69
98
  console.log(chalk.dim('Run "openspec init" to set up tools.'));
70
99
  return;
71
100
  }
72
- // 6. Check version status for all configured tools
73
- const commandConfiguredTools = getCommandConfiguredTools(resolvedProjectPath);
74
- const commandConfiguredSet = new Set(commandConfiguredTools);
75
- const toolStatuses = configuredTools.map((toolId) => {
76
- const status = getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION);
77
- if (!status.configured && commandConfiguredSet.has(toolId)) {
78
- return { ...status, configured: true };
79
- }
80
- return status;
81
- });
101
+ // 6. Check version status for all configured tools, against the same workflow set
102
+ // the generation loop below writes — otherwise a legacy-upgraded tool would be
103
+ // fingerprinted against commands it was never given.
104
+ const toolStatuses = configuredTools.map((toolId) => getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION, {
105
+ workflows: legacyWorkflowOverrides[toolId] ?? desiredWorkflows,
106
+ }));
82
107
  const statusByTool = new Map(toolStatuses.map((status) => [status.toolId, status]));
83
108
  // 7. Smart update detection
84
109
  const toolsNeedingVersionUpdate = toolStatuses
@@ -90,30 +115,38 @@ export class UpdateCommand {
90
115
  ...toolsNeedingConfigSync,
91
116
  ]);
92
117
  const toolsUpToDate = toolStatuses.filter((s) => !toolsToUpdateSet.has(s.toolId));
93
- if (!this.force && toolsToUpdateSet.size === 0) {
118
+ if (!this.force && toolsToUpdateSet.size === 0 && newlyConfiguredTools.length === 0) {
119
+ if (deferredGlobalCleanup) {
120
+ await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup);
121
+ }
94
122
  // All tools are up to date
95
123
  this.displayUpToDateMessage(toolStatuses);
96
124
  // Still check for new tool directories and extra workflows
97
125
  this.detectNewTools(resolvedProjectPath, configuredTools);
98
126
  this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows);
99
- this.displayOldCoreCustomProfileNote(profile, globalConfig.workflows);
127
+ this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
128
+ this.displaySetupNotes(configuredTools);
100
129
  return;
101
130
  }
102
131
  // 8. Display update plan
103
132
  if (this.force) {
104
133
  console.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`);
105
134
  }
135
+ else if (toolsToUpdateSet.size === 0) {
136
+ console.log('No additional refresh needed after legacy migration.');
137
+ }
106
138
  else {
107
139
  this.displayUpdatePlan([...toolsToUpdateSet], statusByTool, toolsUpToDate);
108
140
  }
109
141
  console.log();
110
142
  // 9. Determine what to generate based on delivery
111
- const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : [];
112
- const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : [];
143
+ const deliveryIncludesCommands = delivery !== 'skills';
113
144
  // 10. Update tools (all if force, otherwise only those needing update)
114
145
  const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet];
115
146
  const updatedTools = [];
116
147
  const failedTools = [];
148
+ const skillsInvocableCommandSkips = [];
149
+ const zeroArtifactTools = [];
117
150
  let removedCommandCount = 0;
118
151
  let removedSkillCount = 0;
119
152
  let removedDeselectedCommandCount = 0;
@@ -125,21 +158,32 @@ export class UpdateCommand {
125
158
  const spinner = ora(`Updating ${tool.name}...`).start();
126
159
  try {
127
160
  const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills');
161
+ const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
162
+ const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
163
+ const toolWorkflows = legacyWorkflowOverrides[tool.value] ?? desiredWorkflows;
164
+ const skillTemplates = getSkillTemplates(toolWorkflows);
165
+ const commandContents = getCommandContents(toolWorkflows);
128
166
  // Generate skill files if delivery includes skills
129
167
  if (shouldGenerateSkills) {
130
168
  for (const { template, dirName } of skillTemplates) {
131
169
  const skillDir = path.join(skillsDir, dirName);
132
170
  const skillFile = path.join(skillDir, 'SKILL.md');
133
- // Use hyphen-based command references for tools where filename === command name (oh-my-pi, opencode, pi)
134
- const transformer = (tool.value === 'opencode' || tool.value === 'pi' || tool.value === 'oh-my-pi') ? transformToHyphenCommands : undefined;
171
+ const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value));
135
172
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
136
173
  await FileSystemUtils.writeFile(skillFile, skillContent);
137
174
  }
138
- removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows);
175
+ removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, toolWorkflows);
139
176
  }
140
177
  // Delete skill directories if delivery is commands-only
141
- if (!shouldGenerateSkills) {
178
+ if (shouldRemoveSkillsForTool(tool.value, delivery)) {
142
179
  removedSkillCount += await this.removeSkillDirs(skillsDir);
180
+ // A tool with no command adapter now has zero OpenSpec artifacts;
181
+ // say so like init does, rather than deleting its skills silently
182
+ // and letting tool detection re-suggest an init that would also
183
+ // generate nothing under this delivery setting.
184
+ if (!shouldGenerateCommandsForTool(tool.value, delivery)) {
185
+ zeroArtifactTools.push(tool.name);
186
+ }
143
187
  }
144
188
  // Generate commands if delivery includes commands
145
189
  if (shouldGenerateCommands) {
@@ -150,11 +194,14 @@ export class UpdateCommand {
150
194
  const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path);
151
195
  await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
152
196
  }
153
- removedDeselectedCommandCount += await this.removeUnselectedCommandFiles(resolvedProjectPath, toolId, desiredWorkflows);
197
+ removedDeselectedCommandCount += await this.removeUnselectedCommandFiles(resolvedProjectPath, toolId, toolWorkflows);
154
198
  }
155
199
  }
200
+ else if (deliveryIncludesCommands && resolveCommandSurfaceCapability(tool.value) === 'skills-invocable') {
201
+ skillsInvocableCommandSkips.push(tool.value);
202
+ }
156
203
  // Delete command files if delivery is skills-only
157
- if (!shouldGenerateCommands) {
204
+ if (shouldReconcileCommandFilesForTool(tool.value, delivery)) {
158
205
  removedCommandCount += await this.removeCommandFiles(resolvedProjectPath, toolId);
159
206
  }
160
207
  spinner.succeed(`Updated ${tool.name}`);
@@ -168,6 +215,9 @@ export class UpdateCommand {
168
215
  });
169
216
  }
170
217
  }
218
+ if (deferredGlobalCleanup) {
219
+ await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup);
220
+ }
171
221
  // 11. Summary
172
222
  console.log();
173
223
  if (updatedTools.length > 0) {
@@ -176,26 +226,60 @@ export class UpdateCommand {
176
226
  if (failedTools.length > 0) {
177
227
  console.log(chalk.red(`✗ Failed: ${failedTools.map(f => `${f.name} (${f.error})`).join(', ')}`));
178
228
  }
229
+ if (skillsInvocableCommandSkips.length > 0) {
230
+ console.log(chalk.dim(`Commands skipped for: ${skillsInvocableCommandSkips.join(', ')} (uses skills)`));
231
+ }
179
232
  if (removedCommandCount > 0) {
180
233
  console.log(chalk.dim(`Removed: ${removedCommandCount} command files (delivery: skills)`));
181
234
  }
182
235
  if (removedSkillCount > 0) {
183
236
  console.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (delivery: commands)`));
184
237
  }
238
+ if (zeroArtifactTools.length > 0) {
239
+ const names = zeroArtifactTools.join(', ');
240
+ console.log(chalk.yellow(`No skills or commands remain for ${names}: delivery is set to 'commands' but ` +
241
+ `${zeroArtifactTools.length === 1 ? 'it supports' : 'they support'} only skills. ` +
242
+ `Run 'openspec config set delivery both' to generate skills.`));
243
+ }
185
244
  if (removedDeselectedCommandCount > 0) {
186
245
  console.log(chalk.dim(`Removed: ${removedDeselectedCommandCount} command files (deselected workflows)`));
187
246
  }
188
247
  if (removedDeselectedSkillCount > 0) {
189
248
  console.log(chalk.dim(`Removed: ${removedDeselectedSkillCount} skill directories (deselected workflows)`));
190
249
  }
191
- // 12. Show onboarding message for newly configured tools from legacy upgrade
250
+ // 12. Show onboarding message for newly configured tools from legacy upgrade.
251
+ // Command tools get the command name their files answer to, skill-only
252
+ // tools their documented skill invocation, and disagreements fall back to
253
+ // naming the skill.
192
254
  if (newlyConfiguredTools.length > 0) {
255
+ const referenceFor = (command) => {
256
+ const neutralForm = `the ${transformToSkillReferences(command).slice(1)} skill`;
257
+ const forms = new Set(newlyConfiguredTools.map((toolId) => {
258
+ if (shouldGenerateCommandsForTool(toolId, delivery)) {
259
+ // Name the command the tool's files actually answer to:
260
+ // /opsx-<id> where the filename is the command name.
261
+ const transformer = getTransformerForTool(toolId, delivery, resolveCommandSurfaceCapability(toolId), resolveCommandInvocation(toolId));
262
+ return transformer ? transformer(command) : command;
263
+ }
264
+ return getSkillReferenceTransformer(toolId)(command);
265
+ }));
266
+ return forms.size === 1 ? [...forms][0] : neutralForm;
267
+ };
268
+ // Only hint at workflows these tools actually received. A legacy upgrade
269
+ // can install a narrower set than the profile (inferred Codex prompts).
270
+ const installedWorkflows = [
271
+ ...new Set(newlyConfiguredTools.flatMap((toolId) => legacyWorkflowOverrides[toolId] ?? desiredWorkflows)),
272
+ ];
273
+ const entries = getOnboardingCommands(installedWorkflows).map(({ command, description }) => [referenceFor(command), description]);
193
274
  console.log();
194
- console.log(chalk.bold('Getting started:'));
195
- console.log(' /opsx:new Start a new change');
196
- console.log(' /opsx:continue Create the next artifact');
197
- console.log(' /opsx:apply Implement tasks');
198
- console.log();
275
+ if (entries.length > 0) {
276
+ const width = Math.max(...entries.map(([reference]) => reference.length));
277
+ console.log(chalk.bold('Getting started:'));
278
+ for (const [reference, description] of entries) {
279
+ console.log(` ${reference.padEnd(width)} ${description}`);
280
+ }
281
+ console.log();
282
+ }
199
283
  console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
200
284
  }
201
285
  const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])];
@@ -203,7 +287,8 @@ export class UpdateCommand {
203
287
  this.detectNewTools(resolvedProjectPath, configuredAndNewTools);
204
288
  // 14. Display note about extra workflows not in profile
205
289
  this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows);
206
- this.displayOldCoreCustomProfileNote(profile, globalConfig.workflows);
290
+ this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
291
+ this.displaySetupNotes(configuredAndNewTools);
207
292
  // 15. List affected tools
208
293
  if (updatedTools.length > 0) {
209
294
  const toolDisplayNames = updatedTools;
@@ -240,6 +325,18 @@ export class UpdateCommand {
240
325
  console.log(chalk.dim(`Already up to date: ${upToDateNames.join(', ')}`));
241
326
  }
242
327
  }
328
+ /**
329
+ * Shows manual setup notes for configured tools that need extra
330
+ * configuration before they pick up generated files.
331
+ */
332
+ displaySetupNotes(toolIds) {
333
+ for (const toolId of toolIds) {
334
+ const tool = AI_TOOLS.find((t) => t.value === toolId);
335
+ if (tool?.setupNote) {
336
+ console.log(chalk.yellow(`Setup required for ${tool.name}: ${tool.setupNote}`));
337
+ }
338
+ }
339
+ }
243
340
  /**
244
341
  * Detects new tool directories that aren't currently configured and displays a hint.
245
342
  */
@@ -268,21 +365,23 @@ export class UpdateCommand {
268
365
  }
269
366
  }
270
367
  /**
271
- * Suggest opting back into core when a custom profile still matches the old
272
- * pre-sync core set. Keep custom profiles user-owned; do not mutate them.
368
+ * Point out core workflows a custom profile is missing, so releases that
369
+ * grow CORE_WORKFLOWS stay discoverable. Keep custom profiles user-owned;
370
+ * do not mutate them.
273
371
  */
274
- displayOldCoreCustomProfileNote(profile, workflows) {
372
+ displayMissingCoreWorkflowsNote(profile, workflows) {
275
373
  if (profile !== 'custom' || !workflows) {
276
374
  return;
277
375
  }
278
376
  const workflowSet = new Set(workflows);
279
- const matchesOldCore = workflowSet.size === OLD_CORE_WORKFLOWS.length &&
280
- OLD_CORE_WORKFLOWS.every((workflow) => workflowSet.has(workflow));
281
- if (!matchesOldCore) {
377
+ const missing = CORE_WORKFLOWS.filter((workflow) => !workflowSet.has(workflow));
378
+ if (missing.length === 0) {
282
379
  return;
283
380
  }
284
- console.log(chalk.dim('Note: The core profile now includes sync. Your custom profile is preserving the old core workflow set.'));
285
- console.log(chalk.dim('Run `openspec config profile core` and then `openspec update` to add sync.'));
381
+ const label = missing.length === 1 ? 'workflow' : 'workflows';
382
+ const pronoun = missing.length === 1 ? 'it' : 'them';
383
+ console.log(chalk.dim(`Note: Your custom profile is missing ${missing.length} core ${label}: ${missing.join(', ')}`));
384
+ console.log(chalk.dim(`Run \`openspec config profile\` to add ${pronoun}, or \`openspec config profile core\` to use the core set.`));
286
385
  }
287
386
  /**
288
387
  * Removes skill directories for workflows when delivery changed to commands-only.
@@ -384,6 +483,72 @@ export class UpdateCommand {
384
483
  }
385
484
  return removed;
386
485
  }
486
+ /**
487
+ * Offers to move OpenSpec content out of a renamed tool's former directory
488
+ * when the old location might still be the live one — today, Windsurf's
489
+ * `.windsurf/` after the Devin Desktop rebrand.
490
+ *
491
+ * Interactive runs are asked, because nothing on disk distinguishes a user
492
+ * who took the rebrand from one still on a pre-rebrand Windsurf build that
493
+ * reads only `.windsurf/`. `--force` and non-interactive runs migrate, which
494
+ * is what an unattended upgrade wants.
495
+ */
496
+ /** Surfaces files the move left behind rather than overwriting. */
497
+ reportKeptInPlace(migration) {
498
+ const notice = keptInPlaceNotice(migration);
499
+ if (notice)
500
+ console.log(chalk.dim(notice));
501
+ }
502
+ async offerConsentedLegacyMigrations(projectPath) {
503
+ const pending = findLegacyToolMigrations(projectPath).filter((m) => m.needsConsent);
504
+ const declined = [];
505
+ if (pending.length === 0)
506
+ return declined;
507
+ for (const migration of pending) {
508
+ // Nothing movable: every legacy file differs from its counterpart, so
509
+ // there is no move to offer. Still say so — silence would leave two
510
+ // divergent copies the user never hears about.
511
+ if (!hasMovableContent(migration)) {
512
+ this.reportKeptInPlace(migration);
513
+ console.log();
514
+ continue;
515
+ }
516
+ console.log(chalk.yellow(legacyMigrationNotice(migration)));
517
+ if (!this.force && isInteractive()) {
518
+ const { confirm } = await import('@inquirer/prompts');
519
+ let shouldMigrate;
520
+ try {
521
+ shouldMigrate = await confirm({
522
+ message: `Move ${describeLegacyMigration(migration)} from ${migration.from}/ to ${migration.to}/?`,
523
+ default: true,
524
+ });
525
+ }
526
+ catch {
527
+ // Closed stdin is not consent, and it must not abort the update.
528
+ shouldMigrate = false;
529
+ }
530
+ if (!shouldMigrate) {
531
+ // Say what declining costs. OpenSpec writes the current root now, so
532
+ // the files keep working where they are, but OpenSpec stops managing
533
+ // them — it no longer looks in the former directory.
534
+ console.log(chalk.dim(`Left in place. OpenSpec writes ${migration.to}/ now and will not manage ` +
535
+ `${migration.from}/, so those files stay as they are until you move them. ` +
536
+ `You will be asked again next run.`));
537
+ console.log();
538
+ declined.push(migration);
539
+ continue;
540
+ }
541
+ }
542
+ for (const applied of migrateLegacyToolDirs(projectPath, [migration.toolId])) {
543
+ if (hasMovableContent(applied)) {
544
+ console.log(chalk.dim(`Migrated ${describeLegacyMigration(applied)}: ${applied.from} → ${applied.to}`));
545
+ }
546
+ this.reportKeptInPlace(applied);
547
+ }
548
+ console.log();
549
+ }
550
+ return declined;
551
+ }
387
552
  /**
388
553
  * Detect and handle legacy OpenSpec artifacts.
389
554
  * Unlike init, update warns but continues if legacy files found in non-interactive mode.
@@ -393,25 +558,37 @@ export class UpdateCommand {
393
558
  // Detect legacy artifacts
394
559
  const detection = await detectLegacyArtifacts(projectPath);
395
560
  if (!detection.hasLegacyArtifacts) {
396
- return []; // No legacy artifacts found
561
+ return { newlyConfiguredTools: [], workflowOverrides: {} }; // No legacy artifacts found
397
562
  }
398
563
  // Show what was detected
399
- console.log();
400
- console.log(formatDetectionSummary(detection));
401
- console.log();
564
+ const immediateSummary = formatDetectionSummary(omitGlobalLegacyPromptFiles(detection));
565
+ const deferredSummary = formatDeferredGlobalPromptSummary(detection);
566
+ if (immediateSummary || deferredSummary) {
567
+ console.log();
568
+ if (immediateSummary) {
569
+ console.log(immediateSummary);
570
+ console.log();
571
+ }
572
+ if (deferredSummary) {
573
+ console.log(deferredSummary);
574
+ console.log();
575
+ }
576
+ }
402
577
  const canPrompt = isInteractive();
403
578
  if (this.force) {
404
- // --force flag: proceed with cleanup automatically
405
- await this.performLegacyCleanup(projectPath, detection);
406
- // Then upgrade legacy tools to new skills
407
- return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
579
+ const legacyUpgrade = await this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
580
+ await this.performImmediateLegacyCleanup(projectPath, detection);
581
+ return {
582
+ ...legacyUpgrade,
583
+ deferredGlobalCleanup: pickGlobalLegacyPromptFiles(detection, detection.globalSlashCommandFiles),
584
+ };
408
585
  }
409
586
  if (!canPrompt) {
410
587
  // Non-interactive mode without --force: warn and continue
411
588
  // (Unlike init, update doesn't abort - user may just want to update skills)
412
589
  console.log(chalk.yellow('⚠ Run with --force to auto-cleanup legacy files, or run interactively.'));
413
590
  console.log();
414
- return [];
591
+ return { newlyConfiguredTools: [], workflowOverrides: {} };
415
592
  }
416
593
  // Interactive mode: prompt for confirmation
417
594
  const { confirm } = await import('@inquirer/prompts');
@@ -420,14 +597,47 @@ export class UpdateCommand {
420
597
  default: true,
421
598
  });
422
599
  if (shouldCleanup) {
423
- await this.performLegacyCleanup(projectPath, detection);
424
- // Then upgrade legacy tools to new skills
425
- return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
600
+ const legacyUpgrade = await this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
601
+ await this.performImmediateLegacyCleanup(projectPath, detection);
602
+ return {
603
+ ...legacyUpgrade,
604
+ deferredGlobalCleanup: pickGlobalLegacyPromptFiles(detection, detection.globalSlashCommandFiles),
605
+ };
426
606
  }
427
607
  else {
428
608
  console.log(chalk.dim('Skipping legacy cleanup. Continuing with skill update...'));
429
609
  console.log();
430
- return [];
610
+ return { newlyConfiguredTools: [], workflowOverrides: {} };
611
+ }
612
+ }
613
+ /**
614
+ * Cleans approved repo-local legacy artifacts before configured tools refresh.
615
+ */
616
+ async performImmediateLegacyCleanup(projectPath, detection) {
617
+ const immediateDetection = omitGlobalLegacyPromptFiles(detection);
618
+ if (immediateDetection.hasLegacyArtifacts) {
619
+ await this.performLegacyCleanup(projectPath, immediateDetection);
620
+ }
621
+ }
622
+ /**
623
+ * Cleans approved global Codex prompts after configured tools refresh so newly
624
+ * installed replacement skills can retire their prompts in the same run.
625
+ */
626
+ async performDeferredGlobalPromptCleanup(projectPath, detection) {
627
+ const availableCodexWorkflows = new Set(scanInstalledWorkflows(projectPath, ['codex']));
628
+ const removableMatches = getLegacyGlobalPromptMatches(detection)
629
+ .filter((prompt) => prompt.workflowIds.every((workflowId) => availableCodexWorkflows.has(workflowId)));
630
+ if (removableMatches.length > 0) {
631
+ await this.performLegacyCleanup(projectPath, pickGlobalLegacyPromptFiles(detection, removableMatches.map((prompt) => prompt.path)));
632
+ }
633
+ const blockedMatches = getLegacyGlobalPromptMatches(detection)
634
+ .filter((prompt) => !removableMatches.some((match) => match.path === prompt.path));
635
+ if (blockedMatches.length > 0) {
636
+ console.log(chalk.yellow('Preserved deferred global prompts without replacement skills:'));
637
+ for (const prompt of blockedMatches) {
638
+ console.log(chalk.dim(` - ${prompt.toolId}: ${prompt.path}`));
639
+ }
640
+ console.log();
431
641
  }
432
642
  }
433
643
  /**
@@ -445,14 +655,14 @@ export class UpdateCommand {
445
655
  console.log();
446
656
  }
447
657
  /**
448
- * Upgrade legacy tools to new skills system.
449
- * Returns array of tool IDs that were newly configured.
658
+ * Upgrades unconfigured legacy tools into the skills-based setup and carries
659
+ * workflow overrides for migrations that should mirror legacy Codex prompts.
450
660
  */
451
661
  async upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery) {
452
662
  // Get tools that had legacy artifacts
453
663
  const legacyTools = getToolsFromLegacyArtifacts(detection);
454
664
  if (legacyTools.length === 0) {
455
- return [];
665
+ return { newlyConfiguredTools: [], workflowOverrides: {} };
456
666
  }
457
667
  // Get currently configured tools
458
668
  const configuredTools = getConfiguredToolsForProfileSync(projectPath);
@@ -460,13 +670,13 @@ export class UpdateCommand {
460
670
  // Filter to tools that aren't already configured
461
671
  const unconfiguredLegacyTools = legacyTools.filter((t) => !configuredSet.has(t));
462
672
  if (unconfiguredLegacyTools.length === 0) {
463
- return [];
673
+ return { newlyConfiguredTools: [], workflowOverrides: {} };
464
674
  }
465
675
  // Get valid tools (those with skillsDir)
466
676
  const validToolIds = new Set(getToolsWithSkillsDir());
467
677
  const validUnconfiguredTools = unconfiguredLegacyTools.filter((t) => validToolIds.has(t));
468
678
  if (validUnconfiguredTools.length === 0) {
469
- return [];
679
+ return { newlyConfiguredTools: [], workflowOverrides: {} };
470
680
  }
471
681
  // Show what tools were detected from legacy artifacts
472
682
  console.log(chalk.bold('Tools detected from legacy artifacts:'));
@@ -502,15 +712,13 @@ export class UpdateCommand {
502
712
  if (selectedTools.length === 0) {
503
713
  console.log(chalk.dim('Skipping tool setup.'));
504
714
  console.log();
505
- return [];
715
+ return { newlyConfiguredTools: [], workflowOverrides: {} };
506
716
  }
507
717
  }
718
+ const inferredCodexWorkflows = getLegacyWorkflowIdsForTool(detection, 'codex');
508
719
  // Create skills/commands for selected tools using effective profile+delivery.
509
720
  const newlyConfigured = [];
510
- const shouldGenerateSkills = delivery !== 'commands';
511
- const shouldGenerateCommands = delivery !== 'skills';
512
- const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : [];
513
- const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : [];
721
+ const workflowOverrides = {};
514
722
  for (const toolId of selectedTools) {
515
723
  const tool = AI_TOOLS.find((t) => t.value === toolId);
516
724
  if (!tool?.skillsDir)
@@ -518,13 +726,22 @@ export class UpdateCommand {
518
726
  const spinner = ora(`Setting up ${tool.name}...`).start();
519
727
  try {
520
728
  const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
729
+ const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
730
+ const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
731
+ const toolWorkflows = (tool.value === 'codex' && inferredCodexWorkflows.length > 0
732
+ ? inferredCodexWorkflows
733
+ : desiredWorkflows);
734
+ if (tool.value === 'codex' && inferredCodexWorkflows.length > 0) {
735
+ workflowOverrides[tool.value] = inferredCodexWorkflows;
736
+ }
737
+ const skillTemplates = getSkillTemplates(toolWorkflows);
738
+ const commandContents = getCommandContents(toolWorkflows);
521
739
  // Create skill files when delivery includes skills
522
740
  if (shouldGenerateSkills) {
523
741
  for (const { template, dirName } of skillTemplates) {
524
742
  const skillDir = path.join(skillsDir, dirName);
525
743
  const skillFile = path.join(skillDir, 'SKILL.md');
526
- // Use hyphen-based command references for tools where filename === command name (oh-my-pi, opencode, pi)
527
- const transformer = (tool.value === 'opencode' || tool.value === 'pi' || tool.value === 'oh-my-pi') ? transformToHyphenCommands : undefined;
744
+ const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value));
528
745
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
529
746
  await FileSystemUtils.writeFile(skillFile, skillContent);
530
747
  }
@@ -551,7 +768,7 @@ export class UpdateCommand {
551
768
  if (newlyConfigured.length > 0) {
552
769
  console.log();
553
770
  }
554
- return newlyConfigured;
771
+ return { newlyConfiguredTools: newlyConfigured, workflowOverrides };
555
772
  }
556
773
  }
557
774
  //# sourceMappingURL=update.js.map
@@ -19,6 +19,9 @@ export declare const VALIDATION_MESSAGES: {
19
19
  readonly CHANGE_WHY_TOO_LONG: "Why section should not exceed 1000 characters";
20
20
  readonly CHANGE_WHAT_EMPTY: "What Changes section cannot be empty";
21
21
  readonly CHANGE_NO_DELTAS: "Change must have at least one delta";
22
+ readonly CHANGE_SKIP_SPECS_CONFLICT: "skip_specs is set in .openspec.yaml but spec files exist under specs/. Remove skip_specs or delete the delta spec files";
23
+ readonly CHANGE_SKIP_SPECS_ACCEPTED: "skip_specs is set in .openspec.yaml: change declares no spec-level behavior changes, zero deltas accepted";
24
+ readonly CHANGE_SKIP_SPECS_INVALID_METADATA: "skip_specs is set but .openspec.yaml is not valid change metadata, so the marker is not honored. Fix the metadata";
22
25
  readonly CHANGE_TOO_MANY_DELTAS: "Consider splitting changes with more than 10 deltas";
23
26
  readonly DELTA_SPEC_EMPTY: "Spec name cannot be empty";
24
27
  readonly DELTA_DESCRIPTION_EMPTY: "Delta description cannot be empty";
@@ -26,7 +29,7 @@ export declare const VALIDATION_MESSAGES: {
26
29
  readonly REQUIREMENT_TOO_LONG: "Requirement text is very long (>500 characters). Consider breaking it down.";
27
30
  readonly DELTA_DESCRIPTION_TOO_BRIEF: "Delta description is too brief";
28
31
  readonly DELTA_MISSING_REQUIREMENTS: "Delta should include requirements";
29
- readonly GUIDE_NO_DELTAS: "No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one \"#### Scenario:\" block. Tip: run \"openspec change show <change-id> --json --deltas-only\" to inspect parsed deltas.";
32
+ readonly GUIDE_NO_DELTAS: "No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one \"#### Scenario:\" block. If this change intentionally modifies no specs (pure refactor, tooling, docs), set \"skip_specs: true\" in the change's .openspec.yaml instead. Tip: run \"openspec change show <change-id> --json --deltas-only\" to inspect parsed deltas.";
30
33
  readonly GUIDE_MISSING_SPEC_SECTIONS: "Missing required sections. Expected headers: \"## Purpose\" and \"## Requirements\". Example:\n## Purpose\n[brief purpose]\n\n## Requirements\n### Requirement: Clear requirement statement\nUsers SHALL ...\n\n#### Scenario: Descriptive name\n- **WHEN** ...\n- **THEN** ...";
31
34
  readonly GUIDE_MISSING_CHANGE_SECTIONS: "Missing required sections. Expected headers: \"## Why\" and \"## What Changes\". Ensure deltas are documented in specs/ using delta headers.";
32
35
  readonly GUIDE_SCENARIO_FORMAT: "Scenarios must use level-4 headers. Convert bullet lists into:\n#### Scenario: Short name\n- **WHEN** ...\n- **THEN** ...\n- **AND** ...";
@@ -23,6 +23,9 @@ export const VALIDATION_MESSAGES = {
23
23
  CHANGE_WHY_TOO_LONG: `Why section should not exceed ${MAX_WHY_SECTION_LENGTH} characters`,
24
24
  CHANGE_WHAT_EMPTY: 'What Changes section cannot be empty',
25
25
  CHANGE_NO_DELTAS: 'Change must have at least one delta',
26
+ CHANGE_SKIP_SPECS_CONFLICT: 'skip_specs is set in .openspec.yaml but spec files exist under specs/. Remove skip_specs or delete the delta spec files',
27
+ CHANGE_SKIP_SPECS_ACCEPTED: 'skip_specs is set in .openspec.yaml: change declares no spec-level behavior changes, zero deltas accepted',
28
+ CHANGE_SKIP_SPECS_INVALID_METADATA: 'skip_specs is set but .openspec.yaml is not valid change metadata, so the marker is not honored. Fix the metadata',
26
29
  CHANGE_TOO_MANY_DELTAS: `Consider splitting changes with more than ${MAX_DELTAS_PER_CHANGE} deltas`,
27
30
  DELTA_SPEC_EMPTY: 'Spec name cannot be empty',
28
31
  DELTA_DESCRIPTION_EMPTY: 'Delta description cannot be empty',
@@ -32,7 +35,7 @@ export const VALIDATION_MESSAGES = {
32
35
  DELTA_DESCRIPTION_TOO_BRIEF: 'Delta description is too brief',
33
36
  DELTA_MISSING_REQUIREMENTS: 'Delta should include requirements',
34
37
  // Guidance snippets (appended to primary messages for remediation)
35
- GUIDE_NO_DELTAS: 'No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one "#### Scenario:" block. Tip: run "openspec change show <change-id> --json --deltas-only" to inspect parsed deltas.',
38
+ GUIDE_NO_DELTAS: 'No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one "#### Scenario:" block. If this change intentionally modifies no specs (pure refactor, tooling, docs), set "skip_specs: true" in the change\'s .openspec.yaml instead. Tip: run "openspec change show <change-id> --json --deltas-only" to inspect parsed deltas.',
36
39
  GUIDE_MISSING_SPEC_SECTIONS: 'Missing required sections. Expected headers: "## Purpose" and "## Requirements". Example:\n## Purpose\n[brief purpose]\n\n## Requirements\n### Requirement: Clear requirement statement\nUsers SHALL ...\n\n#### Scenario: Descriptive name\n- **WHEN** ...\n- **THEN** ...',
37
40
  GUIDE_MISSING_CHANGE_SECTIONS: 'Missing required sections. Expected headers: "## Why" and "## What Changes". Ensure deltas are documented in specs/ using delta headers.',
38
41
  GUIDE_SCENARIO_FORMAT: 'Scenarios must use level-4 headers. Convert bullet lists into:\n#### Scenario: Short name\n- **WHEN** ...\n- **THEN** ...\n- **AND** ...',