@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
@@ -12,8 +12,12 @@ import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js';
12
12
  import { COMMAND_IDS } from './shared/tool-detection.js';
13
13
  import { ALL_WORKFLOWS } from './profiles.js';
14
14
  import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js';
15
+ import { FileSystemUtils } from '../utils/file-system.js';
16
+ import { isSharedSkillTargetActive } from './shared-skill-target.js';
17
+ import { isLegacyCodexSkillEquivalentToCurrent } from './shared/skill-content-equivalence.js';
15
18
  import path from 'path';
16
19
  import * as fs from 'fs';
20
+ import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js';
17
21
  /**
18
22
  * Former tool roots whose OpenSpec-managed content belongs under the tool's
19
23
  * current skillsDir. User files are never touched.
@@ -27,6 +31,9 @@ export const LEGACY_TOOL_ROOTS = {
27
31
  // default — but a pre-rebrand Windsurf build reads ONLY .windsurf/, and
28
32
  // nothing on disk tells that user apart, so the move is offered, not taken.
29
33
  devin: [{ root: '.windsurf', needsConsent: true }],
34
+ // Codex now reads the canonical shared .agents root. Generate the current
35
+ // replacement first so a divergent legacy file is preserved, not overwritten.
36
+ codex: [{ root: '.codex', needsConsent: false, timing: 'after-generation' }],
30
37
  };
31
38
  function classifyManagedFile(source, destination) {
32
39
  if (isSamePath(source, destination))
@@ -34,7 +41,12 @@ function classifyManagedFile(source, destination) {
34
41
  if (!fs.existsSync(destination))
35
42
  return 'move';
36
43
  try {
37
- return fs.readFileSync(source, 'utf-8') === fs.readFileSync(destination, 'utf-8')
44
+ const sourceContent = fs.readFileSync(source, 'utf-8');
45
+ const destinationContent = fs.readFileSync(destination, 'utf-8');
46
+ const equivalentGeneratedSkills = path.basename(source) === 'SKILL.md' &&
47
+ path.basename(destination) === 'SKILL.md' &&
48
+ isLegacyCodexSkillEquivalentToCurrent(sourceContent, destinationContent);
49
+ return sourceContent === destinationContent || equivalentGeneratedSkills
38
50
  ? 'drop'
39
51
  : 'keep';
40
52
  }
@@ -64,8 +76,8 @@ function legacyCommandPath(commandPath, currentRoot, legacyRoot) {
64
76
  * Reports the OpenSpec content sitting under each tool's legacy root, without
65
77
  * moving anything. Callers use this to ask before a move that needs consent.
66
78
  */
67
- export function findLegacyToolMigrations(projectPath) {
68
- return collectLegacyToolMigrations(projectPath, false);
79
+ export function findLegacyToolMigrations(projectPath, timing = 'before-generation') {
80
+ return collectLegacyToolMigrations(projectPath, false, undefined, timing);
69
81
  }
70
82
  /**
71
83
  * Moves OpenSpec-managed skill directories (openspec-*) and command files
@@ -78,10 +90,10 @@ export function findLegacyToolMigrations(projectPath) {
78
90
  * @param toolIds - Restrict the move to these tools; omit to move every tool
79
91
  * whose legacy root needs no consent
80
92
  */
81
- export function migrateLegacyToolDirs(projectPath, toolIds) {
82
- return collectLegacyToolMigrations(projectPath, true, toolIds);
93
+ export function migrateLegacyToolDirs(projectPath, toolIds, timing = 'before-generation') {
94
+ return collectLegacyToolMigrations(projectPath, true, toolIds, timing);
83
95
  }
84
- function collectLegacyToolMigrations(projectPath, apply, toolIds) {
96
+ function collectLegacyToolMigrations(projectPath, apply, toolIds, timing = 'before-generation') {
85
97
  const migrations = [];
86
98
  for (const tool of AI_TOOLS) {
87
99
  if (!tool.skillsDir)
@@ -89,19 +101,31 @@ function collectLegacyToolMigrations(projectPath, apply, toolIds) {
89
101
  if (toolIds && !toolIds.includes(tool.value))
90
102
  continue;
91
103
  for (const legacy of LEGACY_TOOL_ROOTS[tool.value] ?? []) {
104
+ const legacyTiming = legacy.timing ?? 'before-generation';
105
+ if (legacyTiming !== timing)
106
+ continue;
92
107
  if (legacy.root === tool.skillsDir)
93
108
  continue;
94
109
  // Without an explicit tool list, only moves that need no consent run.
95
110
  if (apply && !toolIds && legacy.needsConsent)
96
111
  continue;
97
- if (!fs.existsSync(path.join(projectPath, legacy.root)))
112
+ const legacyRootPath = path.join(projectPath, legacy.root);
113
+ if (!fs.existsSync(legacyRootPath))
114
+ continue;
115
+ try {
116
+ FileSystemUtils.assertProjectArtifactPath(projectPath, legacyRootPath);
117
+ FileSystemUtils.assertProjectArtifactPath(projectPath, path.join(projectPath, tool.skillsDir));
118
+ }
119
+ catch {
120
+ console.warn(`Skipping legacy ${legacy.root}/ migration because the directory resolves outside this project.`);
98
121
  continue;
99
- const skills = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply);
122
+ }
123
+ const skills = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply, legacyTiming === 'after-generation');
100
124
  const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply);
101
125
  if (apply) {
102
- removeDirIfEmpty(path.join(projectPath, legacy.root, 'skills'));
103
- removeDirIfEmpty(path.join(projectPath, legacy.root, 'workflows'));
104
- removeDirIfEmpty(path.join(projectPath, legacy.root));
126
+ removeDirIfEmpty(path.join(legacyRootPath, 'skills'));
127
+ removeDirIfEmpty(path.join(legacyRootPath, 'workflows'));
128
+ removeDirIfEmpty(legacyRootPath);
105
129
  }
106
130
  // Kept-only results are retained deliberately. When every legacy file
107
131
  // differs from its counterpart nothing is movable, and dropping the
@@ -122,7 +146,7 @@ function collectLegacyToolMigrations(projectPath, apply, toolIds) {
122
146
  }
123
147
  return migrations;
124
148
  }
125
- function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply) {
149
+ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply, requireDestination = false) {
126
150
  const legacySkillsDir = path.join(projectPath, legacyRoot, 'skills');
127
151
  if (!fs.existsSync(legacySkillsDir))
128
152
  return { moved: 0, kept: 0 };
@@ -137,6 +161,12 @@ function migrateSkillDirs(projectPath, currentRoot, legacyRoot, apply) {
137
161
  continue;
138
162
  const destination = path.join(currentSkillsDir, dirName);
139
163
  const destinationSkill = path.join(destination, 'SKILL.md');
164
+ if (requireDestination && !fs.existsSync(destinationSkill))
165
+ continue;
166
+ if (!areProjectArtifacts(projectPath, sourceSkill, destinationSkill)) {
167
+ console.warn(`Skipping legacy ${legacyRoot}/skills/${dirName} migration because it resolves outside this project.`);
168
+ continue;
169
+ }
140
170
  const disposition = classifyManagedFile(sourceSkill, destinationSkill);
141
171
  if (disposition === 'skip')
142
172
  continue;
@@ -186,6 +216,10 @@ function migrateCommandFiles(projectPath, tool, legacyRoot, apply) {
186
216
  if (!fs.existsSync(source))
187
217
  continue;
188
218
  const destination = path.join(projectPath, currentPath);
219
+ if (!areProjectArtifacts(projectPath, source, destination)) {
220
+ console.warn(`Skipping legacy ${legacyPath} migration because it resolves outside this project.`);
221
+ continue;
222
+ }
189
223
  const disposition = classifyManagedFile(source, destination);
190
224
  if (disposition === 'skip')
191
225
  continue;
@@ -277,6 +311,17 @@ function isSamePath(a, b) {
277
311
  return false;
278
312
  }
279
313
  }
314
+ function areProjectArtifacts(projectPath, ...artifactPaths) {
315
+ try {
316
+ for (const artifactPath of artifactPaths) {
317
+ FileSystemUtils.assertProjectArtifactPath(projectPath, artifactPath);
318
+ }
319
+ return true;
320
+ }
321
+ catch {
322
+ return false;
323
+ }
324
+ }
280
325
  function removeDirIfEmpty(dirPath) {
281
326
  try {
282
327
  if (fs.readdirSync(dirPath).length === 0) {
@@ -287,20 +332,31 @@ function removeDirIfEmpty(dirPath) {
287
332
  // Missing or non-empty directory — nothing to do
288
333
  }
289
334
  }
290
- function scanInstalledWorkflowArtifacts(projectPath, tools) {
335
+ function scanInstalledWorkflowArtifacts(projectPath, tools, includeLegacySkills = false) {
291
336
  const installed = new Set();
292
337
  let hasSkills = false;
293
338
  let hasCommands = false;
294
339
  for (const tool of tools) {
295
- if (!tool.skillsDir)
340
+ if (!toolSupportsSkills(tool))
296
341
  continue;
297
- const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
298
- for (const workflowId of ALL_WORKFLOWS) {
299
- const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId];
300
- const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md');
301
- if (fs.existsSync(skillFile)) {
302
- installed.add(workflowId);
303
- hasSkills = true;
342
+ const skillsDirs = [];
343
+ if (tool.globalSkillsDir) {
344
+ skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
345
+ }
346
+ else if (isSharedSkillTargetActive(projectPath, tool.value)) {
347
+ skillsDirs.push(resolveToolSkillsDir(projectPath, tool));
348
+ if (includeLegacySkills) {
349
+ skillsDirs.push(...(tool.legacySkillsDirs ?? []).map((root) => path.join(projectPath, root, 'skills')));
350
+ }
351
+ }
352
+ for (const skillsDir of skillsDirs) {
353
+ for (const workflowId of ALL_WORKFLOWS) {
354
+ const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId];
355
+ const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md');
356
+ if (fs.existsSync(skillFile)) {
357
+ installed.add(workflowId);
358
+ hasSkills = true;
359
+ }
304
360
  }
305
361
  }
306
362
  const adapter = CommandAdapterRegistry.get(tool.value);
@@ -366,7 +422,7 @@ export function migrateIfNeeded(projectPath, tools) {
366
422
  return;
367
423
  }
368
424
  // Scan for installed workflows
369
- const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools);
425
+ const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools, true);
370
426
  const installedWorkflows = artifacts.workflows;
371
427
  if (installedWorkflows.length === 0) {
372
428
  // No workflows installed, new user — defaults will apply
@@ -53,4 +53,14 @@ export interface DeltaPlan {
53
53
  * Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
54
54
  */
55
55
  export declare function parseDeltaSpec(content: string): DeltaPlan;
56
+ /**
57
+ * Scenario names the current requirement block has and the incoming
58
+ * (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
59
+ * so every name reported here would be dropped from the main spec.
60
+ *
61
+ * Shared by archive (which refuses to apply the block) and validate (which
62
+ * reports the same loss at authoring time, #1477), so the two cannot disagree
63
+ * about what counts as a dropped scenario.
64
+ */
65
+ export declare function findMissingCurrentScenarios(current: RequirementBlock, incoming: RequirementBlock): string[];
56
66
  //# sourceMappingURL=requirement-blocks.d.ts.map
@@ -257,4 +257,64 @@ function parseRenamedPairs(sectionBody) {
257
257
  }
258
258
  return pairs;
259
259
  }
260
+ /**
261
+ * Scenario names the current requirement block has and the incoming
262
+ * (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
263
+ * so every name reported here would be dropped from the main spec.
264
+ *
265
+ * Shared by archive (which refuses to apply the block) and validate (which
266
+ * reports the same loss at authoring time, #1477), so the two cannot disagree
267
+ * about what counts as a dropped scenario.
268
+ */
269
+ export function findMissingCurrentScenarios(current, incoming) {
270
+ // Multiplicity-aware: a name present N times in current and M times in
271
+ // incoming means max(0, N - M) instances are missing. Set membership would
272
+ // treat N>M as fully covered and let archive silently drop duplicates
273
+ // (residual #1246 / duplicate-scenario-name blind spot).
274
+ const remainingIncoming = new Map();
275
+ for (const scenario of parseScenarioBlocks(incoming.raw)) {
276
+ const name = scenario.name;
277
+ remainingIncoming.set(name, (remainingIncoming.get(name) ?? 0) + 1);
278
+ }
279
+ const missing = [];
280
+ for (const scenario of parseScenarioBlocks(current.raw)) {
281
+ const name = scenario.name;
282
+ const remaining = remainingIncoming.get(name) ?? 0;
283
+ if (remaining > 0) {
284
+ remainingIncoming.set(name, remaining - 1);
285
+ }
286
+ else {
287
+ missing.push(name);
288
+ }
289
+ }
290
+ return missing;
291
+ }
292
+ function parseScenarioBlocks(requirementRaw) {
293
+ const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n');
294
+ // A `#### Scenario:` inside a fenced example is not a real scenario. The
295
+ // validator's countScenarios already ignores fenced lines; the drift check
296
+ // must agree with it, or a fenced sample can false-abort an archive (or
297
+ // mask a genuinely dropped scenario).
298
+ const mask = buildCodeFenceMask(lines);
299
+ const scenarios = [];
300
+ let index = 0;
301
+ while (index < lines.length) {
302
+ const headerMatch = mask[index] ? null : lines[index].match(/^####\s*Scenario:\s*(.+)\s*$/);
303
+ if (!headerMatch) {
304
+ index++;
305
+ continue;
306
+ }
307
+ const start = index;
308
+ const name = headerMatch[1].trim();
309
+ index++;
310
+ while (index < lines.length && (mask[index] || !/^####\s*Scenario:\s*(.+)\s*$/.test(lines[index]))) {
311
+ index++;
312
+ }
313
+ scenarios.push({
314
+ name,
315
+ raw: lines.slice(start, index).join('\n').trimEnd(),
316
+ });
317
+ }
318
+ return scenarios;
319
+ }
260
320
  //# sourceMappingURL=requirement-blocks.js.map
@@ -1,5 +1,5 @@
1
1
  export interface MainSpecStructureIssue {
2
- kind: 'delta-header' | 'requirement-outside-requirements';
2
+ kind: 'delta-header' | 'requirement-outside-requirements' | 'duplicate-requirement';
3
3
  line: number;
4
4
  header: string;
5
5
  message: string;
@@ -8,6 +8,7 @@ export function findMainSpecStructureIssues(content) {
8
8
  const stripped = stripFencedCodeBlocksPreservingLines(normalized);
9
9
  const lines = stripped.split('\n');
10
10
  const issues = [];
11
+ const requirementLines = new Map();
11
12
  const requirementsHeaderIndex = lines.findIndex(line => REQUIREMENTS_SECTION_HEADER.test(line));
12
13
  let requirementsEndIndex = lines.length;
13
14
  if (requirementsHeaderIndex !== -1) {
@@ -30,7 +31,7 @@ export function findMainSpecStructureIssues(content) {
30
31
  line: i + 1,
31
32
  header: trimmed,
32
33
  message: `Main spec contains delta header "${trimmed}". ` +
33
- 'Delta headers are only valid inside openspec/changes/<name>/specs/<capability>/spec.md ' +
34
+ 'Delta headers are only valid inside openspec/changes/<name>/specs/<capability-path>/spec.md ' +
34
35
  'and truncate the parsed ## Requirements section.',
35
36
  });
36
37
  continue;
@@ -50,6 +51,21 @@ export function findMainSpecStructureIssues(content) {
50
51
  message: `Requirement header "${trimmed}" appears outside the main ## Requirements section. ` +
51
52
  'Main specs only parse requirements inside that section, so this requirement is currently invisible to validate, list, and archive.',
52
53
  });
54
+ continue;
55
+ }
56
+ const requirementName = requirementMatch[1].trim();
57
+ const previousLine = requirementLines.get(requirementName);
58
+ if (previousLine !== undefined) {
59
+ issues.push({
60
+ kind: 'duplicate-requirement',
61
+ line: i + 1,
62
+ header: trimmed,
63
+ message: `Requirement header "${trimmed}" duplicates the requirement declared on line ${previousLine}. ` +
64
+ 'Requirement names must be unique so spec updates cannot discard one block while updating another.',
65
+ });
66
+ }
67
+ else {
68
+ requirementLines.set(requirementName, i + 1);
53
69
  }
54
70
  }
55
71
  return issues;
@@ -5,6 +5,10 @@ import { ALL_WORKFLOWS } from './profiles.js';
5
5
  import { CommandAdapterRegistry } from './command-generation/index.js';
6
6
  import { getConfiguredTools } from './shared/index.js';
7
7
  import { shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
8
+ import { readSharedSkillTarget } from './shared-skill-target.js';
9
+ import { FileSystemUtils } from '../utils/file-system.js';
10
+ import { isLegacyCodexSkillEquivalentToCurrent } from './shared/skill-content-equivalence.js';
11
+ import { hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, } from './shared/skill-paths.js';
8
12
  /**
9
13
  * Maps workflow IDs to their skill directory names.
10
14
  */
@@ -41,14 +45,45 @@ export function getConfiguredToolsForProfileSync(projectPath) {
41
45
  */
42
46
  export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkflows, delivery) {
43
47
  const tool = AI_TOOLS.find((t) => t.value === toolId);
44
- if (!tool?.skillsDir)
48
+ if (!tool || !toolSupportsSkills(tool))
45
49
  return false;
46
50
  const knownDesiredWorkflows = toKnownWorkflows(desiredWorkflows);
47
51
  const desiredWorkflowSet = new Set(knownDesiredWorkflows);
48
- const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
52
+ const skillsDir = resolveToolSkillsDir(projectPath, tool);
49
53
  const adapter = CommandAdapterRegistry.get(toolId);
50
54
  const shouldGenerateSkills = shouldGenerateSkillsForTool(toolId, delivery);
51
55
  const shouldGenerateCommands = shouldGenerateCommandsForTool(toolId, delivery);
56
+ const sharedTarget = tool.skillsDir
57
+ ? readSharedSkillTarget(projectPath, tool.skillsDir)
58
+ : undefined;
59
+ for (const root of tool.legacySkillsDirs ?? []) {
60
+ for (const workflow of knownDesiredWorkflows) {
61
+ const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
62
+ const legacySkill = path.join(projectPath, root, 'skills', dirName, 'SKILL.md');
63
+ if (!fs.existsSync(legacySkill))
64
+ continue;
65
+ const currentSkill = path.join(skillsDir, dirName, 'SKILL.md');
66
+ if (!fs.existsSync(currentSkill) || sharedTarget !== toolId) {
67
+ return true;
68
+ }
69
+ try {
70
+ if (FileSystemUtils.canonicalizeExistingPath(legacySkill) ===
71
+ FileSystemUtils.canonicalizeExistingPath(currentSkill)) {
72
+ continue;
73
+ }
74
+ // Equivalent generated copies are actionable: migration can safely
75
+ // remove the redundant legacy file even when version, line endings,
76
+ // or supported invocation syntax changed. Materially divergent copies
77
+ // stay in place without forcing an update on every run.
78
+ if (isLegacyCodexSkillEquivalentToCurrent(fs.readFileSync(legacySkill, 'utf-8'), fs.readFileSync(currentSkill, 'utf-8'))) {
79
+ return true;
80
+ }
81
+ }
82
+ catch {
83
+ return true;
84
+ }
85
+ }
86
+ }
52
87
  if (shouldGenerateSkills) {
53
88
  for (const workflow of knownDesiredWorkflows) {
54
89
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
@@ -68,7 +103,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
68
103
  }
69
104
  }
70
105
  }
71
- else if (shouldRemoveSkillsForTool(toolId, delivery)) {
106
+ else if (shouldRemoveSkillsForTool(toolId, delivery) && !hasGlobalSkillTarget(tool)) {
72
107
  for (const workflow of ALL_WORKFLOWS) {
73
108
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
74
109
  const skillDir = path.join(skillsDir, dirName);
@@ -116,10 +151,10 @@ export function getToolsNeedingProfileSync(projectPath, desiredWorkflows, delive
116
151
  }
117
152
  function getInstalledWorkflowsForTool(projectPath, toolId, options) {
118
153
  const tool = AI_TOOLS.find((t) => t.value === toolId);
119
- if (!tool?.skillsDir)
154
+ if (!tool || !toolSupportsSkills(tool))
120
155
  return [];
121
156
  const installed = new Set();
122
- const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
157
+ const skillsDir = resolveToolSkillsDir(projectPath, tool);
123
158
  if (options.includeSkills) {
124
159
  for (const workflow of ALL_WORKFLOWS) {
125
160
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
@@ -31,6 +31,9 @@ export declare const ProjectConfigSchema: z.ZodObject<{
31
31
  }, z.core.$strip>>;
32
32
  }, z.core.$strip>>;
33
33
  store: z.ZodOptional<z.ZodString>;
34
+ githubCopilot: z.ZodOptional<z.ZodObject<{
35
+ cloudAgent: z.ZodOptional<z.ZodBoolean>;
36
+ }, z.core.$strip>>;
34
37
  }, z.core.$strip>;
35
38
  /** Normalized in-memory shape of a referenced store declaration. */
36
39
  export interface DeclarationEntry {
@@ -57,6 +57,15 @@ export const ProjectConfigSchema = z.object({
57
57
  .string()
58
58
  .optional()
59
59
  .describe('Store id used as the OpenSpec root when no local planning shape exists'),
60
+ // Optional: GitHub Copilot integration preferences. `cloudAgent` is the
61
+ // opt-in for generating the Copilot cloud coding-agent files (a GitHub
62
+ // Actions workflow + agent file); absent means "not yet decided".
63
+ githubCopilot: z
64
+ .object({
65
+ cloudAgent: z.boolean().optional(),
66
+ })
67
+ .optional()
68
+ .describe('GitHub Copilot integration preferences'),
60
69
  });
61
70
  export function loadOperationInputs(projectConfig, operationId) {
62
71
  const context = projectConfig?.context !== undefined && projectConfig.context.trim().length > 0
@@ -235,7 +244,11 @@ export function readProjectConfig(projectRoot) {
235
244
  const rulesField = z.record(z.string(), z.array(z.string()));
236
245
  // First check if it's an object structure (guard against null since typeof null === 'object')
237
246
  if (typeof raw.rules === 'object' && raw.rules !== null && !Array.isArray(raw.rules)) {
238
- const parsedRules = {};
247
+ // Artifact ids are intentionally not restricted to the built-in naming
248
+ // convention, so keys such as "constructor" remain valid for custom
249
+ // schemas. A null-prototype map preserves those keys as data without
250
+ // letting "__proto__" mutate the lookup object's prototype.
251
+ const parsedRules = Object.create(null);
239
252
  let hasValidRules = false;
240
253
  for (const [artifactId, rules] of Object.entries(raw.rules)) {
241
254
  const rulesArrayResult = z.array(z.string()).safeParse(rules);
@@ -281,6 +294,23 @@ export function readProjectConfig(projectRoot) {
281
294
  console.warn(`Warning: ignoring invalid store: field in ${configPathForWarnings(projectRoot)} (must be a single store id string).`);
282
295
  }
283
296
  }
297
+ // Parse githubCopilot preferences (only cloudAgent is recognized today).
298
+ if (raw.githubCopilot !== undefined) {
299
+ if (typeof raw.githubCopilot === 'object' &&
300
+ raw.githubCopilot !== null &&
301
+ !Array.isArray(raw.githubCopilot)) {
302
+ const cloudAgent = raw.githubCopilot.cloudAgent;
303
+ if (typeof cloudAgent === 'boolean') {
304
+ config.githubCopilot = { cloudAgent };
305
+ }
306
+ else if (cloudAgent !== undefined) {
307
+ console.warn(`Invalid 'githubCopilot.cloudAgent' field in config (must be a boolean)`);
308
+ }
309
+ }
310
+ else {
311
+ console.warn(`Invalid 'githubCopilot' field in config (must be an object)`);
312
+ }
313
+ }
284
314
  // Return partial config even if some fields failed
285
315
  return Object.keys(config).length > 0 ? config : null;
286
316
  }
@@ -5,4 +5,5 @@
5
5
  */
6
6
  export { SKILL_NAMES, type SkillName, COMMAND_IDS, type CommandId, type ToolSkillStatus, type ToolVersionStatus, getToolsWithSkillsDir, getToolSkillStatus, getToolStates, extractGeneratedByVersion, getToolVersionStatus, getConfiguredTools, getAllToolVersionStatus, } from './tool-detection.js';
7
7
  export { type SkillTemplateEntry, type CommandTemplateEntry, getSkillTemplates, getCommandTemplates, getCommandContents, generateSkillContent, } from './skill-generation.js';
8
+ export { type SkillCapableTool, toolSupportsSkills, getSkillCapableTools, hasGlobalSkillTarget, resolveToolSkillsDir, } from './skill-paths.js';
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -5,4 +5,5 @@
5
5
  */
6
6
  export { SKILL_NAMES, COMMAND_IDS, getToolsWithSkillsDir, getToolSkillStatus, getToolStates, extractGeneratedByVersion, getToolVersionStatus, getConfiguredTools, getAllToolVersionStatus, } from './tool-detection.js';
7
7
  export { getSkillTemplates, getCommandTemplates, getCommandContents, generateSkillContent, } from './skill-generation.js';
8
+ export { toolSupportsSkills, getSkillCapableTools, hasGlobalSkillTarget, resolveToolSkillsDir, } from './skill-paths.js';
8
9
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Returns whether a legacy Codex skill differs from the current canonical
3
+ * replacement only by generated version, checkout line endings/BOM, or the
4
+ * known Codex/generic dual-reference expansion.
5
+ */
6
+ export declare function isLegacyCodexSkillEquivalentToCurrent(legacyContent: string, currentContent: string): boolean;
7
+ //# sourceMappingURL=skill-content-equivalence.d.ts.map
@@ -0,0 +1,41 @@
1
+ import { OPENSPEC_SKILL_NAMES } from '../config.js';
2
+ const GENERATED_VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
3
+ const OPENSPEC_SKILL_NAME_SET = new Set(OPENSPEC_SKILL_NAMES);
4
+ /**
5
+ * Normalizes checkout line endings and a valid generated version inside the
6
+ * YAML frontmatter. Free-form `generatedBy` text in the instructions remains
7
+ * material.
8
+ */
9
+ function normalizeGeneratedSkill(content) {
10
+ const normalized = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
11
+ const frontmatter = normalized.match(/^---\n[\s\S]*?\n---(?:\n|$)/)?.[0];
12
+ if (!frontmatter)
13
+ return normalized;
14
+ const versionLine = /^(\s*generatedBy:\s*)(?:"([^"\n]+)"|'([^'\n]+)'|([^\s"'#]+))\s*$/m;
15
+ const normalizedFrontmatter = frontmatter.replace(versionLine, (line, prefix, doubleQuoted, singleQuoted, bare) => {
16
+ const version = doubleQuoted ?? singleQuoted ?? bare;
17
+ return version && GENERATED_VERSION.test(version)
18
+ ? `${prefix}"<generated-version>"`
19
+ : line;
20
+ });
21
+ return normalizedFrontmatter + normalized.slice(frontmatter.length);
22
+ }
23
+ /**
24
+ * Converts only known generated dual references in current Codex content back
25
+ * to the direct syntax used by legacy `.codex` output.
26
+ */
27
+ function toLegacyCodexReferences(content) {
28
+ return content.replace(/\$(openspec-[a-z0-9-]+) \(Codex\) or \/\1 \(other agents\)/g, (match, skillName) => OPENSPEC_SKILL_NAME_SET.has(skillName) ? `$${skillName}` : match);
29
+ }
30
+ /**
31
+ * Returns whether a legacy Codex skill differs from the current canonical
32
+ * replacement only by generated version, checkout line endings/BOM, or the
33
+ * known Codex/generic dual-reference expansion.
34
+ */
35
+ export function isLegacyCodexSkillEquivalentToCurrent(legacyContent, currentContent) {
36
+ const normalizedLegacy = normalizeGeneratedSkill(legacyContent);
37
+ const normalizedCurrent = normalizeGeneratedSkill(currentContent);
38
+ return (normalizedLegacy === normalizedCurrent ||
39
+ normalizedLegacy === toLegacyCodexReferences(normalizedCurrent));
40
+ }
41
+ //# sourceMappingURL=skill-content-equivalence.js.map
@@ -0,0 +1,13 @@
1
+ import { type AIToolOption } from '../config.js';
2
+ export type SkillCapableTool = AIToolOption & ({
3
+ skillsDir: string;
4
+ } | {
5
+ globalSkillsDir: string;
6
+ });
7
+ export declare function toolSupportsSkills(tool: AIToolOption): tool is SkillCapableTool;
8
+ export declare function getSkillCapableTools(): SkillCapableTool[];
9
+ export declare function hasGlobalSkillTarget(tool: AIToolOption): boolean;
10
+ export declare function resolveToolSkillsDir(projectRoot: string, tool: SkillCapableTool, options?: {
11
+ homeDir?: string;
12
+ }): string;
13
+ //# sourceMappingURL=skill-paths.d.ts.map
@@ -0,0 +1,23 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { AI_TOOLS } from '../config.js';
4
+ export function toolSupportsSkills(tool) {
5
+ return Boolean(tool.skillsDir || tool.globalSkillsDir);
6
+ }
7
+ export function getSkillCapableTools() {
8
+ return AI_TOOLS.filter(toolSupportsSkills);
9
+ }
10
+ export function hasGlobalSkillTarget(tool) {
11
+ return Boolean(tool.globalSkillsDir);
12
+ }
13
+ export function resolveToolSkillsDir(projectRoot, tool, options = {}) {
14
+ if (tool.globalSkillsDir) {
15
+ const homeDir = options.homeDir ?? process.env.USERPROFILE ?? process.env.HOME ?? os.homedir();
16
+ return path.join(homeDir, tool.globalSkillsDir, 'skills');
17
+ }
18
+ if (tool.skillsDir) {
19
+ return path.join(projectRoot, tool.skillsDir, 'skills');
20
+ }
21
+ throw new Error(`Tool '${tool.value}' does not support skill generation.`);
22
+ }
23
+ //# sourceMappingURL=skill-paths.js.map