@fission-ai/openspec 1.6.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 (208) hide show
  1. package/README.md +31 -2
  2. package/dist/cli/index.js +79 -8
  3. package/dist/commands/change.d.ts +0 -1
  4. package/dist/commands/change.js +105 -51
  5. package/dist/commands/config.js +14 -8
  6. package/dist/commands/doctor.js +9 -4
  7. package/dist/commands/feedback.js +74 -23
  8. package/dist/commands/schema.js +110 -31
  9. package/dist/commands/show.js +5 -2
  10. package/dist/commands/spec.js +49 -29
  11. package/dist/commands/validate.js +20 -5
  12. package/dist/commands/workflow/index.d.ts +1 -1
  13. package/dist/commands/workflow/index.js +1 -1
  14. package/dist/commands/workflow/instructions.d.ts +7 -1
  15. package/dist/commands/workflow/instructions.js +121 -33
  16. package/dist/commands/workflow/shared.d.ts +13 -2
  17. package/dist/commands/workflow/shared.js +34 -4
  18. package/dist/commands/workflow/status.js +9 -4
  19. package/dist/commands/workflow/templates.js +15 -5
  20. package/dist/core/archive.d.ts +14 -1
  21. package/dist/core/archive.js +1332 -155
  22. package/dist/core/artifact-graph/graph.d.ts +16 -0
  23. package/dist/core/artifact-graph/graph.js +30 -6
  24. package/dist/core/artifact-graph/index.d.ts +1 -1
  25. package/dist/core/artifact-graph/index.js +1 -1
  26. package/dist/core/artifact-graph/instruction-loader.d.ts +30 -3
  27. package/dist/core/artifact-graph/instruction-loader.js +70 -12
  28. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  29. package/dist/core/artifact-graph/outputs.js +67 -5
  30. package/dist/core/artifact-graph/resolver.d.ts +14 -0
  31. package/dist/core/artifact-graph/resolver.js +69 -15
  32. package/dist/core/artifact-graph/types.js +21 -3
  33. package/dist/core/available-tools.d.ts +2 -2
  34. package/dist/core/available-tools.js +14 -3
  35. package/dist/core/change-metadata/schema.d.ts +2 -0
  36. package/dist/core/change-metadata/schema.js +13 -0
  37. package/dist/core/change-status-policy.d.ts +1 -1
  38. package/dist/core/change-status-policy.js +2 -2
  39. package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
  40. package/dist/core/command-generation/adapters/amazon-q.js +7 -1
  41. package/dist/core/command-generation/adapters/antigravity.js +2 -1
  42. package/dist/core/command-generation/adapters/auggie.js +2 -1
  43. package/dist/core/command-generation/adapters/bob.d.ts +5 -1
  44. package/dist/core/command-generation/adapters/bob.js +6 -5
  45. package/dist/core/command-generation/adapters/claude.js +1 -8
  46. package/dist/core/command-generation/adapters/codebuddy.js +3 -2
  47. package/dist/core/command-generation/adapters/continue.js +3 -2
  48. package/dist/core/command-generation/adapters/costrict.js +2 -1
  49. package/dist/core/command-generation/adapters/crush.js +5 -5
  50. package/dist/core/command-generation/adapters/cursor.js +2 -2
  51. package/dist/core/command-generation/adapters/devin.d.ts +19 -0
  52. package/dist/core/command-generation/adapters/devin.js +36 -0
  53. package/dist/core/command-generation/adapters/factory.js +2 -1
  54. package/dist/core/command-generation/adapters/gemini.js +37 -2
  55. package/dist/core/command-generation/adapters/github-copilot.js +2 -1
  56. package/dist/core/command-generation/adapters/iflow.js +5 -4
  57. package/dist/core/command-generation/adapters/index.d.ts +2 -2
  58. package/dist/core/command-generation/adapters/index.js +2 -2
  59. package/dist/core/command-generation/adapters/junie.js +2 -1
  60. package/dist/core/command-generation/adapters/kiro.js +2 -1
  61. package/dist/core/command-generation/adapters/lingma.js +5 -5
  62. package/dist/core/command-generation/adapters/oh-my-pi.d.ts +2 -2
  63. package/dist/core/command-generation/adapters/oh-my-pi.js +3 -5
  64. package/dist/core/command-generation/adapters/opencode.js +3 -5
  65. package/dist/core/command-generation/adapters/pi.d.ts +2 -2
  66. package/dist/core/command-generation/adapters/pi.js +3 -6
  67. package/dist/core/command-generation/adapters/qoder.js +5 -5
  68. package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
  69. package/dist/core/command-generation/adapters/qwen.js +12 -7
  70. package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
  71. package/dist/core/command-generation/adapters/roocode.js +4 -4
  72. package/dist/core/command-generation/adapters/trae.js +1 -21
  73. package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
  74. package/dist/core/command-generation/adapters/zcode.js +33 -0
  75. package/dist/core/command-generation/generator.d.ts +8 -0
  76. package/dist/core/command-generation/generator.js +15 -1
  77. package/dist/core/command-generation/index.d.ts +1 -1
  78. package/dist/core/command-generation/index.js +1 -1
  79. package/dist/core/command-generation/invocation.d.ts +73 -0
  80. package/dist/core/command-generation/invocation.js +77 -0
  81. package/dist/core/command-generation/registry.js +4 -4
  82. package/dist/core/command-generation/types.d.ts +9 -2
  83. package/dist/core/command-generation/yaml.d.ts +20 -6
  84. package/dist/core/command-generation/yaml.js +31 -21
  85. package/dist/core/command-surface.d.ts +15 -0
  86. package/dist/core/command-surface.js +33 -0
  87. package/dist/core/completions/command-registry.js +16 -2
  88. package/dist/core/completions/generators/powershell-generator.js +15 -8
  89. package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
  90. package/dist/core/completions/installers/zsh-installer.js +20 -4
  91. package/dist/core/config-prompts.d.ts +1 -1
  92. package/dist/core/config-prompts.js +14 -1
  93. package/dist/core/config-schema.d.ts +10 -1
  94. package/dist/core/config-schema.js +67 -1
  95. package/dist/core/config.d.ts +15 -0
  96. package/dist/core/config.js +45 -5
  97. package/dist/core/file-state.js +56 -24
  98. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  99. package/dist/core/github-copilot/cloud-agent.js +497 -0
  100. package/dist/core/global-config.d.ts +16 -0
  101. package/dist/core/init.d.ts +58 -0
  102. package/dist/core/init.js +450 -69
  103. package/dist/core/legacy-cleanup.d.ts +59 -0
  104. package/dist/core/legacy-cleanup.js +253 -24
  105. package/dist/core/list.js +5 -7
  106. package/dist/core/migration.d.ts +73 -1
  107. package/dist/core/migration.js +360 -11
  108. package/dist/core/onboarding-commands.d.ts +30 -0
  109. package/dist/core/onboarding-commands.js +38 -0
  110. package/dist/core/parsers/change-parser.d.ts +17 -2
  111. package/dist/core/parsers/change-parser.js +33 -20
  112. package/dist/core/parsers/code-fence.d.ts +15 -0
  113. package/dist/core/parsers/code-fence.js +49 -0
  114. package/dist/core/parsers/markdown-parser.js +2 -1
  115. package/dist/core/parsers/requirement-blocks.d.ts +18 -0
  116. package/dist/core/parsers/requirement-blocks.js +120 -35
  117. package/dist/core/parsers/requirement-text.d.ts +1 -8
  118. package/dist/core/parsers/requirement-text.js +4 -42
  119. package/dist/core/parsers/spec-structure.d.ts +1 -1
  120. package/dist/core/parsers/spec-structure.js +20 -30
  121. package/dist/core/profile-sync-drift.d.ts +0 -8
  122. package/dist/core/profile-sync-drift.js +48 -49
  123. package/dist/core/project-config.d.ts +28 -6
  124. package/dist/core/project-config.js +108 -8
  125. package/dist/core/references.js +42 -4
  126. package/dist/core/relationship-health.d.ts +8 -0
  127. package/dist/core/relationship-health.js +13 -0
  128. package/dist/core/root-selection.d.ts +9 -6
  129. package/dist/core/root-selection.js +42 -5
  130. package/dist/core/shared/index.d.ts +1 -0
  131. package/dist/core/shared/index.js +1 -0
  132. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  133. package/dist/core/shared/skill-content-equivalence.js +41 -0
  134. package/dist/core/shared/skill-paths.d.ts +13 -0
  135. package/dist/core/shared/skill-paths.js +23 -0
  136. package/dist/core/shared/tool-detection.d.ts +25 -5
  137. package/dist/core/shared/tool-detection.js +166 -36
  138. package/dist/core/shared-skill-target.d.ts +16 -0
  139. package/dist/core/shared-skill-target.js +156 -0
  140. package/dist/core/specs-apply.d.ts +80 -33
  141. package/dist/core/specs-apply.js +659 -171
  142. package/dist/core/store/git.d.ts +13 -0
  143. package/dist/core/store/git.js +24 -0
  144. package/dist/core/templates/skill-templates.d.ts +1 -1
  145. package/dist/core/templates/skill-templates.js +1 -1
  146. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  147. package/dist/core/templates/workflows/apply-change.js +48 -158
  148. package/dist/core/templates/workflows/archive-change.js +170 -42
  149. package/dist/core/templates/workflows/bulk-archive-change.js +256 -75
  150. package/dist/core/templates/workflows/continue-change.js +38 -46
  151. package/dist/core/templates/workflows/explore.js +54 -18
  152. package/dist/core/templates/workflows/ff-change.js +46 -30
  153. package/dist/core/templates/workflows/new-change.js +2 -2
  154. package/dist/core/templates/workflows/onboard.js +13 -7
  155. package/dist/core/templates/workflows/propose.js +116 -46
  156. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  157. package/dist/core/templates/workflows/store-selection.js +1 -1
  158. package/dist/core/templates/workflows/sync-specs.js +260 -32
  159. package/dist/core/templates/workflows/update-change.js +26 -16
  160. package/dist/core/templates/workflows/verify-change.js +16 -10
  161. package/dist/core/update.d.ts +40 -5
  162. package/dist/core/update.js +389 -93
  163. package/dist/core/validation/constants.d.ts +4 -1
  164. package/dist/core/validation/constants.js +4 -1
  165. package/dist/core/validation/validator.d.ts +22 -8
  166. package/dist/core/validation/validator.js +222 -50
  167. package/dist/core/version-check.d.ts +152 -0
  168. package/dist/core/version-check.js +731 -0
  169. package/dist/core/view.js +12 -17
  170. package/dist/prompts/searchable-multi-select.js +1 -1
  171. package/dist/telemetry/config.d.ts +3 -4
  172. package/dist/telemetry/index.d.ts +10 -5
  173. package/dist/telemetry/index.js +75 -41
  174. package/dist/ui/welcome-screen.d.ts +9 -1
  175. package/dist/ui/welcome-screen.js +91 -38
  176. package/dist/utils/change-metadata.d.ts +43 -0
  177. package/dist/utils/change-metadata.js +123 -7
  178. package/dist/utils/change-utils.d.ts +10 -4
  179. package/dist/utils/change-utils.js +20 -13
  180. package/dist/utils/ci.d.ts +12 -0
  181. package/dist/utils/ci.js +16 -0
  182. package/dist/utils/command-references.d.ts +92 -6
  183. package/dist/utils/command-references.js +180 -7
  184. package/dist/utils/date.d.ts +8 -0
  185. package/dist/utils/date.js +13 -0
  186. package/dist/utils/file-system.d.ts +10 -0
  187. package/dist/utils/file-system.js +70 -0
  188. package/dist/utils/index.d.ts +1 -1
  189. package/dist/utils/index.js +1 -1
  190. package/dist/utils/interactive.d.ts +23 -0
  191. package/dist/utils/interactive.js +32 -0
  192. package/dist/utils/item-discovery.d.ts +19 -0
  193. package/dist/utils/item-discovery.js +30 -48
  194. package/dist/utils/shell-detection.d.ts +1 -1
  195. package/dist/utils/shell-detection.js +64 -11
  196. package/dist/utils/spec-discovery.d.ts +40 -0
  197. package/dist/utils/spec-discovery.js +115 -0
  198. package/dist/utils/task-progress.d.ts +17 -0
  199. package/dist/utils/task-progress.js +41 -13
  200. package/package.json +14 -12
  201. package/schemas/spec-driven/schema.yaml +67 -10
  202. package/schemas/spec-driven/templates/design.md +2 -2
  203. package/schemas/spec-driven/templates/proposal.md +10 -4
  204. package/schemas/spec-driven/templates/spec.md +3 -0
  205. package/dist/core/command-generation/adapters/codex.d.ts +0 -16
  206. package/dist/core/command-generation/adapters/codex.js +0 -39
  207. package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
  208. package/dist/core/command-generation/adapters/windsurf.js +0 -38
@@ -11,6 +11,14 @@ export interface RequirementsSectionParts {
11
11
  after: string;
12
12
  }
13
13
  export declare function normalizeRequirementName(name: string): string;
14
+ /**
15
+ * Case- and whitespace-insensitive fold of a requirement name. Requirement
16
+ * matching itself is case-sensitive (normalizeRequirementName); this fold
17
+ * exists only for typo detection - near-miss REMOVED headers and the
18
+ * RENAMED+REMOVED cross-section conflict - where two spellings that differ
19
+ * only in case or interior whitespace mean a mistake, never two requirements.
20
+ */
21
+ export declare function foldRequirementName(name: string): string;
14
22
  /**
15
23
  * Extracts the Requirements section from a spec file and parses requirement blocks.
16
24
  */
@@ -45,4 +53,14 @@ export interface DeltaPlan {
45
53
  * Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
46
54
  */
47
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[];
48
66
  //# sourceMappingURL=requirement-blocks.d.ts.map
@@ -2,6 +2,16 @@ import { buildCodeFenceMask } from './requirement-text.js';
2
2
  export function normalizeRequirementName(name) {
3
3
  return name.trim();
4
4
  }
5
+ /**
6
+ * Case- and whitespace-insensitive fold of a requirement name. Requirement
7
+ * matching itself is case-sensitive (normalizeRequirementName); this fold
8
+ * exists only for typo detection - near-miss REMOVED headers and the
9
+ * RENAMED+REMOVED cross-section conflict - where two spellings that differ
10
+ * only in case or interior whitespace mean a mistake, never two requirements.
11
+ */
12
+ export function foldRequirementName(name) {
13
+ return normalizeRequirementName(name).toLowerCase().replace(/\s+/g, ' ');
14
+ }
5
15
  /** The canonical requirement header the delta reader recognizes. */
6
16
  const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/i;
7
17
  /**
@@ -10,7 +20,8 @@ const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/i;
10
20
  export function extractRequirementsSection(content) {
11
21
  const normalized = normalizeLineEndings(content);
12
22
  const lines = normalized.split('\n');
13
- const reqHeaderIndex = lines.findIndex(l => /^##\s+Requirements\s*$/i.test(l));
23
+ const fenceMask = buildCodeFenceMask(lines);
24
+ const reqHeaderIndex = lines.findIndex((l, i) => !fenceMask[i] && /^##\s+Requirements\s*$/i.test(l));
14
25
  if (reqHeaderIndex === -1) {
15
26
  // No requirements section; create an empty one at the end
16
27
  const before = content.trimEnd();
@@ -26,7 +37,7 @@ export function extractRequirementsSection(content) {
26
37
  // Find end of this section: next line that starts with '## ' at same or higher level
27
38
  let endIndex = lines.length;
28
39
  for (let i = reqHeaderIndex + 1; i < lines.length; i++) {
29
- if (/^##\s+/.test(lines[i])) {
40
+ if (!fenceMask[i] && /^##\s+/.test(lines[i])) {
30
41
  endIndex = i;
31
42
  break;
32
43
  }
@@ -34,29 +45,31 @@ export function extractRequirementsSection(content) {
34
45
  const before = lines.slice(0, reqHeaderIndex).join('\n');
35
46
  const headerLine = lines[reqHeaderIndex];
36
47
  const sectionBodyLines = lines.slice(reqHeaderIndex + 1, endIndex);
48
+ const sectionBodyMask = fenceMask.slice(reqHeaderIndex + 1, endIndex);
49
+ const isRequirementHeader = (cursor) => !sectionBodyMask[cursor] && REQUIREMENT_HEADER_REGEX.test(sectionBodyLines[cursor]);
50
+ const isTopLevelHeader = (cursor) => !sectionBodyMask[cursor] && /^##\s+/.test(sectionBodyLines[cursor]);
37
51
  // Parse requirement blocks within section body
38
52
  const blocks = [];
39
53
  let cursor = 0;
40
54
  let preambleLines = [];
41
55
  // Collect preamble lines until first requirement header
42
- while (cursor < sectionBodyLines.length && !REQUIREMENT_HEADER_REGEX.test(sectionBodyLines[cursor])) {
56
+ while (cursor < sectionBodyLines.length && !isRequirementHeader(cursor)) {
43
57
  preambleLines.push(sectionBodyLines[cursor]);
44
58
  cursor++;
45
59
  }
46
60
  while (cursor < sectionBodyLines.length) {
47
- const headerStart = cursor;
48
61
  const headerLineCandidate = sectionBodyLines[cursor];
49
- const headerMatch = headerLineCandidate.match(REQUIREMENT_HEADER_REGEX);
50
- if (!headerMatch) {
62
+ if (!isRequirementHeader(cursor)) {
51
63
  // Not a requirement header; skip line defensively
52
64
  cursor++;
53
65
  continue;
54
66
  }
67
+ const headerMatch = headerLineCandidate.match(REQUIREMENT_HEADER_REGEX);
55
68
  const name = normalizeRequirementName(headerMatch[1]);
56
69
  cursor++;
57
70
  // Gather lines until next requirement header or end of section
58
71
  const bodyLines = [headerLineCandidate];
59
- while (cursor < sectionBodyLines.length && !REQUIREMENT_HEADER_REGEX.test(sectionBodyLines[cursor]) && !/^##\s+/.test(sectionBodyLines[cursor])) {
72
+ while (cursor < sectionBodyLines.length && !isRequirementHeader(cursor) && !isTopLevelHeader(cursor)) {
60
73
  bodyLines.push(sectionBodyLines[cursor]);
61
74
  cursor++;
62
75
  }
@@ -74,14 +87,18 @@ export function extractRequirementsSection(content) {
74
87
  };
75
88
  }
76
89
  function normalizeLineEndings(content) {
77
- return content.replace(/\r\n?/g, '\n');
90
+ // Strip a UTF-8 BOM: Windows editors and PowerShell redirects prepend one,
91
+ // and it would keep the first line's `## ADDED Requirements` from matching.
92
+ return content.replace(/^/, '').replace(/\r\n?/g, '\n');
78
93
  }
79
94
  /**
80
95
  * Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
81
96
  */
82
97
  export function parseDeltaSpec(content) {
83
98
  const normalized = normalizeLineEndings(content);
84
- const sections = splitTopLevelSections(normalized);
99
+ const lines = normalized.split('\n');
100
+ const fenceMask = buildCodeFenceMask(lines);
101
+ const sections = splitTopLevelSections(lines, fenceMask);
85
102
  const addedLookup = getSectionCaseInsensitive(sections, 'ADDED Requirements');
86
103
  const modifiedLookup = getSectionCaseInsensitive(sections, 'MODIFIED Requirements');
87
104
  const removedLookup = getSectionCaseInsensitive(sections, 'REMOVED Requirements');
@@ -114,43 +131,45 @@ export function parseDeltaSpec(content) {
114
131
  },
115
132
  };
116
133
  }
117
- function splitTopLevelSections(content) {
118
- const lines = content.split('\n');
134
+ function splitTopLevelSections(lines, fenceMask) {
119
135
  const result = {};
120
136
  const indices = [];
121
137
  for (let i = 0; i < lines.length; i++) {
138
+ if (fenceMask[i])
139
+ continue;
122
140
  const m = lines[i].match(/^(##)\s+(.+)$/);
123
141
  if (m) {
124
- const level = m[1].length; // only care for '##'
125
- indices.push({ title: m[2].trim(), index: i, level });
142
+ indices.push({ title: m[2].trim(), index: i });
126
143
  }
127
144
  }
128
145
  for (let i = 0; i < indices.length; i++) {
129
146
  const current = indices[i];
130
147
  const next = indices[i + 1];
131
- const body = lines.slice(current.index + 1, next ? next.index : lines.length).join('\n');
132
- // First body line, 1-based: the header is at 0-based current.index.
133
- result[current.title] = { body, bodyStartLine: current.index + 2 };
148
+ const end = next ? next.index : lines.length;
149
+ result[current.title] = {
150
+ lines: lines.slice(current.index + 1, end),
151
+ fenceMask: fenceMask.slice(current.index + 1, end),
152
+ bodyStartLine: current.index + 2,
153
+ };
134
154
  }
135
155
  return result;
136
156
  }
157
+ const EMPTY_SECTION_BODY = { lines: [], fenceMask: [], bodyStartLine: 0 };
137
158
  function getSectionCaseInsensitive(sections, desired) {
138
159
  const target = desired.toLowerCase();
139
- for (const [title, { body, bodyStartLine }] of Object.entries(sections)) {
140
- if (title.toLowerCase() === target)
141
- return { title, body, bodyStartLine, found: true };
160
+ for (const [title, body] of Object.entries(sections)) {
161
+ if (title.toLowerCase() === target) {
162
+ return { title, body, bodyStartLine: body.bodyStartLine, found: true };
163
+ }
142
164
  }
143
- return { title: desired, body: '', bodyStartLine: 0, found: false };
165
+ return { title: desired, body: EMPTY_SECTION_BODY, bodyStartLine: 0, found: false };
144
166
  }
145
167
  function parseRequirementBlocksFromSection(sectionBody, skipped) {
146
- if (!sectionBody)
168
+ const { lines, fenceMask } = sectionBody;
169
+ if (lines.length === 0)
147
170
  return [];
148
- const lines = normalizeLineEndings(sectionBody).split('\n');
149
- // Record the non-canonical level-3 headers this reader skips, at the moment
150
- // it skips them, so the INFO note describes the reader's real boundaries.
151
- // Fence-masked lines are excluded: the body reader treats them as fenced
152
- // content, not as headers.
153
- const fenceMask = skipped ? buildCodeFenceMask(lines) : undefined;
171
+ const isRequirementHeader = (i) => !fenceMask[i] && REQUIREMENT_HEADER_REGEX.test(lines[i]);
172
+ const isTopLevelHeader = (i) => !fenceMask[i] && /^##\s+/.test(lines[i]);
154
173
  const recordIfSkippedHeader = (index) => {
155
174
  if (!skipped || fenceMask[index])
156
175
  return;
@@ -167,7 +186,7 @@ function parseRequirementBlocksFromSection(sectionBody, skipped) {
167
186
  let i = 0;
168
187
  while (i < lines.length) {
169
188
  // Seek next requirement header
170
- while (i < lines.length && !REQUIREMENT_HEADER_REGEX.test(lines[i])) {
189
+ while (i < lines.length && !isRequirementHeader(i)) {
171
190
  recordIfSkippedHeader(i);
172
191
  i++;
173
192
  }
@@ -182,7 +201,7 @@ function parseRequirementBlocksFromSection(sectionBody, skipped) {
182
201
  const name = normalizeRequirementName(m[1]);
183
202
  const buf = [headerLine];
184
203
  i++;
185
- while (i < lines.length && !REQUIREMENT_HEADER_REGEX.test(lines[i]) && !/^##\s+/.test(lines[i])) {
204
+ while (i < lines.length && !isRequirementHeader(i) && !isTopLevelHeader(i)) {
186
205
  recordIfSkippedHeader(i);
187
206
  buf.push(lines[i]);
188
207
  i++;
@@ -192,11 +211,14 @@ function parseRequirementBlocksFromSection(sectionBody, skipped) {
192
211
  return blocks;
193
212
  }
194
213
  function parseRemovedNames(sectionBody) {
195
- if (!sectionBody)
214
+ const { lines, fenceMask } = sectionBody;
215
+ if (lines.length === 0)
196
216
  return [];
197
217
  const names = [];
198
- const lines = normalizeLineEndings(sectionBody).split('\n');
199
- for (const line of lines) {
218
+ for (let i = 0; i < lines.length; i++) {
219
+ if (fenceMask[i])
220
+ continue;
221
+ const line = lines[i];
200
222
  const m = line.match(REQUIREMENT_HEADER_REGEX);
201
223
  if (m) {
202
224
  names.push(normalizeRequirementName(m[1]));
@@ -211,12 +233,15 @@ function parseRemovedNames(sectionBody) {
211
233
  return names;
212
234
  }
213
235
  function parseRenamedPairs(sectionBody) {
214
- if (!sectionBody)
236
+ const { lines, fenceMask } = sectionBody;
237
+ if (lines.length === 0)
215
238
  return [];
216
239
  const pairs = [];
217
- const lines = normalizeLineEndings(sectionBody).split('\n');
218
240
  let current = {};
219
- for (const line of lines) {
241
+ for (let i = 0; i < lines.length; i++) {
242
+ if (fenceMask[i])
243
+ continue;
244
+ const line = lines[i];
220
245
  const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
221
246
  const toMatch = line.match(/^\s*-?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
222
247
  if (fromMatch) {
@@ -232,4 +257,64 @@ function parseRenamedPairs(sectionBody) {
232
257
  }
233
258
  return pairs;
234
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
+ }
235
320
  //# sourceMappingURL=requirement-blocks.js.map
@@ -8,14 +8,7 @@
8
8
  * extraction, scenario counting, and `SHALL`/`MUST` detection in
9
9
  * `validate <change>`, `validate <spec>`, and `archive`.
10
10
  */
11
- /**
12
- * Build a per-line mask marking lines that fall inside a fenced code block
13
- * (``` ``` ``` or ``` ~~~ ```), including the fence lines themselves. Mirrors the
14
- * fence rules markdown uses: a fence opens on the first ```` ```/~~~ ```` of
15
- * length >= 3 and closes on a line of the same marker whose length is >= the
16
- * opening length, with nothing but whitespace after it.
17
- */
18
- export declare function buildCodeFenceMask(lines: string[]): boolean[];
11
+ export { buildCodeFenceMask } from './code-fence.js';
19
12
  /**
20
13
  * The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
21
14
  * as whole words so the change-delta reader and the schema-based reader accept
@@ -8,48 +8,10 @@
8
8
  * extraction, scenario counting, and `SHALL`/`MUST` detection in
9
9
  * `validate <change>`, `validate <spec>`, and `archive`.
10
10
  */
11
- /**
12
- * Build a per-line mask marking lines that fall inside a fenced code block
13
- * (``` ``` ``` or ``` ~~~ ```), including the fence lines themselves. Mirrors the
14
- * fence rules markdown uses: a fence opens on the first ```` ```/~~~ ```` of
15
- * length >= 3 and closes on a line of the same marker whose length is >= the
16
- * opening length, with nothing but whitespace after it.
17
- */
18
- export function buildCodeFenceMask(lines) {
19
- const mask = new Array(lines.length).fill(false);
20
- let activeFence = null;
21
- for (let i = 0; i < lines.length; i++) {
22
- const fence = getFenceMarker(lines[i]);
23
- if (!activeFence) {
24
- if (fence) {
25
- activeFence = fence;
26
- mask[i] = true;
27
- }
28
- continue;
29
- }
30
- mask[i] = true;
31
- if (isClosingFence(lines[i], activeFence)) {
32
- activeFence = null;
33
- }
34
- }
35
- return mask;
36
- }
37
- function getFenceMarker(line) {
38
- const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
39
- if (!fenceMatch) {
40
- return null;
41
- }
42
- return {
43
- marker: fenceMatch[1][0],
44
- length: fenceMatch[1].length,
45
- };
46
- }
47
- function isClosingFence(line, activeFence) {
48
- const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
49
- return Boolean(fenceMatch &&
50
- fenceMatch[1][0] === activeFence.marker &&
51
- fenceMatch[1].length >= activeFence.length);
52
- }
11
+ // Re-exported so existing importers keep working; the single implementation
12
+ // lives in code-fence.ts.
13
+ export { buildCodeFenceMask } from './code-fence.js';
14
+ import { buildCodeFenceMask } from './code-fence.js';
53
15
  /** Lines that look like `**ID**: ...` / `**Priority**: ...` metadata. */
54
16
  const METADATA_LINE = /^\*\*[^*]+\*\*:/;
55
17
  /** Any markdown header line — the boundary where a requirement body ends. */
@@ -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;
@@ -1,3 +1,4 @@
1
+ import { buildCodeFenceMask } from './code-fence.js';
1
2
  const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i;
2
3
  const TOP_LEVEL_SECTION_HEADER = /^##\s+/;
3
4
  const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
@@ -7,6 +8,7 @@ export function findMainSpecStructureIssues(content) {
7
8
  const stripped = stripFencedCodeBlocksPreservingLines(normalized);
8
9
  const lines = stripped.split('\n');
9
10
  const issues = [];
11
+ const requirementLines = new Map();
10
12
  const requirementsHeaderIndex = lines.findIndex(line => REQUIREMENTS_SECTION_HEADER.test(line));
11
13
  let requirementsEndIndex = lines.length;
12
14
  if (requirementsHeaderIndex !== -1) {
@@ -29,7 +31,7 @@ export function findMainSpecStructureIssues(content) {
29
31
  line: i + 1,
30
32
  header: trimmed,
31
33
  message: `Main spec contains delta header "${trimmed}". ` +
32
- '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 ' +
33
35
  'and truncate the parsed ## Requirements section.',
34
36
  });
35
37
  continue;
@@ -49,40 +51,28 @@ export function findMainSpecStructureIssues(content) {
49
51
  message: `Requirement header "${trimmed}" appears outside the main ## Requirements section. ` +
50
52
  'Main specs only parse requirements inside that section, so this requirement is currently invisible to validate, list, and archive.',
51
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);
52
69
  }
53
70
  }
54
71
  return issues;
55
72
  }
56
73
  export function stripFencedCodeBlocksPreservingLines(content) {
57
74
  const lines = content.split('\n');
58
- const output = [];
59
- let activeFence = null;
60
- for (const line of lines) {
61
- const fenceMatch = line.match(/^\s*(`{3,}|~{3,})(.*)$/);
62
- if (!activeFence) {
63
- if (fenceMatch) {
64
- activeFence = {
65
- marker: fenceMatch[1][0],
66
- length: fenceMatch[1].length,
67
- };
68
- output.push('');
69
- }
70
- else {
71
- output.push(line);
72
- }
73
- continue;
74
- }
75
- output.push('');
76
- if (isClosingFence(line, activeFence)) {
77
- activeFence = null;
78
- }
79
- }
80
- return output.join('\n');
81
- }
82
- function isClosingFence(line, activeFence) {
83
- const fenceMatch = line.match(/^\s*(`{3,}|~{3,})\s*$/);
84
- return Boolean(fenceMatch &&
85
- fenceMatch[1][0] === activeFence.marker &&
86
- fenceMatch[1].length >= activeFence.length);
75
+ const mask = buildCodeFenceMask(lines);
76
+ return lines.map((line, i) => (mask[i] ? '' : line)).join('\n');
87
77
  }
88
78
  //# sourceMappingURL=spec-structure.js.map
@@ -5,14 +5,6 @@ type WorkflowId = (typeof ALL_WORKFLOWS)[number];
5
5
  * Maps workflow IDs to their skill directory names.
6
6
  */
7
7
  export declare const WORKFLOW_TO_SKILL_DIR: Record<WorkflowId, string>;
8
- /**
9
- * Checks whether a tool has at least one generated OpenSpec command file.
10
- */
11
- export declare function toolHasAnyConfiguredCommand(projectPath: string, toolId: string): boolean;
12
- /**
13
- * Returns tools with at least one generated command file on disk.
14
- */
15
- export declare function getCommandConfiguredTools(projectPath: string): string[];
16
8
  /**
17
9
  * Returns tools that are configured via either skills or commands.
18
10
  */
@@ -3,7 +3,12 @@ import * as fs from 'fs';
3
3
  import { AI_TOOLS } from './config.js';
4
4
  import { ALL_WORKFLOWS } from './profiles.js';
5
5
  import { CommandAdapterRegistry } from './command-generation/index.js';
6
- import { COMMAND_IDS, getConfiguredTools } from './shared/index.js';
6
+ import { getConfiguredTools } from './shared/index.js';
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';
7
12
  /**
8
13
  * Maps workflow IDs to their skill directory names.
9
14
  */
@@ -24,48 +29,11 @@ export const WORKFLOW_TO_SKILL_DIR = {
24
29
  function toKnownWorkflows(workflows) {
25
30
  return workflows.filter((workflow) => ALL_WORKFLOWS.includes(workflow));
26
31
  }
27
- /**
28
- * Checks whether a tool has at least one generated OpenSpec command file.
29
- */
30
- export function toolHasAnyConfiguredCommand(projectPath, toolId) {
31
- const adapter = CommandAdapterRegistry.get(toolId);
32
- if (!adapter)
33
- return false;
34
- for (const commandId of COMMAND_IDS) {
35
- const cmdPath = adapter.getFilePath(commandId);
36
- const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
37
- if (fs.existsSync(fullPath)) {
38
- return true;
39
- }
40
- }
41
- return false;
42
- }
43
- /**
44
- * Returns tools with at least one generated command file on disk.
45
- */
46
- export function getCommandConfiguredTools(projectPath) {
47
- return AI_TOOLS
48
- .filter((tool) => {
49
- if (!tool.skillsDir)
50
- return false;
51
- const toolDir = path.join(projectPath, tool.skillsDir);
52
- try {
53
- return fs.statSync(toolDir).isDirectory();
54
- }
55
- catch {
56
- return false;
57
- }
58
- })
59
- .map((tool) => tool.value)
60
- .filter((toolId) => toolHasAnyConfiguredCommand(projectPath, toolId));
61
- }
62
32
  /**
63
33
  * Returns tools that are configured via either skills or commands.
64
34
  */
65
35
  export function getConfiguredToolsForProfileSync(projectPath) {
66
- const skillConfigured = getConfiguredTools(projectPath);
67
- const commandConfigured = getCommandConfiguredTools(projectPath);
68
- return [...new Set([...skillConfigured, ...commandConfigured])];
36
+ return getConfiguredTools(projectPath);
69
37
  }
70
38
  /**
71
39
  * Detects if a single tool has profile/delivery drift against the desired state.
@@ -77,14 +45,45 @@ export function getConfiguredToolsForProfileSync(projectPath) {
77
45
  */
78
46
  export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkflows, delivery) {
79
47
  const tool = AI_TOOLS.find((t) => t.value === toolId);
80
- if (!tool?.skillsDir)
48
+ if (!tool || !toolSupportsSkills(tool))
81
49
  return false;
82
50
  const knownDesiredWorkflows = toKnownWorkflows(desiredWorkflows);
83
51
  const desiredWorkflowSet = new Set(knownDesiredWorkflows);
84
- const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
52
+ const skillsDir = resolveToolSkillsDir(projectPath, tool);
85
53
  const adapter = CommandAdapterRegistry.get(toolId);
86
- const shouldGenerateSkills = delivery !== 'commands';
87
- const shouldGenerateCommands = delivery !== 'skills';
54
+ const shouldGenerateSkills = shouldGenerateSkillsForTool(toolId, delivery);
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
+ }
88
87
  if (shouldGenerateSkills) {
89
88
  for (const workflow of knownDesiredWorkflows) {
90
89
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
@@ -104,7 +103,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
104
103
  }
105
104
  }
106
105
  }
107
- else {
106
+ else if (shouldRemoveSkillsForTool(toolId, delivery) && !hasGlobalSkillTarget(tool)) {
108
107
  for (const workflow of ALL_WORKFLOWS) {
109
108
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
110
109
  const skillDir = path.join(skillsDir, dirName);
@@ -132,7 +131,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
132
131
  }
133
132
  }
134
133
  }
135
- else if (!shouldGenerateCommands && adapter) {
134
+ else if (shouldReconcileCommandFilesForTool(toolId, delivery) && adapter) {
136
135
  for (const workflow of ALL_WORKFLOWS) {
137
136
  const cmdPath = adapter.getFilePath(workflow);
138
137
  const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
@@ -152,10 +151,10 @@ export function getToolsNeedingProfileSync(projectPath, desiredWorkflows, delive
152
151
  }
153
152
  function getInstalledWorkflowsForTool(projectPath, toolId, options) {
154
153
  const tool = AI_TOOLS.find((t) => t.value === toolId);
155
- if (!tool?.skillsDir)
154
+ if (!tool || !toolSupportsSkills(tool))
156
155
  return [];
157
156
  const installed = new Set();
158
- const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
157
+ const skillsDir = resolveToolSkillsDir(projectPath, tool);
159
158
  if (options.includeSkills) {
160
159
  for (const workflow of ALL_WORKFLOWS) {
161
160
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
@@ -188,9 +187,9 @@ export function hasProjectConfigDrift(projectPath, desiredWorkflows, delivery) {
188
187
  return true;
189
188
  }
190
189
  const desiredSet = new Set(toKnownWorkflows(desiredWorkflows));
191
- const includeSkills = delivery !== 'commands';
192
- const includeCommands = delivery !== 'skills';
193
190
  for (const toolId of configuredTools) {
191
+ const includeSkills = shouldGenerateSkillsForTool(toolId, delivery);
192
+ const includeCommands = shouldGenerateCommandsForTool(toolId, delivery);
194
193
  const installed = getInstalledWorkflowsForTool(projectPath, toolId, { includeSkills, includeCommands });
195
194
  if (installed.some((workflow) => !desiredSet.has(workflow))) {
196
195
  return true;