@fission-ai/openspec 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/index.js +6 -1
  3. package/dist/commands/change.js +15 -1
  4. package/dist/commands/schema.js +89 -20
  5. package/dist/commands/spec.js +30 -6
  6. package/dist/commands/validate.js +2 -2
  7. package/dist/commands/workflow/instructions.js +32 -27
  8. package/dist/commands/workflow/status.js +2 -2
  9. package/dist/commands/workflow/templates.js +15 -5
  10. package/dist/core/archive.d.ts +14 -0
  11. package/dist/core/archive.js +1265 -151
  12. package/dist/core/artifact-graph/index.d.ts +1 -1
  13. package/dist/core/artifact-graph/index.js +1 -1
  14. package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
  15. package/dist/core/artifact-graph/instruction-loader.js +15 -5
  16. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  17. package/dist/core/artifact-graph/outputs.js +67 -5
  18. package/dist/core/artifact-graph/resolver.js +35 -9
  19. package/dist/core/artifact-graph/types.js +21 -3
  20. package/dist/core/available-tools.d.ts +2 -2
  21. package/dist/core/available-tools.js +14 -3
  22. package/dist/core/change-metadata/schema.d.ts +1 -0
  23. package/dist/core/change-metadata/schema.js +7 -0
  24. package/dist/core/change-status-policy.js +2 -2
  25. package/dist/core/completions/command-registry.js +8 -0
  26. package/dist/core/config-schema.d.ts +3 -0
  27. package/dist/core/config-schema.js +28 -1
  28. package/dist/core/config.d.ts +3 -0
  29. package/dist/core/config.js +24 -2
  30. package/dist/core/file-state.js +56 -24
  31. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  32. package/dist/core/github-copilot/cloud-agent.js +497 -0
  33. package/dist/core/global-config.d.ts +11 -0
  34. package/dist/core/init.d.ts +23 -0
  35. package/dist/core/init.js +234 -39
  36. package/dist/core/legacy-cleanup.js +4 -2
  37. package/dist/core/migration.d.ts +6 -4
  38. package/dist/core/migration.js +78 -22
  39. package/dist/core/parsers/requirement-blocks.d.ts +10 -0
  40. package/dist/core/parsers/requirement-blocks.js +60 -0
  41. package/dist/core/parsers/spec-structure.d.ts +1 -1
  42. package/dist/core/parsers/spec-structure.js +17 -1
  43. package/dist/core/profile-sync-drift.js +40 -5
  44. package/dist/core/project-config.d.ts +3 -0
  45. package/dist/core/project-config.js +31 -1
  46. package/dist/core/shared/index.d.ts +1 -0
  47. package/dist/core/shared/index.js +1 -0
  48. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  49. package/dist/core/shared/skill-content-equivalence.js +41 -0
  50. package/dist/core/shared/skill-paths.d.ts +13 -0
  51. package/dist/core/shared/skill-paths.js +23 -0
  52. package/dist/core/shared/tool-detection.js +66 -35
  53. package/dist/core/shared-skill-target.d.ts +16 -0
  54. package/dist/core/shared-skill-target.js +156 -0
  55. package/dist/core/specs-apply.d.ts +72 -0
  56. package/dist/core/specs-apply.js +448 -55
  57. package/dist/core/templates/skill-templates.d.ts +1 -1
  58. package/dist/core/templates/skill-templates.js +1 -1
  59. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  60. package/dist/core/templates/workflows/apply-change.js +22 -182
  61. package/dist/core/templates/workflows/archive-change.js +8 -4
  62. package/dist/core/templates/workflows/bulk-archive-change.js +30 -26
  63. package/dist/core/templates/workflows/continue-change.js +6 -6
  64. package/dist/core/templates/workflows/explore.js +42 -18
  65. package/dist/core/templates/workflows/onboard.js +9 -3
  66. package/dist/core/templates/workflows/propose.js +72 -20
  67. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  68. package/dist/core/templates/workflows/store-selection.js +1 -1
  69. package/dist/core/templates/workflows/sync-specs.js +96 -18
  70. package/dist/core/templates/workflows/update-change.js +10 -8
  71. package/dist/core/update.d.ts +1 -0
  72. package/dist/core/update.js +103 -24
  73. package/dist/core/validation/validator.d.ts +23 -3
  74. package/dist/core/validation/validator.js +134 -18
  75. package/dist/core/version-check.js +6 -11
  76. package/dist/telemetry/config.d.ts +3 -4
  77. package/dist/telemetry/index.d.ts +9 -4
  78. package/dist/telemetry/index.js +20 -8
  79. package/dist/utils/change-metadata.d.ts +17 -4
  80. package/dist/utils/change-metadata.js +26 -4
  81. package/dist/utils/ci.d.ts +12 -0
  82. package/dist/utils/ci.js +16 -0
  83. package/dist/utils/command-references.d.ts +14 -2
  84. package/dist/utils/command-references.js +45 -3
  85. package/dist/utils/file-system.d.ts +10 -0
  86. package/dist/utils/file-system.js +70 -0
  87. package/dist/utils/interactive.d.ts +23 -0
  88. package/dist/utils/interactive.js +32 -0
  89. package/dist/utils/spec-discovery.d.ts +4 -4
  90. package/dist/utils/spec-discovery.js +19 -6
  91. package/dist/utils/task-progress.d.ts +17 -0
  92. package/dist/utils/task-progress.js +41 -13
  93. package/package.json +1 -1
  94. package/schemas/spec-driven/schema.yaml +8 -6
  95. 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,23 +515,34 @@ 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,
432
547
  });
433
548
  }
@@ -446,6 +561,7 @@ export class InitCommand {
446
561
  path.join(openspecPath, 'changes', 'archive'),
447
562
  ];
448
563
  for (const dir of directories) {
564
+ FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
449
565
  await FileSystemUtils.createDirectory(dir);
450
566
  }
451
567
  return;
@@ -458,6 +574,7 @@ export class InitCommand {
458
574
  path.join(openspecPath, 'changes', 'archive'),
459
575
  ];
460
576
  for (const dir of directories) {
577
+ FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
461
578
  await FileSystemUtils.createDirectory(dir);
462
579
  }
463
580
  spinner.stopAndPersist({
@@ -476,7 +593,7 @@ export class InitCommand {
476
593
  * @param tools - Selected tools with their skill directory metadata
477
594
  * @returns Created, refreshed, and failed tools plus removed artifact counts
478
595
  */
479
- async generateSkillsAndCommands(projectPath, tools) {
596
+ async generateSkillsAndCommands(projectPath, tools, writeCopilotCloud) {
480
597
  const createdTools = [];
481
598
  const refreshedTools = [];
482
599
  const failedTools = [];
@@ -501,22 +618,24 @@ export class InitCommand {
501
618
  const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
502
619
  // Generate skill files if the selected delivery and tool capability allow skills
503
620
  if (shouldGenerateSkills) {
504
- // Use tool-specific skillsDir
505
- const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
506
621
  // Create skill directories and SKILL.md files
507
622
  for (const { template, dirName } of skillTemplates) {
508
- const skillDir = path.join(skillsDir, dirName);
623
+ const skillDir = path.join(tool.skillsPath, dirName);
509
624
  const skillFile = path.join(skillDir, 'SKILL.md');
510
625
  // Generate SKILL.md content with YAML frontmatter including generatedBy
511
626
  const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value));
512
627
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
513
628
  // Write the skill file
629
+ FileSystemUtils.assertPathWithin(tool.skillsRoot, skillFile);
514
630
  await FileSystemUtils.writeFile(skillFile, skillContent);
515
631
  }
632
+ writeSharedSkillTarget(projectPath, tool.value);
516
633
  }
517
- if (shouldRemoveSkillsForTool(tool.value, delivery)) {
518
- const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
519
- removedSkillCount += await this.removeSkillDirs(skillsDir);
634
+ if (shouldRemoveSkillsForTool(tool.value, delivery) && !tool.isGlobalSkillTarget) {
635
+ removedSkillCount += await this.removeSkillDirs(tool.skillsRoot, tool.skillsPath);
636
+ // Retain an explicit selection even when this delivery mode produces
637
+ // no skills, so a divergent legacy sibling cannot reclaim ownership.
638
+ writeSharedSkillTarget(projectPath, tool.value);
520
639
  }
521
640
  // Generate commands if delivery includes commands
522
641
  if (shouldGenerateCommands) {
@@ -524,7 +643,7 @@ export class InitCommand {
524
643
  if (adapter) {
525
644
  const generatedCommands = generateCommands(commandContents, adapter);
526
645
  for (const cmd of generatedCommands) {
527
- const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
646
+ const commandFile = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmd.path);
528
647
  await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
529
648
  }
530
649
  }
@@ -540,6 +659,9 @@ export class InitCommand {
540
659
  if (shouldReconcileCommandFilesForTool(tool.value, delivery)) {
541
660
  removedCommandCount += await this.removeCommandFiles(projectPath, tool.value);
542
661
  }
662
+ if (tool.value === 'github-copilot' && writeCopilotCloud) {
663
+ await writeCopilotCloudFiles(projectPath);
664
+ }
543
665
  spinner.succeed(`Setup complete for ${tool.name}`);
544
666
  if (tool.wasConfigured) {
545
667
  refreshedTools.push(tool);
@@ -553,6 +675,16 @@ export class InitCommand {
553
675
  failedTools.push({ name: tool.name, error: error });
554
676
  }
555
677
  }
678
+ for (const tool of [...createdTools, ...refreshedTools]) {
679
+ for (const migration of migrateLegacyToolDirs(projectPath, [tool.value], 'after-generation')) {
680
+ if (hasMovableContent(migration)) {
681
+ console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
682
+ }
683
+ const kept = keptInPlaceNotice(migration);
684
+ if (kept)
685
+ console.log(chalk.dim(kept));
686
+ }
687
+ }
556
688
  return {
557
689
  createdTools,
558
690
  refreshedTools,
@@ -576,6 +708,7 @@ export class InitCommand {
576
708
  }
577
709
  try {
578
710
  const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA });
711
+ FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), configPath);
579
712
  await FileSystemUtils.writeFile(configPath, yamlContent);
580
713
  return 'created';
581
714
  }
@@ -586,9 +719,9 @@ export class InitCommand {
586
719
  // ═══════════════════════════════════════════════════════════
587
720
  // UI & OUTPUT
588
721
  // ═══════════════════════════════════════════════════════════
589
- displaySuccessMessage(projectPath, tools, results, configStatus) {
722
+ displaySuccessMessage(projectPath, tools, results, configStatus, copilot) {
590
723
  console.log();
591
- console.log(chalk.bold('OpenSpec Setup Complete'));
724
+ console.log(chalk.bold(results.failedTools.length > 0 ? 'OpenSpec Setup Incomplete' : 'OpenSpec Setup Complete'));
592
725
  console.log();
593
726
  // Show created vs refreshed tools
594
727
  if (results.createdTools.length > 0) {
@@ -604,21 +737,56 @@ export class InitCommand {
604
737
  const profile = this.profileOverride ?? globalConfig.profile ?? 'core';
605
738
  const delivery = globalConfig.delivery ?? 'both';
606
739
  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}/`);
740
+ const usesGlobalSkillTarget = successfulTools.some((tool) => tool.isGlobalSkillTarget);
741
+ if (!usesGlobalSkillTarget) {
742
+ const toolDirs = [
743
+ ...new Set(successfulTools
744
+ .map((tool) => tool.skillsDir)
745
+ .filter((skillsDir) => Boolean(skillsDir))),
746
+ ].join(', ');
747
+ const skillCount = successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, delivery))
748
+ ? getSkillTemplates(workflows).length
749
+ : 0;
750
+ const commandCount = successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, delivery))
751
+ ? getCommandContents(workflows).length
752
+ : 0;
753
+ if (skillCount > 0 && commandCount > 0) {
754
+ console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`);
755
+ }
756
+ else if (skillCount > 0) {
757
+ console.log(`${skillCount} skills in ${toolDirs}/`);
758
+ }
759
+ else if (commandCount > 0) {
760
+ console.log(`${commandCount} commands in ${toolDirs}/`);
761
+ }
619
762
  }
620
- else if (commandCount > 0) {
621
- console.log(`${commandCount} commands in ${toolDirs}/`);
763
+ else {
764
+ const skillTools = successfulTools.filter((tool) => shouldGenerateSkillsForTool(tool.value, delivery));
765
+ const skillCount = skillTools.length * getSkillTemplates(workflows).length;
766
+ if (skillCount > 0) {
767
+ const skillDirs = [...new Set(skillTools.map((tool) => tool.skillsPath))];
768
+ console.log(`${skillCount} skills in ${skillDirs.join(', ')}`);
769
+ }
770
+ const commandContents = getCommandContents(workflows);
771
+ const commandTools = successfulTools.filter((tool) => shouldGenerateCommandsForTool(tool.value, delivery));
772
+ const commandCount = commandTools.length * commandContents.length;
773
+ if (commandCount > 0) {
774
+ const commandDirs = [
775
+ ...new Set(commandTools.flatMap((tool) => {
776
+ const adapter = CommandAdapterRegistry.get(tool.value);
777
+ if (!adapter)
778
+ return [];
779
+ return commandContents.map((command) => {
780
+ const commandPath = adapter.getFilePath(command.id);
781
+ const absolutePath = path.isAbsolute(commandPath)
782
+ ? commandPath
783
+ : path.join(projectPath, commandPath);
784
+ return path.dirname(absolutePath);
785
+ });
786
+ })),
787
+ ];
788
+ console.log(`${commandCount} commands in ${commandDirs.join(', ')}`);
789
+ }
622
790
  }
623
791
  }
624
792
  // Show failures
@@ -638,6 +806,26 @@ export class InitCommand {
638
806
  if (results.removedSkillCount > 0) {
639
807
  console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`));
640
808
  }
809
+ // GitHub Copilot cloud files are opt-in — report what is actually on disk:
810
+ // list the managed files that now exist (never files we didn't write), flag
811
+ // any user-owned file we left untouched, note an opt-out cleanup, or (when
812
+ // skipped for want of a signal) say how to turn them on.
813
+ const copilotSucceeded = successfulTools.some((tool) => tool.value === 'github-copilot');
814
+ if (copilotSucceeded && copilot.write) {
815
+ if (copilot.present.length > 0) {
816
+ console.log(`GitHub Copilot cloud files: ${copilot.present.join(', ')}`);
817
+ }
818
+ if (copilot.collisions.length > 0) {
819
+ console.log(chalk.dim(`Left your existing ${copilot.collisions.join(' and ')} untouched — add the OpenSpec ` +
820
+ `install step by hand so the Copilot cloud agent can run openspec.`));
821
+ }
822
+ }
823
+ else if (copilotSucceeded && copilot.removed > 0) {
824
+ console.log(chalk.dim(`Removed: ${copilot.removed} Copilot cloud agent file(s) (opted out of cloud files)`));
825
+ }
826
+ else if (copilotSucceeded && copilot.skippedUndecided) {
827
+ console.log(chalk.dim("Skipped GitHub Copilot cloud files (opt-in). Enable with 'openspec init --copilot-cloud'."));
828
+ }
641
829
  // Show manual setup notes for tools that need extra configuration
642
830
  for (const tool of successfulTools) {
643
831
  const setupNote = AI_TOOLS.find((t) => t.value === tool.value)?.setupNote;
@@ -684,7 +872,13 @@ export class InitCommand {
684
872
  hint = `Start your first change: ${transformer ? transformer(command) : command} "your idea"`;
685
873
  }
686
874
  else if (shouldGenerateSkillsForTool(tool.value, activeDelivery)) {
687
- hint = `Start your first change: ${getSkillReferenceTransformer(tool.value)(command)} "your idea"`;
875
+ const skillReference = getSkillReferenceTransformer(tool.value)(command);
876
+ // Tools with no slash surface (e.g. Rovo Dev) reference skills as
877
+ // prose ("the openspec-propose skill"); phrase the hint so it reads
878
+ // as an instruction rather than a dead command with an argument.
879
+ hint = usesNaturalLanguageSkillReferences(tool.value)
880
+ ? `Start your first change: ask ${tool.name} to use ${skillReference} with "your idea"`
881
+ : `Start your first change: ${skillReference} "your idea"`;
688
882
  }
689
883
  else {
690
884
  continue;
@@ -757,18 +951,19 @@ export class InitCommand {
757
951
  spinner: PROGRESS_SPINNER,
758
952
  }).start();
759
953
  }
760
- async removeSkillDirs(skillsDir) {
954
+ async removeSkillDirs(skillsRoot, skillsDir) {
761
955
  let removed = 0;
762
956
  for (const workflow of ALL_WORKFLOWS) {
763
957
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
764
958
  if (!dirName)
765
959
  continue;
766
960
  const skillDir = path.join(skillsDir, dirName);
961
+ if (!fs.existsSync(skillDir))
962
+ continue;
963
+ FileSystemUtils.assertPathWithin(skillsRoot, skillDir);
767
964
  try {
768
- if (fs.existsSync(skillDir)) {
769
- await fs.promises.rm(skillDir, { recursive: true, force: true });
770
- removed++;
771
- }
965
+ await fs.promises.rm(skillDir, { recursive: true, force: true });
966
+ removed++;
772
967
  }
773
968
  catch {
774
969
  // Ignore errors
@@ -783,7 +978,7 @@ export class InitCommand {
783
978
  return 0;
784
979
  for (const workflow of ALL_WORKFLOWS) {
785
980
  const cmdPath = adapter.getFilePath(workflow);
786
- const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
981
+ const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath);
787
982
  try {
788
983
  if (fs.existsSync(fullPath)) {
789
984
  await fs.promises.unlink(fullPath);
@@ -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
@@ -14,6 +14,8 @@ export interface LegacyToolRoot {
14
14
  * location may still be the live one for somebody.
15
15
  */
16
16
  needsConsent: boolean;
17
+ /** Migrations that need a freshly generated destination run afterward. */
18
+ timing?: 'before-generation' | 'after-generation';
17
19
  }
18
20
  /**
19
21
  * Former tool roots whose OpenSpec-managed content belongs under the tool's
@@ -32,8 +34,8 @@ export interface LegacyToolMigration {
32
34
  commandFiles: number;
33
35
  /**
34
36
  * OpenSpec-managed files left under the legacy root because the copy there
35
- * differs from the one that survives — the user edited it, so it is reported
36
- * rather than dropped.
37
+ * differs materially from the one that survives, so it is reported rather
38
+ * than dropped.
37
39
  */
38
40
  keptInPlace: number;
39
41
  /** Whether this move needs the user's consent first */
@@ -43,7 +45,7 @@ export interface LegacyToolMigration {
43
45
  * Reports the OpenSpec content sitting under each tool's legacy root, without
44
46
  * moving anything. Callers use this to ask before a move that needs consent.
45
47
  */
46
- export declare function findLegacyToolMigrations(projectPath: string): LegacyToolMigration[];
48
+ export declare function findLegacyToolMigrations(projectPath: string, timing?: 'before-generation' | 'after-generation'): LegacyToolMigration[];
47
49
  /**
48
50
  * Moves OpenSpec-managed skill directories (openspec-*) and command files
49
51
  * (opsx-*) from a tool's legacy root to its current one. When the destination
@@ -55,7 +57,7 @@ export declare function findLegacyToolMigrations(projectPath: string): LegacyToo
55
57
  * @param toolIds - Restrict the move to these tools; omit to move every tool
56
58
  * whose legacy root needs no consent
57
59
  */
58
- export declare function migrateLegacyToolDirs(projectPath: string, toolIds?: string[]): LegacyToolMigration[];
60
+ export declare function migrateLegacyToolDirs(projectPath: string, toolIds?: string[], timing?: 'before-generation' | 'after-generation'): LegacyToolMigration[];
59
61
  /**
60
62
  * Summarizes what a migration moved, e.g. "6 skills and 6 commands".
61
63
  */