@fission-ai/openspec 1.7.0 → 1.9.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 (113) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/index.d.ts +15 -0
  3. package/dist/cli/index.js +39 -5
  4. package/dist/commands/change.js +16 -1
  5. package/dist/commands/config.d.ts +5 -0
  6. package/dist/commands/config.js +5 -1
  7. package/dist/commands/schema.js +281 -47
  8. package/dist/commands/spec.js +30 -6
  9. package/dist/commands/validate.d.ts +23 -0
  10. package/dist/commands/validate.js +148 -4
  11. package/dist/commands/workflow/instructions.js +32 -27
  12. package/dist/commands/workflow/schemas.d.ts +2 -0
  13. package/dist/commands/workflow/schemas.js +9 -2
  14. package/dist/commands/workflow/status.js +2 -2
  15. package/dist/commands/workflow/templates.js +15 -5
  16. package/dist/core/archive.d.ts +14 -0
  17. package/dist/core/archive.js +1272 -151
  18. package/dist/core/artifact-graph/index.d.ts +1 -1
  19. package/dist/core/artifact-graph/index.js +1 -1
  20. package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
  21. package/dist/core/artifact-graph/instruction-loader.js +15 -5
  22. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  23. package/dist/core/artifact-graph/outputs.js +67 -5
  24. package/dist/core/artifact-graph/resolver.d.ts +0 -12
  25. package/dist/core/artifact-graph/resolver.js +49 -9
  26. package/dist/core/artifact-graph/types.js +21 -3
  27. package/dist/core/available-tools.d.ts +2 -2
  28. package/dist/core/available-tools.js +14 -3
  29. package/dist/core/change-metadata/schema.d.ts +1 -0
  30. package/dist/core/change-metadata/schema.js +7 -0
  31. package/dist/core/change-status-policy.js +2 -2
  32. package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
  33. package/dist/core/command-generation/adapters/command-code.js +34 -0
  34. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  35. package/dist/core/command-generation/adapters/index.js +1 -0
  36. package/dist/core/command-generation/registry.js +2 -0
  37. package/dist/core/completions/command-registry.js +13 -0
  38. package/dist/core/config-schema.d.ts +3 -0
  39. package/dist/core/config-schema.js +28 -1
  40. package/dist/core/config.d.ts +4 -0
  41. package/dist/core/config.js +40 -17
  42. package/dist/core/file-state.js +56 -24
  43. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  44. package/dist/core/github-copilot/cloud-agent.js +497 -0
  45. package/dist/core/global-config.d.ts +11 -0
  46. package/dist/core/init.d.ts +23 -0
  47. package/dist/core/init.js +253 -46
  48. package/dist/core/legacy-cleanup.d.ts +10 -0
  49. package/dist/core/legacy-cleanup.js +52 -28
  50. package/dist/core/migration.d.ts +6 -4
  51. package/dist/core/migration.js +78 -22
  52. package/dist/core/parsers/requirement-blocks.d.ts +10 -0
  53. package/dist/core/parsers/requirement-blocks.js +93 -1
  54. package/dist/core/parsers/requirement-text.d.ts +8 -0
  55. package/dist/core/parsers/requirement-text.js +4 -3
  56. package/dist/core/parsers/spec-structure.d.ts +1 -1
  57. package/dist/core/parsers/spec-structure.js +17 -1
  58. package/dist/core/profile-sync-drift.js +40 -5
  59. package/dist/core/project-config.d.ts +3 -0
  60. package/dist/core/project-config.js +31 -1
  61. package/dist/core/root-selection.d.ts +1 -1
  62. package/dist/core/shared/index.d.ts +1 -0
  63. package/dist/core/shared/index.js +1 -0
  64. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  65. package/dist/core/shared/skill-content-equivalence.js +41 -0
  66. package/dist/core/shared/skill-paths.d.ts +13 -0
  67. package/dist/core/shared/skill-paths.js +23 -0
  68. package/dist/core/shared/tool-detection.js +66 -35
  69. package/dist/core/shared-skill-target.d.ts +31 -0
  70. package/dist/core/shared-skill-target.js +186 -0
  71. package/dist/core/specs-apply.d.ts +72 -0
  72. package/dist/core/specs-apply.js +459 -59
  73. package/dist/core/templates/skill-templates.d.ts +1 -1
  74. package/dist/core/templates/skill-templates.js +1 -1
  75. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  76. package/dist/core/templates/workflows/apply-change.js +25 -182
  77. package/dist/core/templates/workflows/archive-change.js +8 -4
  78. package/dist/core/templates/workflows/bulk-archive-change.js +30 -26
  79. package/dist/core/templates/workflows/continue-change.js +6 -6
  80. package/dist/core/templates/workflows/explore.js +42 -18
  81. package/dist/core/templates/workflows/onboard.js +9 -3
  82. package/dist/core/templates/workflows/propose.js +72 -20
  83. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  84. package/dist/core/templates/workflows/store-selection.js +1 -1
  85. package/dist/core/templates/workflows/sync-specs.js +96 -18
  86. package/dist/core/templates/workflows/update-change.js +10 -8
  87. package/dist/core/update.d.ts +1 -0
  88. package/dist/core/update.js +136 -30
  89. package/dist/core/validation/task-numbering.d.ts +16 -0
  90. package/dist/core/validation/task-numbering.js +57 -0
  91. package/dist/core/validation/validator.d.ts +26 -3
  92. package/dist/core/validation/validator.js +189 -19
  93. package/dist/core/version-check.js +6 -11
  94. package/dist/telemetry/config.d.ts +3 -4
  95. package/dist/telemetry/index.d.ts +12 -5
  96. package/dist/telemetry/index.js +27 -9
  97. package/dist/utils/change-metadata.d.ts +17 -4
  98. package/dist/utils/change-metadata.js +26 -4
  99. package/dist/utils/ci.d.ts +12 -0
  100. package/dist/utils/ci.js +16 -0
  101. package/dist/utils/command-references.d.ts +14 -2
  102. package/dist/utils/command-references.js +45 -3
  103. package/dist/utils/file-system.d.ts +10 -0
  104. package/dist/utils/file-system.js +70 -0
  105. package/dist/utils/interactive.d.ts +52 -0
  106. package/dist/utils/interactive.js +135 -0
  107. package/dist/utils/spec-discovery.d.ts +4 -4
  108. package/dist/utils/spec-discovery.js +19 -6
  109. package/dist/utils/task-progress.d.ts +51 -1
  110. package/dist/utils/task-progress.js +93 -41
  111. package/package.json +1 -1
  112. package/schemas/spec-driven/schema.yaml +8 -6
  113. package/schemas/spec-driven/templates/proposal.md +6 -4
package/dist/core/init.js CHANGED
@@ -12,19 +12,21 @@ import { createRequire } from 'module';
12
12
  import { FileSystemUtils } from '../utils/file-system.js';
13
13
  import { classifyOpenSpecDir, storePointerProblem } from './project-config.js';
14
14
  import { findRepoPlanningRootSync } from './planning-home.js';
15
- import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js';
15
+ import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
16
16
  import { AI_TOOLS, OPENSPEC_DIR_NAME, resolveToolIdAlias, } from './config.js';
17
17
  import { PALETTE } from './styles/palette.js';
18
18
  import { isInteractive } from '../utils/interactive.js';
19
19
  import { serializeConfig } from './config-prompts.js';
20
20
  import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js';
21
21
  import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatCleanupSummary, formatDeferredGlobalPromptSummary, formatDetectionSummary, getLegacyGlobalPromptMatches, omitGlobalLegacyPromptFiles, pickGlobalLegacyPromptFiles, } from './legacy-cleanup.js';
22
- import { getToolsWithSkillsDir, getToolStates, getSkillTemplates, getCommandContents, generateSkillContent, } from './shared/index.js';
22
+ import { getToolsWithSkillsDir, getToolStates, getSkillTemplates, getCommandContents, generateSkillContent, hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, } from './shared/index.js';
23
23
  import { getGlobalConfig } from './global-config.js';
24
24
  import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js';
25
25
  import { getAvailableTools } from './available-tools.js';
26
+ import { writeSharedSkillTarget } from './shared-skill-target.js';
26
27
  import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptInPlaceNotice, hasMovableContent, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js';
27
28
  import { resolveCommandSurfaceCapability, resolveCommandInvocation, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
29
+ import { writeCopilotCloudFiles, readCopilotCloudOptIn, hasExistingManagedCloudFiles, persistCopilotCloudOptIn, removeCopilotCloudFiles, findUnmanagedCloudFiles, listManagedCloudFiles, } from './github-copilot/cloud-agent.js';
28
30
  const require = createRequire(import.meta.url);
29
31
  const { version: OPENSPEC_VERSION } = require('../../package.json');
30
32
  // -----------------------------------------------------------------------------
@@ -58,12 +60,14 @@ export class InitCommand {
58
60
  interactiveOption;
59
61
  profileOverride;
60
62
  animation;
63
+ copilotCloudOption;
61
64
  constructor(options = {}) {
62
65
  this.toolsArg = options.tools;
63
66
  this.force = options.force ?? false;
64
67
  this.interactiveOption = options.interactive;
65
68
  this.profileOverride = options.profile;
66
69
  this.animation = options.animation ?? true;
70
+ this.copilotCloudOption = options.copilotCloud;
67
71
  }
68
72
  async execute(targetPath) {
69
73
  const projectPath = path.resolve(targetPath);
@@ -120,7 +124,7 @@ export class InitCommand {
120
124
  // Get tool selection (pass detected tools for pre-selection)
121
125
  const selectedToolIds = await this.getSelectedTools(toolStates, extendMode, detectedTools, projectPath);
122
126
  // Validate selected tools
123
- const validatedTools = this.validateTools(selectedToolIds, toolStates);
127
+ const validatedTools = this.validateTools(selectedToolIds, toolStates, projectPath);
124
128
  // Selecting a renamed tool is consent to leave its former directory:
125
129
  // init is about to write the current one, and leaving OpenSpec content
126
130
  // behind would give the user two installs of the same tool.
@@ -132,10 +136,16 @@ export class InitCommand {
132
136
  if (kept)
133
137
  console.log(chalk.dim(kept));
134
138
  }
139
+ // Decide whether to generate GitHub Copilot cloud files. This is opt-in
140
+ // (see cloud-agent.ts): selecting the Copilot tool no longer silently
141
+ // writes a GitHub Actions workflow into the user's .github/. The decision
142
+ // is made before generation so the write can be gated, and persisted after
143
+ // config.yaml exists so future non-interactive updates honor it.
144
+ const copilotDecision = await this.resolveCopilotCloudDecision(projectPath, validatedTools);
135
145
  // Create directory structure and config
136
146
  await this.createDirectoryStructure(openspecPath, extendMode);
137
147
  // Generate skills and commands for each tool
138
- const results = await this.generateSkillsAndCommands(projectPath, validatedTools);
148
+ const results = await this.generateSkillsAndCommands(projectPath, validatedTools, copilotDecision.write);
139
149
  // Legacy cleanup was deferred to avoid interfering with skill/command generation;
140
150
  // now that outputs are written, finalize the cleanup (e.g. remove stale files).
141
151
  if (deferredLegacyCleanup) {
@@ -143,8 +153,49 @@ export class InitCommand {
143
153
  }
144
154
  // Create config.yaml if needed
145
155
  const configStatus = await this.createConfig(openspecPath, extendMode);
156
+ // Persist an explicit Copilot cloud decision so `openspec update` (which
157
+ // never prompts) honors it. Best-effort: a config-write failure must not
158
+ // fail an otherwise-successful init.
159
+ if (copilotDecision.persist !== undefined) {
160
+ try {
161
+ await persistCopilotCloudOptIn(projectPath, copilotDecision.persist);
162
+ }
163
+ catch {
164
+ // Non-fatal: the files (if any) were still written correctly.
165
+ }
166
+ }
167
+ // An explicit opt-out means "no cloud files here": clean up any that a
168
+ // previous run (or an older OpenSpec) generated. Only OpenSpec-managed
169
+ // files are removed — a user-customized file is preserved.
170
+ let copilotRemoved = 0;
171
+ if (copilotDecision.optedOut) {
172
+ try {
173
+ copilotRemoved = await removeCopilotCloudFiles(projectPath);
174
+ }
175
+ catch {
176
+ // Non-fatal: removal targets files from a prior run; a failure here
177
+ // just leaves them for the next `openspec update` to clean up.
178
+ }
179
+ }
180
+ // Report the cloud outcome from what is actually on disk after the write,
181
+ // not from the decision alone: writing over a user-owned file is a no-op,
182
+ // and the alternate-agent path can remove a managed file — so list only
183
+ // managed files that exist, and separately flag any left-untouched ones.
184
+ const copilotSucceeded = [...results.createdTools, ...results.refreshedTools].some((tool) => tool.value === 'github-copilot');
185
+ const wroteCloud = copilotDecision.write && copilotSucceeded;
186
+ const copilotPresent = wroteCloud ? await listManagedCloudFiles(projectPath) : [];
187
+ const copilotCollisions = wroteCloud ? await findUnmanagedCloudFiles(projectPath) : [];
146
188
  // Display success message
147
- this.displaySuccessMessage(projectPath, validatedTools, results, configStatus);
189
+ this.displaySuccessMessage(projectPath, validatedTools, results, configStatus, {
190
+ write: copilotDecision.write,
191
+ skippedUndecided: copilotDecision.skippedUndecided,
192
+ present: copilotPresent,
193
+ collisions: copilotCollisions,
194
+ removed: copilotRemoved,
195
+ });
196
+ if (results.failedTools.length > 0) {
197
+ throw new Error(`OpenSpec setup failed for: ${results.failedTools.map((tool) => tool.name).join(', ')}`);
198
+ }
148
199
  }
149
200
  // ═══════════════════════════════════════════════════════════
150
201
  // VALIDATION & SETUP
@@ -164,6 +215,59 @@ export class InitCommand {
164
215
  return false;
165
216
  return isInteractive({ interactive: this.interactiveOption });
166
217
  }
218
+ /**
219
+ * Decide whether to generate GitHub Copilot cloud files, and whether to
220
+ * persist that decision. Precedence:
221
+ * 1. `--copilot-cloud` / `--no-copilot-cloud` flag (explicit this run)
222
+ * 2. persisted opt-in in config.yaml
223
+ * 3. managed files already present (migration for pre-opt-in projects)
224
+ * 4. interactive confirm (default No)
225
+ * 5. non-interactive with no signal: skip, and don't persist a default
226
+ *
227
+ * @returns `write` — generate the files this run; `persist` — value to write
228
+ * back to config (undefined = leave config untouched); `optedOut` — the user
229
+ * explicitly declined, so any already-generated managed files should be
230
+ * removed; `skippedUndecided` — selected but no signal and couldn't ask, so
231
+ * the caller can hint that the opt-in exists.
232
+ */
233
+ async resolveCopilotCloudDecision(projectPath, tools) {
234
+ const copilotSelected = tools.some((tool) => tool.value === 'github-copilot');
235
+ if (!copilotSelected) {
236
+ // A flag that can't apply is a likely mistake — say so rather than no-op.
237
+ if (this.copilotCloudOption !== undefined) {
238
+ console.log(chalk.yellow('--copilot-cloud/--no-copilot-cloud was ignored because the github-copilot tool was not selected.'));
239
+ }
240
+ return { write: false, optedOut: false, skippedUndecided: false };
241
+ }
242
+ if (this.copilotCloudOption !== undefined) {
243
+ return {
244
+ write: this.copilotCloudOption,
245
+ persist: this.copilotCloudOption,
246
+ optedOut: !this.copilotCloudOption,
247
+ skippedUndecided: false,
248
+ };
249
+ }
250
+ const persistedOptIn = readCopilotCloudOptIn(projectPath);
251
+ if (typeof persistedOptIn === 'boolean') {
252
+ return { write: persistedOptIn, optedOut: !persistedOptIn, skippedUndecided: false };
253
+ }
254
+ if (await hasExistingManagedCloudFiles(projectPath)) {
255
+ return { write: true, optedOut: false, skippedUndecided: false };
256
+ }
257
+ if (this.canPromptInteractively()) {
258
+ const { confirm } = await import('@inquirer/prompts');
259
+ const answer = await confirm({
260
+ message: 'Set up GitHub Copilot cloud coding-agent files? This is for the GitHub-hosted ' +
261
+ 'Copilot coding agent (github.com), not Copilot in your editor. It writes two files: ' +
262
+ '.github/workflows/copilot-setup-steps.yml and .github/agents/openspec.agent.md.',
263
+ default: false,
264
+ });
265
+ return { write: answer, persist: answer, optedOut: !answer, skippedUndecided: false };
266
+ }
267
+ // Non-interactive with no explicit signal: don't write, and leave the
268
+ // decision unpersisted so a later interactive run can still prompt.
269
+ return { write: false, optedOut: false, skippedUndecided: true };
270
+ }
167
271
  resolveProfileOverride() {
168
272
  if (this.profileOverride === undefined) {
169
273
  return undefined;
@@ -411,24 +515,36 @@ export class InitCommand {
411
515
  }
412
516
  return deduped;
413
517
  }
414
- validateTools(toolIds, toolStates) {
518
+ validateTools(toolIds, toolStates, projectPath) {
415
519
  const validatedTools = [];
416
- for (const toolId of toolIds) {
520
+ const reconciledToolIds = toolIds.includes('codex') && toolIds.includes('agents')
521
+ ? toolIds.filter((toolId) => toolId !== 'agents')
522
+ : toolIds;
523
+ if (reconciledToolIds.length !== toolIds.length) {
524
+ console.log(chalk.dim('Codex and agents share .agents/skills; writing one tree with Codex and generic skill references.'));
525
+ }
526
+ for (const toolId of reconciledToolIds) {
417
527
  const tool = AI_TOOLS.find((t) => t.value === toolId);
418
528
  if (!tool) {
419
529
  const validToolIds = getToolsWithSkillsDir();
420
530
  throw new Error(`Unknown tool '${toolId}'. Valid tools:\n ${validToolIds.join('\n ')}`);
421
531
  }
422
- if (!tool.skillsDir) {
532
+ if (!toolSupportsSkills(tool)) {
423
533
  const validToolsWithSkills = getToolsWithSkillsDir();
424
534
  throw new Error(`Tool '${toolId}' does not support skill generation.\nTools with skill generation support:\n ${validToolsWithSkills.join('\n ')}`);
425
535
  }
426
536
  const preState = toolStates.get(tool.value);
537
+ const skillsPath = resolveToolSkillsDir(projectPath, tool);
538
+ const isGlobalSkillTarget = hasGlobalSkillTarget(tool);
427
539
  validatedTools.push({
428
540
  value: tool.value,
429
541
  name: tool.name,
430
542
  skillsDir: tool.skillsDir,
543
+ skillsPath,
544
+ skillsRoot: isGlobalSkillTarget ? skillsPath : projectPath,
545
+ isGlobalSkillTarget,
431
546
  wasConfigured: preState?.configured ?? false,
547
+ requiresIdeRestart: tool.requiresIdeRestart,
432
548
  });
433
549
  }
434
550
  return validatedTools;
@@ -446,6 +562,7 @@ export class InitCommand {
446
562
  path.join(openspecPath, 'changes', 'archive'),
447
563
  ];
448
564
  for (const dir of directories) {
565
+ FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
449
566
  await FileSystemUtils.createDirectory(dir);
450
567
  }
451
568
  return;
@@ -458,6 +575,7 @@ export class InitCommand {
458
575
  path.join(openspecPath, 'changes', 'archive'),
459
576
  ];
460
577
  for (const dir of directories) {
578
+ FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
461
579
  await FileSystemUtils.createDirectory(dir);
462
580
  }
463
581
  spinner.stopAndPersist({
@@ -476,7 +594,7 @@ export class InitCommand {
476
594
  * @param tools - Selected tools with their skill directory metadata
477
595
  * @returns Created, refreshed, and failed tools plus removed artifact counts
478
596
  */
479
- async generateSkillsAndCommands(projectPath, tools) {
597
+ async generateSkillsAndCommands(projectPath, tools, writeCopilotCloud) {
480
598
  const createdTools = [];
481
599
  const refreshedTools = [];
482
600
  const failedTools = [];
@@ -501,22 +619,24 @@ export class InitCommand {
501
619
  const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
502
620
  // Generate skill files if the selected delivery and tool capability allow skills
503
621
  if (shouldGenerateSkills) {
504
- // Use tool-specific skillsDir
505
- const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
506
622
  // Create skill directories and SKILL.md files
507
623
  for (const { template, dirName } of skillTemplates) {
508
- const skillDir = path.join(skillsDir, dirName);
624
+ const skillDir = path.join(tool.skillsPath, dirName);
509
625
  const skillFile = path.join(skillDir, 'SKILL.md');
510
626
  // Generate SKILL.md content with YAML frontmatter including generatedBy
511
627
  const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value));
512
628
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
513
629
  // Write the skill file
630
+ FileSystemUtils.assertPathWithin(tool.skillsRoot, skillFile);
514
631
  await FileSystemUtils.writeFile(skillFile, skillContent);
515
632
  }
633
+ writeSharedSkillTarget(projectPath, tool.value);
516
634
  }
517
- if (shouldRemoveSkillsForTool(tool.value, delivery)) {
518
- const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
519
- removedSkillCount += await this.removeSkillDirs(skillsDir);
635
+ if (shouldRemoveSkillsForTool(tool.value, delivery) && !tool.isGlobalSkillTarget) {
636
+ removedSkillCount += await this.removeSkillDirs(tool.skillsRoot, tool.skillsPath);
637
+ // Retain an explicit selection even when this delivery mode produces
638
+ // no skills, so a divergent legacy sibling cannot reclaim ownership.
639
+ writeSharedSkillTarget(projectPath, tool.value);
520
640
  }
521
641
  // Generate commands if delivery includes commands
522
642
  if (shouldGenerateCommands) {
@@ -524,7 +644,7 @@ export class InitCommand {
524
644
  if (adapter) {
525
645
  const generatedCommands = generateCommands(commandContents, adapter);
526
646
  for (const cmd of generatedCommands) {
527
- const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
647
+ const commandFile = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmd.path);
528
648
  await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
529
649
  }
530
650
  }
@@ -540,6 +660,9 @@ export class InitCommand {
540
660
  if (shouldReconcileCommandFilesForTool(tool.value, delivery)) {
541
661
  removedCommandCount += await this.removeCommandFiles(projectPath, tool.value);
542
662
  }
663
+ if (tool.value === 'github-copilot' && writeCopilotCloud) {
664
+ await writeCopilotCloudFiles(projectPath);
665
+ }
543
666
  spinner.succeed(`Setup complete for ${tool.name}`);
544
667
  if (tool.wasConfigured) {
545
668
  refreshedTools.push(tool);
@@ -553,6 +676,16 @@ export class InitCommand {
553
676
  failedTools.push({ name: tool.name, error: error });
554
677
  }
555
678
  }
679
+ for (const tool of [...createdTools, ...refreshedTools]) {
680
+ for (const migration of migrateLegacyToolDirs(projectPath, [tool.value], 'after-generation')) {
681
+ if (hasMovableContent(migration)) {
682
+ console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
683
+ }
684
+ const kept = keptInPlaceNotice(migration);
685
+ if (kept)
686
+ console.log(chalk.dim(kept));
687
+ }
688
+ }
556
689
  return {
557
690
  createdTools,
558
691
  refreshedTools,
@@ -576,6 +709,7 @@ export class InitCommand {
576
709
  }
577
710
  try {
578
711
  const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA });
712
+ FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), configPath);
579
713
  await FileSystemUtils.writeFile(configPath, yamlContent);
580
714
  return 'created';
581
715
  }
@@ -586,9 +720,9 @@ export class InitCommand {
586
720
  // ═══════════════════════════════════════════════════════════
587
721
  // UI & OUTPUT
588
722
  // ═══════════════════════════════════════════════════════════
589
- displaySuccessMessage(projectPath, tools, results, configStatus) {
723
+ displaySuccessMessage(projectPath, tools, results, configStatus, copilot) {
590
724
  console.log();
591
- console.log(chalk.bold('OpenSpec Setup Complete'));
725
+ console.log(chalk.bold(results.failedTools.length > 0 ? 'OpenSpec Setup Incomplete' : 'OpenSpec Setup Complete'));
592
726
  console.log();
593
727
  // Show created vs refreshed tools
594
728
  if (results.createdTools.length > 0) {
@@ -604,21 +738,56 @@ export class InitCommand {
604
738
  const profile = this.profileOverride ?? globalConfig.profile ?? 'core';
605
739
  const delivery = globalConfig.delivery ?? 'both';
606
740
  const workflows = getProfileWorkflows(profile, globalConfig.workflows);
607
- const toolDirs = [...new Set(successfulTools.map((t) => t.skillsDir))].join(', ');
608
- const skillCount = successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, delivery))
609
- ? getSkillTemplates(workflows).length
610
- : 0;
611
- const commandCount = successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, delivery))
612
- ? getCommandContents(workflows).length
613
- : 0;
614
- if (skillCount > 0 && commandCount > 0) {
615
- console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`);
616
- }
617
- else if (skillCount > 0) {
618
- console.log(`${skillCount} skills in ${toolDirs}/`);
741
+ const usesGlobalSkillTarget = successfulTools.some((tool) => tool.isGlobalSkillTarget);
742
+ if (!usesGlobalSkillTarget) {
743
+ const toolDirs = [
744
+ ...new Set(successfulTools
745
+ .map((tool) => tool.skillsDir)
746
+ .filter((skillsDir) => Boolean(skillsDir))),
747
+ ].join(', ');
748
+ const skillCount = successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, delivery))
749
+ ? getSkillTemplates(workflows).length
750
+ : 0;
751
+ const commandCount = successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, delivery))
752
+ ? getCommandContents(workflows).length
753
+ : 0;
754
+ if (skillCount > 0 && commandCount > 0) {
755
+ console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`);
756
+ }
757
+ else if (skillCount > 0) {
758
+ console.log(`${skillCount} skills in ${toolDirs}/`);
759
+ }
760
+ else if (commandCount > 0) {
761
+ console.log(`${commandCount} commands in ${toolDirs}/`);
762
+ }
619
763
  }
620
- else if (commandCount > 0) {
621
- console.log(`${commandCount} commands in ${toolDirs}/`);
764
+ else {
765
+ const skillTools = successfulTools.filter((tool) => shouldGenerateSkillsForTool(tool.value, delivery));
766
+ const skillCount = skillTools.length * getSkillTemplates(workflows).length;
767
+ if (skillCount > 0) {
768
+ const skillDirs = [...new Set(skillTools.map((tool) => tool.skillsPath))];
769
+ console.log(`${skillCount} skills in ${skillDirs.join(', ')}`);
770
+ }
771
+ const commandContents = getCommandContents(workflows);
772
+ const commandTools = successfulTools.filter((tool) => shouldGenerateCommandsForTool(tool.value, delivery));
773
+ const commandCount = commandTools.length * commandContents.length;
774
+ if (commandCount > 0) {
775
+ const commandDirs = [
776
+ ...new Set(commandTools.flatMap((tool) => {
777
+ const adapter = CommandAdapterRegistry.get(tool.value);
778
+ if (!adapter)
779
+ return [];
780
+ return commandContents.map((command) => {
781
+ const commandPath = adapter.getFilePath(command.id);
782
+ const absolutePath = path.isAbsolute(commandPath)
783
+ ? commandPath
784
+ : path.join(projectPath, commandPath);
785
+ return path.dirname(absolutePath);
786
+ });
787
+ })),
788
+ ];
789
+ console.log(`${commandCount} commands in ${commandDirs.join(', ')}`);
790
+ }
622
791
  }
623
792
  }
624
793
  // Show failures
@@ -638,6 +807,26 @@ export class InitCommand {
638
807
  if (results.removedSkillCount > 0) {
639
808
  console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`));
640
809
  }
810
+ // GitHub Copilot cloud files are opt-in — report what is actually on disk:
811
+ // list the managed files that now exist (never files we didn't write), flag
812
+ // any user-owned file we left untouched, note an opt-out cleanup, or (when
813
+ // skipped for want of a signal) say how to turn them on.
814
+ const copilotSucceeded = successfulTools.some((tool) => tool.value === 'github-copilot');
815
+ if (copilotSucceeded && copilot.write) {
816
+ if (copilot.present.length > 0) {
817
+ console.log(`GitHub Copilot cloud files: ${copilot.present.join(', ')}`);
818
+ }
819
+ if (copilot.collisions.length > 0) {
820
+ console.log(chalk.dim(`Left your existing ${copilot.collisions.join(' and ')} untouched — add the OpenSpec ` +
821
+ `install step by hand so the Copilot cloud agent can run openspec.`));
822
+ }
823
+ }
824
+ else if (copilotSucceeded && copilot.removed > 0) {
825
+ console.log(chalk.dim(`Removed: ${copilot.removed} Copilot cloud agent file(s) (opted out of cloud files)`));
826
+ }
827
+ else if (copilotSucceeded && copilot.skippedUndecided) {
828
+ console.log(chalk.dim("Skipped GitHub Copilot cloud files (opt-in). Enable with 'openspec init --copilot-cloud'."));
829
+ }
641
830
  // Show manual setup notes for tools that need extra configuration
642
831
  for (const tool of successfulTools) {
643
832
  const setupNote = AI_TOOLS.find((t) => t.value === tool.value)?.setupNote;
@@ -684,7 +873,13 @@ export class InitCommand {
684
873
  hint = `Start your first change: ${transformer ? transformer(command) : command} "your idea"`;
685
874
  }
686
875
  else if (shouldGenerateSkillsForTool(tool.value, activeDelivery)) {
687
- hint = `Start your first change: ${getSkillReferenceTransformer(tool.value)(command)} "your idea"`;
876
+ const skillReference = getSkillReferenceTransformer(tool.value)(command);
877
+ // Tools with no slash surface (e.g. Rovo Dev) reference skills as
878
+ // prose ("the openspec-propose skill"); phrase the hint so it reads
879
+ // as an instruction rather than a dead command with an argument.
880
+ hint = usesNaturalLanguageSkillReferences(tool.value)
881
+ ? `Start your first change: ask ${tool.name} to use ${skillReference} with "your idea"`
882
+ : `Start your first change: ${skillReference} "your idea"`;
688
883
  }
689
884
  else {
690
885
  continue;
@@ -736,14 +931,25 @@ export class InitCommand {
736
931
  console.log();
737
932
  console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
738
933
  console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`);
739
- // Restart instruction if any tools were configured and got a surface
740
- // (when nothing was generated there is nothing a restart would pick up);
741
- // only mention commands when commands were actually generated. Not "slash
742
- // commands": Amazon Q's generated files are prompt-library entries invoked
743
- // with @, so a restart line promising slash commands would be wrong for it.
744
- if ((results.createdTools.length > 0 || results.refreshedTools.length > 0) && (commandsGenerated || skillsGenerated)) {
934
+ // Restart instruction only when at least one IDE/editor-resident tool
935
+ // actually received a generated surface. Two conditions, coupled to the SAME
936
+ // tool: (1) its commands/skills are loaded by a long-running editor process
937
+ // (CLI tools pick the files up immediately, so a restart line would be wrong
938
+ // for them — see #1067), and (2) a surface was actually generated for it
939
+ // under the active delivery (an IDE tool that generated nothing has nothing a
940
+ // restart would pick up, even if a co-configured CLI tool did generate).
941
+ // Wording follows what the IDE tool itself generated, not the global
942
+ // aggregate: it must not say "commands" when the IDE tool only got skills
943
+ // while a co-configured CLI tool got commands. Not "slash commands" either:
944
+ // Amazon Q's generated files are prompt-library entries invoked with @, so a
945
+ // restart line promising slash commands would be wrong for it.
946
+ const restartCommandsGenerated = successfulTools.some((tool) => tool.requiresIdeRestart &&
947
+ shouldGenerateCommandsForTool(tool.value, activeDelivery));
948
+ const restartSkillsGenerated = successfulTools.some((tool) => tool.requiresIdeRestart &&
949
+ shouldGenerateSkillsForTool(tool.value, activeDelivery));
950
+ if (restartCommandsGenerated || restartSkillsGenerated) {
745
951
  console.log();
746
- console.log(chalk.white(commandsGenerated
952
+ console.log(chalk.white(restartCommandsGenerated
747
953
  ? 'Restart your IDE for the new commands to take effect.'
748
954
  : 'Restart your IDE for the new skills to take effect.'));
749
955
  }
@@ -757,18 +963,19 @@ export class InitCommand {
757
963
  spinner: PROGRESS_SPINNER,
758
964
  }).start();
759
965
  }
760
- async removeSkillDirs(skillsDir) {
966
+ async removeSkillDirs(skillsRoot, skillsDir) {
761
967
  let removed = 0;
762
968
  for (const workflow of ALL_WORKFLOWS) {
763
969
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
764
970
  if (!dirName)
765
971
  continue;
766
972
  const skillDir = path.join(skillsDir, dirName);
973
+ if (!fs.existsSync(skillDir))
974
+ continue;
975
+ FileSystemUtils.assertPathWithin(skillsRoot, skillDir);
767
976
  try {
768
- if (fs.existsSync(skillDir)) {
769
- await fs.promises.rm(skillDir, { recursive: true, force: true });
770
- removed++;
771
- }
977
+ await fs.promises.rm(skillDir, { recursive: true, force: true });
978
+ removed++;
772
979
  }
773
980
  catch {
774
981
  // Ignore errors
@@ -783,7 +990,7 @@ export class InitCommand {
783
990
  return 0;
784
991
  for (const workflow of ALL_WORKFLOWS) {
785
992
  const cmdPath = adapter.getFilePath(workflow);
786
- const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
993
+ const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath);
787
994
  try {
788
995
  if (fs.existsSync(fullPath)) {
789
996
  await fs.promises.unlink(fullPath);
@@ -206,6 +206,16 @@ export declare function getLegacyWorkflowIdsForTool(detection: LegacyDetectionRe
206
206
  * callers can safely perform the immediate, non-deferred cleanup pass.
207
207
  */
208
208
  export declare function omitGlobalLegacyPromptFiles(detection: LegacyDetectionResult): LegacyDetectionResult;
209
+ /**
210
+ * Returns a detection snapshot with the repo-local slash-command artifacts of
211
+ * the given tools removed. The legacy-upgrade path uses this to skip cleaning a
212
+ * tool's legacy files when its replacement was deliberately NOT written — e.g. a
213
+ * Codex upgrade suppressed because the shared `.agents` root is already owned by
214
+ * another tool. Deleting the legacy prompt without writing its replacement would
215
+ * violate the cleanup contract ("remove X because replacement Y now exists") and
216
+ * strip the tool's only OpenSpec integration.
217
+ */
218
+ export declare function omitToolLegacyArtifacts(detection: LegacyDetectionResult, toolIds: readonly string[]): LegacyDetectionResult;
209
219
  /**
210
220
  * Builds a detection snapshot containing only the selected global Codex prompt
211
221
  * matches for replacement-gated cleanup.
@@ -35,7 +35,6 @@ export const LEGACY_SLASH_COMMAND_PATHS = {
35
35
  'lingma': { type: 'directory', path: '.lingma/commands/openspec' },
36
36
  'crush': { type: 'directory', path: '.crush/commands/openspec' },
37
37
  'gemini': { type: 'directory', path: '.gemini/commands/openspec' },
38
- 'costrict': { type: 'directory', path: '.cospec/openspec/commands' },
39
38
  // File-based: individual openspec-*.md files in a commands/workflows/prompts folder
40
39
  'cursor': { type: 'files', pattern: '.cursor/commands/openspec-*.md' },
41
40
  // Keyed by the tool id these map back to, so the pre-opsx Windsurf files
@@ -54,9 +53,12 @@ export const LEGACY_SLASH_COMMAND_PATHS = {
54
53
  'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },
55
54
  'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
56
55
  'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
57
- 'junie': { type: 'files', pattern: ['.junie/commands/opsx-*.md', '.junie/commands/openspec-*.md'] },
58
56
  'qwen': { type: 'files', pattern: ['.qwen/commands/opsx-*.toml', '.qwen/commands/openspec-*.toml'] },
59
57
  'codex': { type: 'files', pattern: '.codex/prompts/openspec-*.md' },
58
+ // Keep this file-scoped: the CoStrict adapter writes `opsx-*.md` into the
59
+ // same folder, so a directory entry removes the live command files — and
60
+ // anything else the user keeps there — on every run.
61
+ 'costrict': { type: 'files', pattern: '.cospec/openspec/commands/openspec-*.md' },
60
62
  };
61
63
  /**
62
64
  * Final OpenSpec-managed global Codex prompt filenames mapped to the workflows
@@ -612,41 +614,42 @@ export function formatDeferredGlobalPromptSummary(detection) {
612
614
  */
613
615
  export function getToolsFromLegacyArtifacts(detection) {
614
616
  const tools = new Set();
615
- // Match directories to tool IDs
616
617
  for (const dir of detection.slashCommandDirs) {
617
- for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
618
- if (pattern.type === 'directory' && pattern.path === dir) {
619
- tools.add(toolId);
620
- break;
621
- }
622
- }
618
+ const toolId = legacyToolIdForDir(dir);
619
+ if (toolId)
620
+ tools.add(toolId);
623
621
  }
624
- // Match files to tool IDs using glob patterns
625
622
  for (const file of detection.slashCommandFiles) {
626
- // Normalize file path to use forward slashes for consistent matching (Windows compatibility)
627
- const normalizedFile = normalizePathForMatch(file);
628
- for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
629
- if (pattern.type === 'files' && pattern.pattern) {
630
- const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
631
- let matched = false;
632
- for (const p of patterns) {
633
- const regex = globToRegex(p);
634
- if (regex.test(normalizedFile)) {
635
- tools.add(toolId);
636
- matched = true;
637
- break;
638
- }
639
- }
640
- if (matched)
641
- break;
642
- }
643
- }
623
+ const toolId = legacyToolIdForFile(file);
624
+ if (toolId)
625
+ tools.add(toolId);
644
626
  }
645
627
  for (const prompt of getLegacyGlobalPromptMatches(detection)) {
646
628
  tools.add(prompt.toolId);
647
629
  }
648
630
  return Array.from(tools);
649
631
  }
632
+ /** The tool that owns a repo-local legacy slash-command directory, if any. */
633
+ function legacyToolIdForDir(dir) {
634
+ for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
635
+ if (pattern.type === 'directory' && pattern.path === dir)
636
+ return toolId;
637
+ }
638
+ return undefined;
639
+ }
640
+ /** The tool that owns a repo-local legacy slash-command file, if any. */
641
+ function legacyToolIdForFile(file) {
642
+ // Normalize to forward slashes so the glob patterns match on Windows too.
643
+ const normalizedFile = normalizePathForMatch(file);
644
+ for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
645
+ if (pattern.type !== 'files' || !pattern.pattern)
646
+ continue;
647
+ const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
648
+ if (patterns.some((p) => globToRegex(p).test(normalizedFile)))
649
+ return toolId;
650
+ }
651
+ return undefined;
652
+ }
650
653
  /**
651
654
  * Normalizes global Codex prompt matches so callers can rely on workflow-aware
652
655
  * metadata even when older detection results only carry file paths.
@@ -697,6 +700,27 @@ export function omitGlobalLegacyPromptFiles(detection) {
697
700
  nextDetection.hasLegacyArtifacts = hasLegacyArtifacts(nextDetection);
698
701
  return nextDetection;
699
702
  }
703
+ /**
704
+ * Returns a detection snapshot with the repo-local slash-command artifacts of
705
+ * the given tools removed. The legacy-upgrade path uses this to skip cleaning a
706
+ * tool's legacy files when its replacement was deliberately NOT written — e.g. a
707
+ * Codex upgrade suppressed because the shared `.agents` root is already owned by
708
+ * another tool. Deleting the legacy prompt without writing its replacement would
709
+ * violate the cleanup contract ("remove X because replacement Y now exists") and
710
+ * strip the tool's only OpenSpec integration.
711
+ */
712
+ export function omitToolLegacyArtifacts(detection, toolIds) {
713
+ if (toolIds.length === 0)
714
+ return detection;
715
+ const skip = new Set(toolIds);
716
+ const nextDetection = {
717
+ ...detection,
718
+ slashCommandDirs: detection.slashCommandDirs.filter((dir) => !skip.has(legacyToolIdForDir(dir) ?? '')),
719
+ slashCommandFiles: detection.slashCommandFiles.filter((file) => !skip.has(legacyToolIdForFile(file) ?? '')),
720
+ };
721
+ nextDetection.hasLegacyArtifacts = hasLegacyArtifacts(nextDetection);
722
+ return nextDetection;
723
+ }
700
724
  /**
701
725
  * Builds a detection snapshot containing only the selected global Codex prompt
702
726
  * matches for replacement-gated cleanup.