@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
@@ -4,12 +4,289 @@
4
4
  * One-time migration logic for existing projects when profile system is introduced.
5
5
  * Called by both init and update commands before profile resolution.
6
6
  */
7
+ import { AI_TOOLS } from './config.js';
7
8
  import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig } from './global-config.js';
8
9
  import { CommandAdapterRegistry } from './command-generation/index.js';
10
+ import { resolveCommandInvocation, resolveCommandSurfaceCapability, shouldGenerateCommandsForTool, } from './command-surface.js';
9
11
  import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js';
12
+ import { COMMAND_IDS } from './shared/tool-detection.js';
10
13
  import { ALL_WORKFLOWS } from './profiles.js';
14
+ import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js';
11
15
  import path from 'path';
12
16
  import * as fs from 'fs';
17
+ /**
18
+ * Former tool roots whose OpenSpec-managed content belongs under the tool's
19
+ * current skillsDir. User files are never touched.
20
+ */
21
+ export const LEGACY_TOOL_ROOTS = {
22
+ // Kimi CLI became Kimi Code and moved from .kimi to .kimi-code.
23
+ kimi: [{ root: '.kimi', needsConsent: false }],
24
+ // Windsurf was rebranded to Devin Desktop on 2026-06-02 and its config
25
+ // directory moved to .devin/. Devin Desktop reads .windsurf/ only as a
26
+ // fallback and Devin Local does not read it at all, so moving is the right
27
+ // default — but a pre-rebrand Windsurf build reads ONLY .windsurf/, and
28
+ // nothing on disk tells that user apart, so the move is offered, not taken.
29
+ devin: [{ root: '.windsurf', needsConsent: true }],
30
+ };
31
+ function classifyManagedFile(source, destination) {
32
+ if (isSamePath(source, destination))
33
+ return 'skip';
34
+ if (!fs.existsSync(destination))
35
+ return 'move';
36
+ try {
37
+ return fs.readFileSync(source, 'utf-8') === fs.readFileSync(destination, 'utf-8')
38
+ ? 'drop'
39
+ : 'keep';
40
+ }
41
+ catch {
42
+ return 'keep';
43
+ }
44
+ }
45
+ /**
46
+ * Rewrites a generated command path from the tool's current root to a legacy
47
+ * one, so `.devin/workflows/opsx-apply.md` locates its `.windsurf/` twin
48
+ * without the migration hard-coding either layout.
49
+ *
50
+ * Returns undefined for adapters whose paths are absolute (global-scoped
51
+ * command files) or do not start at the tool root — neither can be relocated
52
+ * by swapping a leading segment.
53
+ */
54
+ function legacyCommandPath(commandPath, currentRoot, legacyRoot) {
55
+ if (path.isAbsolute(commandPath))
56
+ return undefined;
57
+ const segments = commandPath.split(/[\\/]/);
58
+ if (segments[0] !== currentRoot)
59
+ return undefined;
60
+ segments[0] = legacyRoot;
61
+ return path.join(...segments);
62
+ }
63
+ /**
64
+ * Reports the OpenSpec content sitting under each tool's legacy root, without
65
+ * moving anything. Callers use this to ask before a move that needs consent.
66
+ */
67
+ export function findLegacyToolMigrations(projectPath) {
68
+ return collectLegacyToolMigrations(projectPath, false);
69
+ }
70
+ /**
71
+ * Moves OpenSpec-managed skill directories (openspec-*) and command files
72
+ * (opsx-*) from a tool's legacy root to its current one. When the destination
73
+ * already exists the legacy copy is removed instead. Legacy directories are
74
+ * deleted only when left empty, so user files under the old location — a
75
+ * hand-written Cascade workflow next to the generated ones — are preserved.
76
+ *
77
+ * @param projectPath - Project root
78
+ * @param toolIds - Restrict the move to these tools; omit to move every tool
79
+ * whose legacy root needs no consent
80
+ */
81
+ export function migrateLegacyToolDirs(projectPath, toolIds) {
82
+ return collectLegacyToolMigrations(projectPath, true, toolIds);
83
+ }
84
+ function collectLegacyToolMigrations(projectPath, apply, toolIds) {
85
+ const migrations = [];
86
+ for (const tool of AI_TOOLS) {
87
+ if (!tool.skillsDir)
88
+ continue;
89
+ if (toolIds && !toolIds.includes(tool.value))
90
+ continue;
91
+ for (const legacy of LEGACY_TOOL_ROOTS[tool.value] ?? []) {
92
+ if (legacy.root === tool.skillsDir)
93
+ continue;
94
+ // Without an explicit tool list, only moves that need no consent run.
95
+ if (apply && !toolIds && legacy.needsConsent)
96
+ continue;
97
+ if (!fs.existsSync(path.join(projectPath, legacy.root)))
98
+ continue;
99
+ const skills = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply);
100
+ const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply);
101
+ if (apply) {
102
+ removeDirIfEmpty(path.join(projectPath, legacy.root, 'skills'));
103
+ removeDirIfEmpty(path.join(projectPath, legacy.root, 'workflows'));
104
+ removeDirIfEmpty(path.join(projectPath, legacy.root));
105
+ }
106
+ // Kept-only results are retained deliberately. When every legacy file
107
+ // differs from its counterpart nothing is movable, and dropping the
108
+ // record here would leave the user with two divergent copies and no
109
+ // word of it.
110
+ if (skills.moved > 0 || commands.moved > 0 || skills.kept > 0 || commands.kept > 0) {
111
+ migrations.push({
112
+ toolId: tool.value,
113
+ from: legacy.root,
114
+ to: tool.skillsDir,
115
+ skillDirs: skills.moved,
116
+ commandFiles: commands.moved,
117
+ keptInPlace: skills.kept + commands.kept,
118
+ needsConsent: legacy.needsConsent,
119
+ });
120
+ }
121
+ }
122
+ }
123
+ return migrations;
124
+ }
125
+ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply) {
126
+ const legacySkillsDir = path.join(projectPath, legacyRoot, 'skills');
127
+ if (!fs.existsSync(legacySkillsDir))
128
+ return { moved: 0, kept: 0 };
129
+ const currentSkillsDir = path.join(projectPath, currentRoot, 'skills');
130
+ let moved = 0;
131
+ let kept = 0;
132
+ for (const workflowId of ALL_WORKFLOWS) {
133
+ const dirName = WORKFLOW_TO_SKILL_DIR[workflowId];
134
+ const source = path.join(legacySkillsDir, dirName);
135
+ const sourceSkill = path.join(source, 'SKILL.md');
136
+ if (!fs.existsSync(sourceSkill))
137
+ continue;
138
+ const destination = path.join(currentSkillsDir, dirName);
139
+ const destinationSkill = path.join(destination, 'SKILL.md');
140
+ const disposition = classifyManagedFile(sourceSkill, destinationSkill);
141
+ if (disposition === 'skip')
142
+ continue;
143
+ if (disposition === 'keep') {
144
+ kept++;
145
+ continue;
146
+ }
147
+ if (!apply) {
148
+ moved++;
149
+ continue;
150
+ }
151
+ try {
152
+ // Move the generated file, never the directory around it. A skill
153
+ // directory can also hold files the user wrote, and this destination is
154
+ // one OpenSpec deletes on its own — commands-only delivery and a
155
+ // deselected workflow both remove the whole skill directory. Carrying a
156
+ // user's file across would be handing it to that later removal.
157
+ if (disposition === 'drop') {
158
+ fs.rmSync(sourceSkill, { force: true });
159
+ }
160
+ else {
161
+ fs.mkdirSync(destination, { recursive: true });
162
+ fs.renameSync(sourceSkill, destinationSkill);
163
+ }
164
+ // Anything the user left beside it stays under the legacy root.
165
+ removeDirIfEmpty(source);
166
+ moved++;
167
+ }
168
+ catch {
169
+ // Leave the legacy directory in place if it cannot be moved
170
+ }
171
+ }
172
+ return { moved, kept };
173
+ }
174
+ function migrateCommandFiles(projectPath, tool, legacyRoot, apply) {
175
+ const adapter = CommandAdapterRegistry.get(tool.value);
176
+ if (!adapter || !tool.skillsDir)
177
+ return { moved: 0, kept: 0 };
178
+ let moved = 0;
179
+ let kept = 0;
180
+ for (const commandId of COMMAND_IDS) {
181
+ const currentPath = adapter.getFilePath(commandId);
182
+ const legacyPath = legacyCommandPath(currentPath, tool.skillsDir, legacyRoot);
183
+ if (!legacyPath)
184
+ continue;
185
+ const source = path.join(projectPath, legacyPath);
186
+ if (!fs.existsSync(source))
187
+ continue;
188
+ const destination = path.join(projectPath, currentPath);
189
+ const disposition = classifyManagedFile(source, destination);
190
+ if (disposition === 'skip')
191
+ continue;
192
+ if (disposition === 'keep') {
193
+ kept++;
194
+ continue;
195
+ }
196
+ if (!apply) {
197
+ moved++;
198
+ continue;
199
+ }
200
+ try {
201
+ if (disposition === 'drop') {
202
+ fs.rmSync(source, { force: true });
203
+ }
204
+ else {
205
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
206
+ fs.renameSync(source, destination);
207
+ }
208
+ moved++;
209
+ }
210
+ catch {
211
+ // Leave the legacy file in place if it cannot be moved
212
+ }
213
+ }
214
+ return { moved, kept };
215
+ }
216
+ /**
217
+ * Summarizes what a migration moved, e.g. "6 skills and 6 commands".
218
+ */
219
+ export function describeLegacyMigration(migration) {
220
+ const parts = [];
221
+ if (migration.skillDirs > 0) {
222
+ parts.push(`${migration.skillDirs} skill${migration.skillDirs === 1 ? '' : 's'}`);
223
+ }
224
+ if (migration.commandFiles > 0) {
225
+ parts.push(`${migration.commandFiles} command${migration.commandFiles === 1 ? '' : 's'}`);
226
+ }
227
+ return parts.join(' and ');
228
+ }
229
+ /**
230
+ * Names OpenSpec-managed files the move deliberately left behind, so a user
231
+ * who customized one knows there are now two copies to reconcile.
232
+ */
233
+ export function keptInPlaceNotice(migration) {
234
+ if (migration.keptInPlace === 0)
235
+ return undefined;
236
+ const n = migration.keptInPlace;
237
+ // Deliberately does not claim the difference came from an edit: an older
238
+ // OpenSpec version's output differs too. Either way nothing was overwritten,
239
+ // and the user is the one who decides which copy to keep.
240
+ return (`Left ${n} file${n === 1 ? '' : 's'} in ${migration.from}/ that ` +
241
+ `differ${n === 1 ? 's' : ''} from the copy in ${migration.to}/. Nothing was ` +
242
+ `overwritten — compare the two and delete the ${migration.from}/ copy once ` +
243
+ `you have kept anything you customized.`);
244
+ }
245
+ /**
246
+ * Whether a migration has anything to move, as opposed to only files left in
247
+ * place. Callers use this to avoid offering a move of nothing.
248
+ */
249
+ export function hasMovableContent(migration) {
250
+ return migration.skillDirs > 0 || migration.commandFiles > 0;
251
+ }
252
+ /**
253
+ * Explains why a consent-gated move is being offered, in the user's terms.
254
+ * Keyed by tool so the reason is specific rather than a generic "files moved".
255
+ */
256
+ export function legacyMigrationNotice(migration) {
257
+ if (migration.toolId === 'devin') {
258
+ return (`Windsurf is now Devin Desktop, and its config directory moved from ` +
259
+ `${migration.from}/ to ${migration.to}/. Devin Desktop reads ${migration.from}/ ` +
260
+ `only as a fallback, and Devin Local does not read it at all.`);
261
+ }
262
+ return `${migration.from}/ is the former location for this tool; ${migration.to}/ is current.`;
263
+ }
264
+ /**
265
+ * Whether two paths are the same file on disk once symlinks are resolved.
266
+ *
267
+ * Symlinking one tool root at the other is a realistic way to straddle a
268
+ * rebrand (`ln -s .devin .windsurf` to keep an older build working). Without
269
+ * this check the "destination already exists, drop the legacy copy" branch
270
+ * deletes the destination itself, taking the only copy with it.
271
+ */
272
+ function isSamePath(a, b) {
273
+ try {
274
+ return fs.realpathSync(a) === fs.realpathSync(b);
275
+ }
276
+ catch {
277
+ return false;
278
+ }
279
+ }
280
+ function removeDirIfEmpty(dirPath) {
281
+ try {
282
+ if (fs.readdirSync(dirPath).length === 0) {
283
+ fs.rmdirSync(dirPath);
284
+ }
285
+ }
286
+ catch {
287
+ // Missing or non-empty directory — nothing to do
288
+ }
289
+ }
13
290
  function scanInstalledWorkflowArtifacts(projectPath, tools) {
14
291
  const installed = new Set();
15
292
  let hasSkills = false;
@@ -103,6 +380,22 @@ export function migrateIfNeeded(projectPath, tools) {
103
380
  }
104
381
  saveGlobalConfig(config);
105
382
  console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`);
106
- console.log("New in this version: /opsx:propose. Try 'openspec config profile core' for the streamlined experience.");
383
+ // Each detected tool resolves to a propose reference for its surface: the
384
+ // command name its generated files answer to when commands will exist for it
385
+ // under the effective delivery (/opsx:propose when namespaced under opsx/,
386
+ // /opsx-propose when the filename is the command), its documented skill
387
+ // invocation otherwise. When the tools disagree — including command tools
388
+ // mixed with skill-only tools — stay syntax-neutral rather than advertise a
389
+ // form that is wrong for one of them.
390
+ const effectiveDelivery = config.delivery ?? 'both';
391
+ const proposeReferences = new Set(tools.map((tool) => {
392
+ if (shouldGenerateCommandsForTool(tool.value, effectiveDelivery)) {
393
+ const transformer = getTransformerForTool(tool.value, effectiveDelivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value));
394
+ return transformer ? transformer('/opsx:propose') : '/opsx:propose';
395
+ }
396
+ return getSkillReferenceTransformer(tool.value)('/opsx:propose');
397
+ }));
398
+ const proposeReference = proposeReferences.size === 1 ? [...proposeReferences][0] : 'the openspec-propose skill';
399
+ console.log(`New in this version: ${proposeReference}. Try 'openspec config profile core' for the streamlined experience.`);
107
400
  }
108
401
  //# sourceMappingURL=migration.js.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Onboarding command hints.
3
+ *
4
+ * The commands shown to a user after setup must be limited to the workflows
5
+ * their profile actually installs, otherwise we advertise slash commands that
6
+ * were correctly never generated.
7
+ *
8
+ * This module decides WHICH hints to show. How each one is spelled for a given
9
+ * tool — command, skill, or a tool-specific skill prefix — is decided by
10
+ * src/utils/command-references.ts at the call site.
11
+ */
12
+ import type { WorkflowId } from './profiles.js';
13
+ export type OnboardingCommand = {
14
+ workflow: WorkflowId;
15
+ command: string;
16
+ description: string;
17
+ };
18
+ /**
19
+ * Longest description the welcome screen can render. It shows these beside a
20
+ * 24-column art column and only animates at MIN_WIDTH (60) columns or wider; a
21
+ * longer line wraps, and the animation's cursor-up count assumes unwrapped
22
+ * lines. See src/ui/welcome-screen.ts.
23
+ */
24
+ export declare const DESCRIPTION_BUDGET = 17;
25
+ /**
26
+ * Returns the onboarding hints for the installed workflows, in lifecycle order.
27
+ * Returns an empty array when none of the onboarding workflows are installed.
28
+ */
29
+ export declare function getOnboardingCommands(workflows: readonly string[]): OnboardingCommand[];
30
+ //# sourceMappingURL=onboarding-commands.d.ts.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Onboarding command hints.
3
+ *
4
+ * The commands shown to a user after setup must be limited to the workflows
5
+ * their profile actually installs, otherwise we advertise slash commands that
6
+ * were correctly never generated.
7
+ *
8
+ * This module decides WHICH hints to show. How each one is spelled for a given
9
+ * tool — command, skill, or a tool-specific skill prefix — is decided by
10
+ * src/utils/command-references.ts at the call site.
11
+ */
12
+ /**
13
+ * Longest description the welcome screen can render. It shows these beside a
14
+ * 24-column art column and only animates at MIN_WIDTH (60) columns or wider; a
15
+ * longer line wraps, and the animation's cursor-up count assumes unwrapped
16
+ * lines. See src/ui/welcome-screen.ts.
17
+ */
18
+ export const DESCRIPTION_BUDGET = 17;
19
+ /**
20
+ * Ordered onboarding hints. Each entry is shown only when its workflow is
21
+ * installed, so the list follows the change lifecycle: start, then build,
22
+ * then implement.
23
+ */
24
+ const ONBOARDING_COMMANDS = [
25
+ { workflow: 'propose', command: '/opsx:propose', description: 'Start a change' },
26
+ { workflow: 'new', command: '/opsx:new', description: 'Scaffold a change' },
27
+ { workflow: 'continue', command: '/opsx:continue', description: 'Next artifact' },
28
+ { workflow: 'apply', command: '/opsx:apply', description: 'Implement tasks' },
29
+ ];
30
+ /**
31
+ * Returns the onboarding hints for the installed workflows, in lifecycle order.
32
+ * Returns an empty array when none of the onboarding workflows are installed.
33
+ */
34
+ export function getOnboardingCommands(workflows) {
35
+ const installed = new Set(workflows);
36
+ return ONBOARDING_COMMANDS.filter((entry) => installed.has(entry.workflow));
37
+ }
38
+ //# sourceMappingURL=onboarding-commands.js.map
@@ -1,10 +1,25 @@
1
- import { MarkdownParser } from './markdown-parser.js';
2
- import { Change } from '../schemas/index.js';
1
+ import { MarkdownParser, Section } from './markdown-parser.js';
2
+ import { Change, Requirement } from '../schemas/index.js';
3
3
  export declare class ChangeParser extends MarkdownParser {
4
4
  private changeDir;
5
5
  constructor(content: string, changeDir: string);
6
6
  parseChangeWithDeltas(name: string): Promise<Change>;
7
7
  private parseDeltaSpecs;
8
+ /**
9
+ * Read requirements from a delta section, ignoring headers that are not
10
+ * `### Requirement: <name>`.
11
+ *
12
+ * A delta section often carries divider headers such as
13
+ * `### Documentation Requirements`. The base parser treats every child header
14
+ * as a requirement, which invented a scenario-less requirement that does not
15
+ * exist (#498): archive warned about a missing scenario, and `show --json`
16
+ * reported an extra delta. The delta reader already skips these headers and
17
+ * notes them, so this keeps the two readers in agreement.
18
+ *
19
+ * Overriding here rather than in MarkdownParser keeps main spec parsing —
20
+ * `view`, `list`, `spec --json`, spec validation — untouched.
21
+ */
22
+ protected parseRequirements(section: Section): Requirement[];
8
23
  private parseSpecDeltas;
9
24
  private parseRenames;
10
25
  private parseSectionsFromContent;
@@ -2,6 +2,7 @@ import { MarkdownParser } from './markdown-parser.js';
2
2
  import { buildCodeFenceMask } from './requirement-text.js';
3
3
  import path from 'path';
4
4
  import { promises as fs } from 'fs';
5
+ import { discoverSpecFiles } from '../../utils/spec-discovery.js';
5
6
  export class ChangeParser extends MarkdownParser {
6
7
  changeDir;
7
8
  constructor(content, changeDir) {
@@ -38,30 +39,42 @@ export class ChangeParser extends MarkdownParser {
38
39
  }
39
40
  async parseDeltaSpecs(specsDir) {
40
41
  const deltas = [];
41
- try {
42
- const specDirs = await fs.readdir(specsDir, { withFileTypes: true });
43
- for (const dir of specDirs) {
44
- if (!dir.isDirectory())
45
- continue;
46
- const specName = dir.name;
47
- const specFile = path.join(specsDir, specName, 'spec.md');
48
- try {
49
- const content = await fs.readFile(specFile, 'utf-8');
50
- const specDeltas = this.parseSpecDeltas(specName, content);
51
- deltas.push(...specDeltas);
52
- }
53
- catch (error) {
54
- // Spec file might not exist, which is okay
55
- continue;
56
- }
42
+ // Discover delta specs recursively so nested layouts like
43
+ // specs/<area>/<capability>/spec.md are parsed too (#1353)
44
+ const specFiles = await discoverSpecFiles(specsDir);
45
+ for (const { id, specFile } of specFiles) {
46
+ try {
47
+ const content = await fs.readFile(specFile, 'utf-8');
48
+ const specDeltas = this.parseSpecDeltas(id, content);
49
+ deltas.push(...specDeltas);
50
+ }
51
+ catch (error) {
52
+ // Spec file might not be readable, which is okay
53
+ continue;
57
54
  }
58
- }
59
- catch (error) {
60
- // Specs directory might not exist, which is okay
61
- return [];
62
55
  }
63
56
  return deltas;
64
57
  }
58
+ /**
59
+ * Read requirements from a delta section, ignoring headers that are not
60
+ * `### Requirement: <name>`.
61
+ *
62
+ * A delta section often carries divider headers such as
63
+ * `### Documentation Requirements`. The base parser treats every child header
64
+ * as a requirement, which invented a scenario-less requirement that does not
65
+ * exist (#498): archive warned about a missing scenario, and `show --json`
66
+ * reported an extra delta. The delta reader already skips these headers and
67
+ * notes them, so this keeps the two readers in agreement.
68
+ *
69
+ * Overriding here rather than in MarkdownParser keeps main spec parsing —
70
+ * `view`, `list`, `spec --json`, spec validation — untouched.
71
+ */
72
+ parseRequirements(section) {
73
+ return super.parseRequirements({
74
+ ...section,
75
+ children: section.children.filter((child) => /^Requirement:\s*\S/i.test(child.title.trim())),
76
+ });
77
+ }
65
78
  parseSpecDeltas(specName, content) {
66
79
  const deltas = [];
67
80
  const sections = this.parseSectionsFromContent(content);
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Shared fenced-code-block detection for the Markdown parsers.
3
+ *
4
+ * Several parsers need to ignore Markdown structure (headers, requirement
5
+ * blocks, scenarios, delta sections) that appears inside fenced code blocks.
6
+ * Keeping this logic in one place avoids the drift that previously left
7
+ * `requirement-blocks.ts` treating fenced `### Requirement:` lines as real
8
+ * requirements during validation and archiving.
9
+ */
10
+ /**
11
+ * Builds a per-line mask where `true` marks a line that is part of a fenced
12
+ * code block (including the opening and closing fence lines themselves).
13
+ */
14
+ export declare function buildCodeFenceMask(lines: string[]): boolean[];
15
+ //# sourceMappingURL=code-fence.d.ts.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Shared fenced-code-block detection for the Markdown parsers.
3
+ *
4
+ * Several parsers need to ignore Markdown structure (headers, requirement
5
+ * blocks, scenarios, delta sections) that appears inside fenced code blocks.
6
+ * Keeping this logic in one place avoids the drift that previously left
7
+ * `requirement-blocks.ts` treating fenced `### Requirement:` lines as real
8
+ * requirements during validation and archiving.
9
+ */
10
+ function getFenceMarker(line) {
11
+ const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
12
+ if (!fenceMatch) {
13
+ return null;
14
+ }
15
+ return {
16
+ marker: fenceMatch[1][0],
17
+ length: fenceMatch[1].length,
18
+ };
19
+ }
20
+ function isClosingFence(line, activeFence) {
21
+ const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
22
+ return Boolean(fenceMatch &&
23
+ fenceMatch[1][0] === activeFence.marker &&
24
+ fenceMatch[1].length >= activeFence.length);
25
+ }
26
+ /**
27
+ * Builds a per-line mask where `true` marks a line that is part of a fenced
28
+ * code block (including the opening and closing fence lines themselves).
29
+ */
30
+ export function buildCodeFenceMask(lines) {
31
+ const mask = new Array(lines.length).fill(false);
32
+ let activeFence = null;
33
+ for (let i = 0; i < lines.length; i++) {
34
+ if (!activeFence) {
35
+ const fence = getFenceMarker(lines[i]);
36
+ if (fence) {
37
+ activeFence = fence;
38
+ mask[i] = true;
39
+ }
40
+ continue;
41
+ }
42
+ mask[i] = true;
43
+ if (isClosingFence(lines[i], activeFence)) {
44
+ activeFence = null;
45
+ }
46
+ }
47
+ return mask;
48
+ }
49
+ //# sourceMappingURL=code-fence.js.map
@@ -10,7 +10,8 @@ export class MarkdownParser {
10
10
  this.currentLine = 0;
11
11
  }
12
12
  static normalizeContent(content) {
13
- return content.replace(/\r\n?/g, '\n');
13
+ // Strip a UTF-8 BOM so a header on the first line still matches.
14
+ return content.replace(/^/, '').replace(/\r\n?/g, '\n');
14
15
  }
15
16
  parseSpec(name) {
16
17
  const sections = this.parseSections();
@@ -11,6 +11,14 @@ export interface RequirementsSectionParts {
11
11
  after: string;
12
12
  }
13
13
  export declare function normalizeRequirementName(name: string): string;
14
+ /**
15
+ * Case- and whitespace-insensitive fold of a requirement name. Requirement
16
+ * matching itself is case-sensitive (normalizeRequirementName); this fold
17
+ * exists only for typo detection - near-miss REMOVED headers and the
18
+ * RENAMED+REMOVED cross-section conflict - where two spellings that differ
19
+ * only in case or interior whitespace mean a mistake, never two requirements.
20
+ */
21
+ export declare function foldRequirementName(name: string): string;
14
22
  /**
15
23
  * Extracts the Requirements section from a spec file and parses requirement blocks.
16
24
  */