@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
@@ -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
  */
@@ -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
@@ -1,4 +1,4 @@
1
- import { buildCodeFenceMask } from './requirement-text.js';
1
+ import { buildCodeFenceMask, SCENARIO_HEADER } from './requirement-text.js';
2
2
  export function normalizeRequirementName(name) {
3
3
  return name.trim();
4
4
  }
@@ -257,4 +257,96 @@ 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
+ /**
293
+ * Any non-fenced level-4 header on the given (masked) line. Reuses the spec
294
+ * path's SCENARIO_HEADER so the two counters cannot drift apart.
295
+ */
296
+ function scenarioHeaderAt(lines, mask, index) {
297
+ return !mask[index] && SCENARIO_HEADER.test(lines[index]);
298
+ }
299
+ /**
300
+ * The scenario name for a `#### ` header, matching the label the author reads:
301
+ * the header text with the leading `####`, an optional CommonMark closing `#`
302
+ * run (`#### Foo ####` renders as `Foo`), and an optional `Scenario:` prefix
303
+ * stripped. Both the current and incoming blocks run through here, so the
304
+ * comparison in findMissingCurrentScenarios stays internally consistent
305
+ * regardless of label — and two headers that render to the same title (one
306
+ * ATX-closed, one not) are not mistaken for a dropped scenario.
307
+ */
308
+ function scenarioNameAt(line) {
309
+ return line
310
+ .replace(SCENARIO_HEADER, '')
311
+ // Optional ATX closing sequence. CommonMark only treats a trailing `#` run
312
+ // as a close when it is preceded by a space or tab — not any Unicode space —
313
+ // so this uses `[ \t]`, not `\s`. A looser `\s` could strip a `#` run after
314
+ // an exotic space (e.g. NBSP) that CommonMark keeps, folding two distinct
315
+ // scenario names into one and masking a real loss. `[ \t]` keeps the fold
316
+ // faithful to how the header actually renders.
317
+ .replace(/[ \t]+#+[ \t]*$/, '')
318
+ .replace(/^Scenario:\s*/i, '')
319
+ .trim();
320
+ }
321
+ function parseScenarioBlocks(requirementRaw) {
322
+ const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n');
323
+ // A scenario is ANY non-fenced `#### ` header, matching the spec path's
324
+ // SCENARIO_HEADER / countScenarios (requirement-text.ts) exactly — not only
325
+ // `#### Scenario:`. The two MUST agree: a level-4 child whose header is not
326
+ // literally `Scenario:` (e.g. `#### Edge case`) is still a scenario the spec
327
+ // path counts, so a MODIFIED block that drops it would otherwise slip past
328
+ // this loss check and be deleted by archive with no error (the parity the
329
+ // SCENARIO_HEADER comment warns not to break). A `####` inside a fenced
330
+ // example is masked out, matching countScenarios.
331
+ const mask = buildCodeFenceMask(lines);
332
+ const scenarios = [];
333
+ let index = 0;
334
+ while (index < lines.length) {
335
+ if (!scenarioHeaderAt(lines, mask, index)) {
336
+ index++;
337
+ continue;
338
+ }
339
+ const start = index;
340
+ const name = scenarioNameAt(lines[index]);
341
+ index++;
342
+ while (index < lines.length && !scenarioHeaderAt(lines, mask, index)) {
343
+ index++;
344
+ }
345
+ scenarios.push({
346
+ name,
347
+ raw: lines.slice(start, index).join('\n').trimEnd(),
348
+ });
349
+ }
350
+ return scenarios;
351
+ }
260
352
  //# sourceMappingURL=requirement-blocks.js.map
@@ -9,6 +9,14 @@
9
9
  * `validate <change>`, `validate <spec>`, and `archive`.
10
10
  */
11
11
  export { buildCodeFenceMask } from './code-fence.js';
12
+ /**
13
+ * A level-4 header. Deliberately matches ANY `####` header, not only
14
+ * `#### Scenario:` — the spec path treats every level-4 child of a requirement
15
+ * as a scenario, so the delta counter must too (parity). The delta/loss path
16
+ * reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
17
+ * keep both paths on it rather than reintroducing a separate `Scenario:` regex.
18
+ */
19
+ export declare const SCENARIO_HEADER: RegExp;
12
20
  /**
13
21
  * The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
14
22
  * as whole words so the change-delta reader and the schema-based reader accept
@@ -19,10 +19,11 @@ const HEADER_LINE = /^#{1,6}\s/;
19
19
  /**
20
20
  * A level-4 header. Deliberately matches ANY `####` header, not only
21
21
  * `#### Scenario:` — the spec path treats every level-4 child of a requirement
22
- * as a scenario, so the delta counter must too (parity). Don't tighten this to
23
- * `Scenario:` without changing both paths together.
22
+ * as a scenario, so the delta counter must too (parity). The delta/loss path
23
+ * reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
24
+ * keep both paths on it rather than reintroducing a separate `Scenario:` regex.
24
25
  */
25
- const SCENARIO_HEADER = /^####\s+/;
26
+ export const SCENARIO_HEADER = /^####\s+/;
26
27
  /**
27
28
  * The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
28
29
  * as whole words so the change-delta reader and the schema-based reader accept
@@ -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
  }
@@ -119,7 +119,7 @@ export declare function toPlanningHome(root: ResolvedOpenSpecRoot): PlanningHome
119
119
  export declare function resolveRootForCommand(selector: StoreSelectorOptions, output?: {
120
120
  json?: boolean;
121
121
  failurePayload?: Record<string, unknown>;
122
- /** Diagnostic commands inspect what exists; they never scaffold. */
122
+ /** Commands that require an existing root set this to false. */
123
123
  allowImplicitRoot?: boolean;
124
124
  }): Promise<ResolvedOpenSpecRoot | null>;
125
125
  //# sourceMappingURL=root-selection.d.ts.map
@@ -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