@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
@@ -13,8 +13,8 @@ import { FileSystemUtils } from '../utils/file-system.js';
13
13
  import { getSkillReferenceTransformer, getTransformerForTool, transformToSkillReferences } from '../utils/command-references.js';
14
14
  import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
15
15
  import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js';
16
- import { getToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, } from './shared/index.js';
17
- import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatDeferredGlobalPromptSummary, formatCleanupSummary, formatDetectionSummary, getLegacyGlobalPromptMatches, getLegacyWorkflowIdsForTool, getToolsFromLegacyArtifacts, omitGlobalLegacyPromptFiles, pickGlobalLegacyPromptFiles, } from './legacy-cleanup.js';
16
+ import { getToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, getToolsWithSkillsDir, hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, } from './shared/index.js';
17
+ import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatDeferredGlobalPromptSummary, formatCleanupSummary, formatDetectionSummary, getLegacyGlobalPromptMatches, getLegacyWorkflowIdsForTool, getToolsFromLegacyArtifacts, omitGlobalLegacyPromptFiles, omitToolLegacyArtifacts, pickGlobalLegacyPromptFiles, } from './legacy-cleanup.js';
18
18
  import { isInteractive } from '../utils/interactive.js';
19
19
  import { getGlobalConfig } from './global-config.js';
20
20
  import { getProfileWorkflows, ALL_WORKFLOWS, CORE_WORKFLOWS } from './profiles.js';
@@ -23,6 +23,8 @@ import { getAvailableTools } from './available-tools.js';
23
23
  import { WORKFLOW_TO_SKILL_DIR, getConfiguredToolsForProfileSync, getToolsNeedingProfileSync, } from './profile-sync-drift.js';
24
24
  import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, findLegacyToolMigrations, migrateLegacyToolDirs, describeLegacyMigration, legacyMigrationNotice, keptInPlaceNotice, hasMovableContent, } from './migration.js';
25
25
  import { resolveCommandSurfaceCapability, resolveCommandInvocation, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
26
+ import { writeSharedSkillTarget, sharedSkillRootOwner } from './shared-skill-target.js';
27
+ import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles, isCopilotCloudEnabled, readCopilotCloudOptIn, findUnmanagedCloudFiles } from './github-copilot/cloud-agent.js';
26
28
  const require = createRequire(import.meta.url);
27
29
  const { version: OPENSPEC_VERSION } = require('../../package.json');
28
30
  /**
@@ -80,6 +82,7 @@ export class UpdateCommand {
80
82
  const { newlyConfiguredTools, workflowOverrides: legacyWorkflowOverrides, deferredGlobalCleanup, } = legacyUpgrade;
81
83
  // 5. Find configured tools
82
84
  const configuredTools = getConfiguredToolsForProfileSync(resolvedProjectPath);
85
+ const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])];
83
86
  if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) {
84
87
  if (deferredGlobalCleanup) {
85
88
  await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup);
@@ -94,6 +97,7 @@ export class UpdateCommand {
94
97
  }
95
98
  return;
96
99
  }
100
+ await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools);
97
101
  console.log(chalk.yellow('No configured tools found.'));
98
102
  console.log(chalk.dim('Run "openspec init" to set up tools.'));
99
103
  return;
@@ -107,7 +111,13 @@ export class UpdateCommand {
107
111
  const statusByTool = new Map(toolStatuses.map((status) => [status.toolId, status]));
108
112
  // 7. Smart update detection
109
113
  const toolsNeedingVersionUpdate = toolStatuses
110
- .filter((s) => s.needsUpdate)
114
+ .filter((s) => {
115
+ if (!s.needsUpdate || delivery !== 'commands') {
116
+ return s.needsUpdate;
117
+ }
118
+ const tool = AI_TOOLS.find((candidate) => candidate.value === s.toolId);
119
+ return !tool || !hasGlobalSkillTarget(tool);
120
+ })
111
121
  .map((s) => s.toolId);
112
122
  const toolsNeedingConfigSync = getToolsNeedingProfileSync(resolvedProjectPath, desiredWorkflows, delivery, configuredTools);
113
123
  const toolsToUpdateSet = new Set([
@@ -121,6 +131,7 @@ export class UpdateCommand {
121
131
  }
122
132
  // All tools are up to date
123
133
  this.displayUpToDateMessage(toolStatuses);
134
+ await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools);
124
135
  // Still check for new tool directories and extra workflows
125
136
  this.detectNewTools(resolvedProjectPath, configuredTools);
126
137
  this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows);
@@ -153,11 +164,12 @@ export class UpdateCommand {
153
164
  let removedDeselectedSkillCount = 0;
154
165
  for (const toolId of toolsToUpdate) {
155
166
  const tool = AI_TOOLS.find((t) => t.value === toolId);
156
- if (!tool?.skillsDir)
167
+ if (!tool || !toolSupportsSkills(tool))
157
168
  continue;
158
169
  const spinner = ora(`Updating ${tool.name}...`).start();
159
170
  try {
160
- const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills');
171
+ const skillsDir = resolveToolSkillsDir(resolvedProjectPath, tool);
172
+ const skillsRoot = hasGlobalSkillTarget(tool) ? skillsDir : resolvedProjectPath;
161
173
  const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
162
174
  const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
163
175
  const toolWorkflows = legacyWorkflowOverrides[tool.value] ?? desiredWorkflows;
@@ -170,13 +182,18 @@ export class UpdateCommand {
170
182
  const skillFile = path.join(skillDir, 'SKILL.md');
171
183
  const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value));
172
184
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
185
+ FileSystemUtils.assertPathWithin(skillsRoot, skillFile);
173
186
  await FileSystemUtils.writeFile(skillFile, skillContent);
174
187
  }
175
- removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, toolWorkflows);
188
+ writeSharedSkillTarget(resolvedProjectPath, tool.value);
189
+ removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsRoot, skillsDir, toolWorkflows);
176
190
  }
177
191
  // Delete skill directories if delivery is commands-only
178
- if (shouldRemoveSkillsForTool(tool.value, delivery)) {
179
- removedSkillCount += await this.removeSkillDirs(skillsDir);
192
+ if (shouldRemoveSkillsForTool(tool.value, delivery) && !hasGlobalSkillTarget(tool)) {
193
+ removedSkillCount += await this.removeSkillDirs(skillsRoot, skillsDir);
194
+ // Persist the selected owner even when commands-only delivery leaves
195
+ // this target with no generated skills.
196
+ writeSharedSkillTarget(resolvedProjectPath, tool.value);
180
197
  // A tool with no command adapter now has zero OpenSpec artifacts;
181
198
  // say so like init does, rather than deleting its skills silently
182
199
  // and letting tool detection re-suggest an init that would also
@@ -191,7 +208,7 @@ export class UpdateCommand {
191
208
  if (adapter) {
192
209
  const generatedCommands = generateCommands(commandContents, adapter);
193
210
  for (const cmd of generatedCommands) {
194
- const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path);
211
+ const commandFile = FileSystemUtils.resolveProjectArtifactPath(resolvedProjectPath, cmd.path);
195
212
  await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
196
213
  }
197
214
  removedDeselectedCommandCount += await this.removeUnselectedCommandFiles(resolvedProjectPath, toolId, toolWorkflows);
@@ -206,6 +223,12 @@ export class UpdateCommand {
206
223
  }
207
224
  spinner.succeed(`Updated ${tool.name}`);
208
225
  updatedTools.push(tool.name);
226
+ for (const migration of migrateLegacyToolDirs(resolvedProjectPath, [tool.value], 'after-generation')) {
227
+ if (hasMovableContent(migration)) {
228
+ console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
229
+ }
230
+ this.reportKeptInPlace(migration);
231
+ }
209
232
  }
210
233
  catch (error) {
211
234
  spinner.fail(`Failed to update ${tool.name}`);
@@ -282,7 +305,7 @@ export class UpdateCommand {
282
305
  }
283
306
  console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
284
307
  }
285
- const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])];
308
+ await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools);
286
309
  // 13. Detect new tool directories not currently configured
287
310
  this.detectNewTools(resolvedProjectPath, configuredAndNewTools);
288
311
  // 14. Display note about extra workflows not in profile
@@ -296,6 +319,51 @@ export class UpdateCommand {
296
319
  }
297
320
  console.log();
298
321
  console.log(chalk.dim('Restart your IDE for changes to take effect.'));
322
+ if (failedTools.length > 0) {
323
+ throw new Error(`OpenSpec update failed for: ${failedTools.map((tool) => tool.name).join(', ')}`);
324
+ }
325
+ }
326
+ async syncCopilotCloudFiles(projectPath, configuredTools) {
327
+ try {
328
+ if (includesGitHubCopilot(configuredTools)) {
329
+ // Cloud files are opt-in (see cloud-agent.ts). `update` never prompts,
330
+ // so it only refreshes files the user has already opted into (via
331
+ // `openspec init` or a `githubCopilot.cloudAgent: true` config), or that
332
+ // a pre-opt-in project already has. Opting in is a deliberate init/config
333
+ // step, never a silent side effect of running update.
334
+ if (await isCopilotCloudEnabled(projectPath)) {
335
+ await writeCopilotCloudFiles(projectPath);
336
+ const collisions = await findUnmanagedCloudFiles(projectPath);
337
+ if (collisions.length > 0) {
338
+ console.log(chalk.dim(`Left your existing ${collisions.join(' and ')} untouched — add the OpenSpec ` +
339
+ `install step by hand so the Copilot cloud agent can run openspec.`));
340
+ }
341
+ return;
342
+ }
343
+ // Explicit opt-out (githubCopilot.cloudAgent: false) means "not here":
344
+ // remove any managed files a prior opt-in left behind (customized files
345
+ // are preserved). If the user simply never decided, stay quiet unless
346
+ // we're at an interactive terminal, where a one-line hint aids discovery.
347
+ if (readCopilotCloudOptIn(projectPath) === false) {
348
+ const removed = await removeCopilotCloudFiles(projectPath);
349
+ if (removed > 0) {
350
+ console.log(chalk.dim(`Removed: ${removed} Copilot cloud agent file(s) (opted out of cloud files)`));
351
+ }
352
+ }
353
+ else if (isInteractive()) {
354
+ console.log(chalk.dim("GitHub Copilot cloud coding-agent files are available (opt-in). Enable with 'openspec init --copilot-cloud'."));
355
+ }
356
+ return;
357
+ }
358
+ const removed = await removeCopilotCloudFiles(projectPath);
359
+ if (removed > 0) {
360
+ console.log(chalk.dim(`Removed: ${removed} Copilot cloud agent file(s) (github-copilot not configured)`));
361
+ }
362
+ }
363
+ catch (error) {
364
+ const message = error instanceof Error ? error.message : String(error);
365
+ console.warn(`Warning: failed to sync Copilot cloud agent files: ${message}`);
366
+ }
299
367
  }
300
368
  /**
301
369
  * Display message when all tools are up to date.
@@ -387,18 +455,19 @@ export class UpdateCommand {
387
455
  * Removes skill directories for workflows when delivery changed to commands-only.
388
456
  * Returns the number of directories removed.
389
457
  */
390
- async removeSkillDirs(skillsDir) {
458
+ async removeSkillDirs(skillsRoot, skillsDir) {
391
459
  let removed = 0;
392
460
  for (const workflow of ALL_WORKFLOWS) {
393
461
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
394
462
  if (!dirName)
395
463
  continue;
396
464
  const skillDir = path.join(skillsDir, dirName);
465
+ if (!fs.existsSync(skillDir))
466
+ continue;
467
+ FileSystemUtils.assertPathWithin(skillsRoot, skillDir);
397
468
  try {
398
- if (fs.existsSync(skillDir)) {
399
- await fs.promises.rm(skillDir, { recursive: true, force: true });
400
- removed++;
401
- }
469
+ await fs.promises.rm(skillDir, { recursive: true, force: true });
470
+ removed++;
402
471
  }
403
472
  catch {
404
473
  // Ignore errors
@@ -410,7 +479,7 @@ export class UpdateCommand {
410
479
  * Removes skill directories for workflows that are no longer selected in the active profile.
411
480
  * Returns the number of directories removed.
412
481
  */
413
- async removeUnselectedSkillDirs(skillsDir, desiredWorkflows) {
482
+ async removeUnselectedSkillDirs(skillsRoot, skillsDir, desiredWorkflows) {
414
483
  const desiredSet = new Set(desiredWorkflows);
415
484
  let removed = 0;
416
485
  for (const workflow of ALL_WORKFLOWS) {
@@ -420,11 +489,12 @@ export class UpdateCommand {
420
489
  if (!dirName)
421
490
  continue;
422
491
  const skillDir = path.join(skillsDir, dirName);
492
+ if (!fs.existsSync(skillDir))
493
+ continue;
494
+ FileSystemUtils.assertPathWithin(skillsRoot, skillDir);
423
495
  try {
424
- if (fs.existsSync(skillDir)) {
425
- await fs.promises.rm(skillDir, { recursive: true, force: true });
426
- removed++;
427
- }
496
+ await fs.promises.rm(skillDir, { recursive: true, force: true });
497
+ removed++;
428
498
  }
429
499
  catch {
430
500
  // Ignore errors
@@ -443,7 +513,7 @@ export class UpdateCommand {
443
513
  return 0;
444
514
  for (const workflow of ALL_WORKFLOWS) {
445
515
  const cmdPath = adapter.getFilePath(workflow);
446
- const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
516
+ const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath);
447
517
  try {
448
518
  if (fs.existsSync(fullPath)) {
449
519
  await fs.promises.unlink(fullPath);
@@ -470,7 +540,7 @@ export class UpdateCommand {
470
540
  if (desiredSet.has(workflow))
471
541
  continue;
472
542
  const cmdPath = adapter.getFilePath(workflow);
473
- const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
543
+ const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath);
474
544
  try {
475
545
  if (fs.existsSync(fullPath)) {
476
546
  await fs.promises.unlink(fullPath);
@@ -577,7 +647,7 @@ export class UpdateCommand {
577
647
  const canPrompt = isInteractive();
578
648
  if (this.force) {
579
649
  const legacyUpgrade = await this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
580
- await this.performImmediateLegacyCleanup(projectPath, detection);
650
+ await this.performImmediateLegacyCleanup(projectPath, detection, legacyUpgrade.skippedSharedSkillTools);
581
651
  return {
582
652
  ...legacyUpgrade,
583
653
  deferredGlobalCleanup: pickGlobalLegacyPromptFiles(detection, detection.globalSlashCommandFiles),
@@ -598,7 +668,7 @@ export class UpdateCommand {
598
668
  });
599
669
  if (shouldCleanup) {
600
670
  const legacyUpgrade = await this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery);
601
- await this.performImmediateLegacyCleanup(projectPath, detection);
671
+ await this.performImmediateLegacyCleanup(projectPath, detection, legacyUpgrade.skippedSharedSkillTools);
602
672
  return {
603
673
  ...legacyUpgrade,
604
674
  deferredGlobalCleanup: pickGlobalLegacyPromptFiles(detection, detection.globalSlashCommandFiles),
@@ -613,8 +683,10 @@ export class UpdateCommand {
613
683
  /**
614
684
  * Cleans approved repo-local legacy artifacts before configured tools refresh.
615
685
  */
616
- async performImmediateLegacyCleanup(projectPath, detection) {
617
- const immediateDetection = omitGlobalLegacyPromptFiles(detection);
686
+ async performImmediateLegacyCleanup(projectPath, detection, skippedSharedSkillTools = []) {
687
+ // Tools whose upgrade was skipped (shared root owned by another) had no
688
+ // replacement written, so their repo-local legacy files must be preserved.
689
+ const immediateDetection = omitToolLegacyArtifacts(omitGlobalLegacyPromptFiles(detection), skippedSharedSkillTools);
618
690
  if (immediateDetection.hasLegacyArtifacts) {
619
691
  await this.performLegacyCleanup(projectPath, immediateDetection);
620
692
  }
@@ -718,14 +790,16 @@ export class UpdateCommand {
718
790
  const inferredCodexWorkflows = getLegacyWorkflowIdsForTool(detection, 'codex');
719
791
  // Create skills/commands for selected tools using effective profile+delivery.
720
792
  const newlyConfigured = [];
793
+ const skippedSharedSkillTools = [];
721
794
  const workflowOverrides = {};
722
795
  for (const toolId of selectedTools) {
723
796
  const tool = AI_TOOLS.find((t) => t.value === toolId);
724
- if (!tool?.skillsDir)
797
+ if (!tool || !toolSupportsSkills(tool))
725
798
  continue;
726
799
  const spinner = ora(`Setting up ${tool.name}...`).start();
727
800
  try {
728
- const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
801
+ const skillsDir = resolveToolSkillsDir(projectPath, tool);
802
+ const skillsRoot = hasGlobalSkillTarget(tool) ? skillsDir : projectPath;
729
803
  const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
730
804
  const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
731
805
  const toolWorkflows = (tool.value === 'codex' && inferredCodexWorkflows.length > 0
@@ -736,6 +810,30 @@ export class UpdateCommand {
736
810
  }
737
811
  const skillTemplates = getSkillTemplates(toolWorkflows);
738
812
  const commandContents = getCommandContents(toolWorkflows);
813
+ // A shared skills root (e.g. `.agents`) already owned by another tool
814
+ // must not be overwritten by a tool inferred from legacy artifacts: a
815
+ // Codex install detected only from global `~/.codex/prompts` would
816
+ // otherwise rewrite an existing vendor-neutral `agents` tree with
817
+ // Codex-specific syntax and flip its ownership marker `agents → codex`.
818
+ // Leave the established owner in place. (init applies the same
819
+ // one-writer rule up front when both targets are selected.)
820
+ //
821
+ // Skipping here means the tool is never recorded as configured, so a
822
+ // persistent legacy signal re-offers it on later runs. Because no
823
+ // replacement is written, this tool is also exempted from immediate
824
+ // legacy cleanup (see skippedSharedSkillTools) — otherwise a repo-local
825
+ // `.codex/prompts` would be deleted with nothing put in its place. That
826
+ // repeat is idempotent and harmless — the alternative is the silent
827
+ // hijack this prevents.
828
+ const sharedOwner = shouldGenerateSkills
829
+ ? sharedSkillRootOwner(projectPath, tool.value)
830
+ : undefined;
831
+ if (sharedOwner) {
832
+ const ownerName = AI_TOOLS.find((candidate) => candidate.value === sharedOwner)?.name ?? sharedOwner;
833
+ spinner.info(`Skipped ${tool.name}: ${tool.skillsDir}/skills is already managed by another tool (${ownerName}).`);
834
+ skippedSharedSkillTools.push(tool.value);
835
+ continue;
836
+ }
739
837
  // Create skill files when delivery includes skills
740
838
  if (shouldGenerateSkills) {
741
839
  for (const { template, dirName } of skillTemplates) {
@@ -743,8 +841,10 @@ export class UpdateCommand {
743
841
  const skillFile = path.join(skillDir, 'SKILL.md');
744
842
  const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value));
745
843
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
844
+ FileSystemUtils.assertPathWithin(skillsRoot, skillFile);
746
845
  await FileSystemUtils.writeFile(skillFile, skillContent);
747
846
  }
847
+ writeSharedSkillTarget(projectPath, tool.value);
748
848
  }
749
849
  // Create commands when delivery includes commands
750
850
  if (shouldGenerateCommands) {
@@ -752,13 +852,19 @@ export class UpdateCommand {
752
852
  if (adapter) {
753
853
  const generatedCommands = generateCommands(commandContents, adapter);
754
854
  for (const cmd of generatedCommands) {
755
- const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
855
+ const commandFile = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmd.path);
756
856
  await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
757
857
  }
758
858
  }
759
859
  }
760
860
  spinner.succeed(`Setup complete for ${tool.name}`);
761
861
  newlyConfigured.push(toolId);
862
+ for (const migration of migrateLegacyToolDirs(projectPath, [tool.value], 'after-generation')) {
863
+ if (hasMovableContent(migration)) {
864
+ console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
865
+ }
866
+ this.reportKeptInPlace(migration);
867
+ }
762
868
  }
763
869
  catch (error) {
764
870
  spinner.fail(`Failed to set up ${tool.name}`);
@@ -768,7 +874,7 @@ export class UpdateCommand {
768
874
  if (newlyConfigured.length > 0) {
769
875
  console.log();
770
876
  }
771
- return { newlyConfiguredTools: newlyConfigured, workflowOverrides };
877
+ return { newlyConfiguredTools: newlyConfigured, workflowOverrides, skippedSharedSkillTools };
772
878
  }
773
879
  }
774
880
  //# sourceMappingURL=update.js.map
@@ -0,0 +1,16 @@
1
+ export interface TaskNumberingDocument {
2
+ path: string;
3
+ content: string;
4
+ }
5
+ export interface TaskNumberingIssue {
6
+ path: string;
7
+ line: number;
8
+ message: string;
9
+ }
10
+ /**
11
+ * Finds ambiguous task references across the task files tracked by a change.
12
+ * Numbering is interpreted only inside `## N.` groups. Unnumbered sections,
13
+ * unnumbered tasks, and files without numbered groups are intentionally ignored.
14
+ */
15
+ export declare function findTaskNumberingIssues(documents: readonly TaskNumberingDocument[]): TaskNumberingIssue[];
16
+ //# sourceMappingURL=task-numbering.d.ts.map
@@ -0,0 +1,57 @@
1
+ import { parseTaskLines } from '../../utils/task-progress.js';
2
+ const LEVEL_TWO_HEADING = /^ {0,3}##(?!#)(?:[ \t]+|[ \t]*\r?$)/;
3
+ const NUMBERED_GROUP_HEADING = /^ {0,3}##[ \t]+(\d+)\.(?:[ \t]|\r?$)/;
4
+ const TASK_ID = /^(\d+(?:\.\d+)+(?:[A-Za-z]+)?)(?=\s|$)/;
5
+ /**
6
+ * Finds ambiguous task references across the task files tracked by a change.
7
+ * Numbering is interpreted only inside `## N.` groups. Unnumbered sections,
8
+ * unnumbered tasks, and files without numbered groups are intentionally ignored.
9
+ */
10
+ export function findTaskNumberingIssues(documents) {
11
+ const issues = [];
12
+ const firstLocationById = new Map();
13
+ for (const document of documents) {
14
+ const lines = document.content.split('\n');
15
+ if (!lines.some((line) => NUMBERED_GROUP_HEADING.test(line)))
16
+ continue;
17
+ let currentGroup;
18
+ lines.forEach((line, index) => {
19
+ if (LEVEL_TWO_HEADING.test(line)) {
20
+ currentGroup = line.match(NUMBERED_GROUP_HEADING)?.[1];
21
+ }
22
+ if (currentGroup === undefined)
23
+ return;
24
+ const task = parseTaskLines(line)[0];
25
+ const id = task?.description.match(TASK_ID)?.[1];
26
+ if (!id)
27
+ return;
28
+ const lineNumber = index + 1;
29
+ const taskGroup = id.split('.')[0];
30
+ const normalizedTaskGroup = taskGroup.replace(/^0+(?=\d)/, '');
31
+ const normalizedCurrentGroup = currentGroup.replace(/^0+(?=\d)/, '');
32
+ if (normalizedTaskGroup !== normalizedCurrentGroup) {
33
+ issues.push({
34
+ path: document.path,
35
+ line: lineNumber,
36
+ message: `Task "${id}" is under group ${currentGroup}, but its leading number points to group ${taskGroup}. Move it to group ${taskGroup} or renumber it.`,
37
+ });
38
+ }
39
+ const firstLocation = firstLocationById.get(id);
40
+ if (firstLocation !== undefined) {
41
+ const firstDeclaration = firstLocation.path === document.path
42
+ ? `on line ${firstLocation.line}`
43
+ : `in ${firstLocation.path} on line ${firstLocation.line}`;
44
+ issues.push({
45
+ path: document.path,
46
+ line: lineNumber,
47
+ message: `Task ID "${id}" is duplicated; it was first declared ${firstDeclaration}.`,
48
+ });
49
+ }
50
+ else {
51
+ firstLocationById.set(id, { path: document.path, line: lineNumber });
52
+ }
53
+ });
54
+ }
55
+ return issues;
56
+ }
57
+ //# sourceMappingURL=task-numbering.js.map
@@ -12,12 +12,35 @@ export declare class Validator {
12
12
  * Validate delta-formatted spec files under a change directory.
13
13
  * Enforces:
14
14
  * - At least one delta across all files
15
- * - ADDED/MODIFIED: each requirement has SHALL/MUST and at least one scenario
15
+ * - ADDED/MODIFIED: each requirement has at least one scenario; missing
16
+ * English SHALL/MUST keywords are guidance unless strict mode is enabled
16
17
  * - REMOVED: names only; no scenario/description required
17
18
  * - RENAMED: pairs well-formed
18
19
  * - No duplicates within sections; no cross-section conflicts per spec
20
+ *
21
+ * When `options.mainSpecsDir` is given, MODIFIED blocks are also checked
22
+ * against the current main specs for the scenario loss archive refuses to
23
+ * apply (#1477). When `options.projectRoot` is given, the schema's tracked
24
+ * task files are checked for ambiguous numbering (#1520). Omitting either
25
+ * option keeps existing library and archive callers behaving as before.
26
+ */
27
+ validateChangeDeltaSpecs(changeDir: string, options?: {
28
+ mainSpecsDir?: string;
29
+ projectRoot?: string;
30
+ }): Promise<ValidationReport>;
31
+ private collectTaskNumberingIssues;
32
+ /**
33
+ * Report MODIFIED requirements whose block omits a scenario the main spec
34
+ * still carries. Uses the same comparison archive applies, so validate can
35
+ * only report what archive would refuse.
36
+ *
37
+ * Silent when the main spec or the requirement header is absent: applying a
38
+ * MODIFIED against a base that is not there yet is a different failure (a
39
+ * sister change still in flight is the legitimate case), and archive is the
40
+ * gate for it. A spec that exists but cannot be read is not absent, though —
41
+ * archive aborts on it, so reporting it beats calling the change valid.
19
42
  */
20
- validateChangeDeltaSpecs(changeDir: string): Promise<ValidationReport>;
43
+ private findScenarioLossIssues;
21
44
  private formatInvalidMarkerMessage;
22
45
  private convertZodErrors;
23
46
  private applySpecRules;
@@ -29,7 +52,7 @@ export declare class Validator {
29
52
  private extractRequirementText;
30
53
  private containsShallOrMust;
31
54
  /**
32
- * Build an error message for a requirement block whose body lacks SHALL/MUST.
55
+ * Build a message for a requirement block whose body lacks SHALL/MUST.
33
56
  *
34
57
  * When the SHALL/MUST keyword already appears in the requirement header (e.g.
35
58
  * `### Requirement: The system SHALL ...`) the original generic error