@fission-ai/openspec 1.6.0-beta.1 → 1.7.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 (175) hide show
  1. package/README.md +28 -1
  2. package/dist/cli/index.js +73 -7
  3. package/dist/commands/change.d.ts +0 -1
  4. package/dist/commands/change.js +90 -50
  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 +21 -11
  9. package/dist/commands/show.js +5 -2
  10. package/dist/commands/spec.js +20 -24
  11. package/dist/commands/validate.js +18 -3
  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 +89 -6
  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 +7 -2
  19. package/dist/core/archive.d.ts +0 -1
  20. package/dist/core/archive.js +96 -33
  21. package/dist/core/artifact-graph/graph.d.ts +16 -0
  22. package/dist/core/artifact-graph/graph.js +30 -6
  23. package/dist/core/artifact-graph/instruction-loader.d.ts +27 -2
  24. package/dist/core/artifact-graph/instruction-loader.js +55 -7
  25. package/dist/core/artifact-graph/resolver.d.ts +14 -0
  26. package/dist/core/artifact-graph/resolver.js +34 -6
  27. package/dist/core/change-metadata/schema.d.ts +1 -0
  28. package/dist/core/change-metadata/schema.js +6 -0
  29. package/dist/core/change-status-policy.d.ts +1 -1
  30. package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
  31. package/dist/core/command-generation/adapters/amazon-q.js +7 -1
  32. package/dist/core/command-generation/adapters/antigravity.js +2 -1
  33. package/dist/core/command-generation/adapters/auggie.js +2 -1
  34. package/dist/core/command-generation/adapters/bob.d.ts +5 -1
  35. package/dist/core/command-generation/adapters/bob.js +6 -5
  36. package/dist/core/command-generation/adapters/claude.js +1 -8
  37. package/dist/core/command-generation/adapters/codebuddy.js +3 -2
  38. package/dist/core/command-generation/adapters/continue.js +3 -2
  39. package/dist/core/command-generation/adapters/costrict.js +2 -1
  40. package/dist/core/command-generation/adapters/crush.js +5 -5
  41. package/dist/core/command-generation/adapters/cursor.js +2 -2
  42. package/dist/core/command-generation/adapters/devin.d.ts +19 -0
  43. package/dist/core/command-generation/adapters/devin.js +36 -0
  44. package/dist/core/command-generation/adapters/factory.js +2 -1
  45. package/dist/core/command-generation/adapters/gemini.js +37 -2
  46. package/dist/core/command-generation/adapters/github-copilot.js +2 -1
  47. package/dist/core/command-generation/adapters/iflow.js +5 -4
  48. package/dist/core/command-generation/adapters/index.d.ts +2 -2
  49. package/dist/core/command-generation/adapters/index.js +2 -2
  50. package/dist/core/command-generation/adapters/junie.js +2 -1
  51. package/dist/core/command-generation/adapters/kiro.js +2 -1
  52. package/dist/core/command-generation/adapters/lingma.js +5 -5
  53. package/dist/core/command-generation/adapters/oh-my-pi.d.ts +2 -2
  54. package/dist/core/command-generation/adapters/oh-my-pi.js +3 -5
  55. package/dist/core/command-generation/adapters/opencode.js +3 -5
  56. package/dist/core/command-generation/adapters/pi.d.ts +2 -2
  57. package/dist/core/command-generation/adapters/pi.js +3 -6
  58. package/dist/core/command-generation/adapters/qoder.js +5 -5
  59. package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
  60. package/dist/core/command-generation/adapters/qwen.js +12 -7
  61. package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
  62. package/dist/core/command-generation/adapters/roocode.js +4 -4
  63. package/dist/core/command-generation/adapters/trae.js +1 -21
  64. package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
  65. package/dist/core/command-generation/adapters/zcode.js +33 -0
  66. package/dist/core/command-generation/generator.d.ts +8 -0
  67. package/dist/core/command-generation/generator.js +15 -1
  68. package/dist/core/command-generation/index.d.ts +1 -1
  69. package/dist/core/command-generation/index.js +1 -1
  70. package/dist/core/command-generation/invocation.d.ts +73 -0
  71. package/dist/core/command-generation/invocation.js +77 -0
  72. package/dist/core/command-generation/registry.js +4 -4
  73. package/dist/core/command-generation/types.d.ts +9 -2
  74. package/dist/core/command-generation/yaml.d.ts +20 -6
  75. package/dist/core/command-generation/yaml.js +31 -21
  76. package/dist/core/command-surface.d.ts +15 -0
  77. package/dist/core/command-surface.js +33 -0
  78. package/dist/core/completions/command-registry.js +8 -2
  79. package/dist/core/completions/generators/powershell-generator.js +15 -8
  80. package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
  81. package/dist/core/completions/installers/zsh-installer.js +20 -4
  82. package/dist/core/config-prompts.d.ts +1 -1
  83. package/dist/core/config-prompts.js +14 -1
  84. package/dist/core/config-schema.d.ts +7 -1
  85. package/dist/core/config-schema.js +40 -1
  86. package/dist/core/config.d.ts +12 -0
  87. package/dist/core/config.js +21 -3
  88. package/dist/core/global-config.d.ts +5 -0
  89. package/dist/core/init.d.ts +35 -0
  90. package/dist/core/init.js +225 -39
  91. package/dist/core/legacy-cleanup.d.ts +59 -0
  92. package/dist/core/legacy-cleanup.js +249 -22
  93. package/dist/core/list.js +5 -7
  94. package/dist/core/migration.d.ts +71 -1
  95. package/dist/core/migration.js +294 -1
  96. package/dist/core/onboarding-commands.d.ts +30 -0
  97. package/dist/core/onboarding-commands.js +38 -0
  98. package/dist/core/parsers/change-parser.d.ts +17 -2
  99. package/dist/core/parsers/change-parser.js +33 -20
  100. package/dist/core/parsers/code-fence.d.ts +15 -0
  101. package/dist/core/parsers/code-fence.js +49 -0
  102. package/dist/core/parsers/markdown-parser.js +2 -1
  103. package/dist/core/parsers/requirement-blocks.d.ts +8 -0
  104. package/dist/core/parsers/requirement-blocks.js +60 -35
  105. package/dist/core/parsers/requirement-text.d.ts +1 -8
  106. package/dist/core/parsers/requirement-text.js +4 -42
  107. package/dist/core/parsers/spec-structure.js +3 -29
  108. package/dist/core/profile-sync-drift.d.ts +0 -8
  109. package/dist/core/profile-sync-drift.js +9 -45
  110. package/dist/core/project-config.d.ts +25 -6
  111. package/dist/core/project-config.js +77 -7
  112. package/dist/core/references.js +42 -4
  113. package/dist/core/relationship-health.d.ts +8 -0
  114. package/dist/core/relationship-health.js +13 -0
  115. package/dist/core/root-selection.d.ts +9 -6
  116. package/dist/core/root-selection.js +42 -5
  117. package/dist/core/shared/tool-detection.d.ts +25 -5
  118. package/dist/core/shared/tool-detection.js +105 -6
  119. package/dist/core/specs-apply.d.ts +8 -33
  120. package/dist/core/specs-apply.js +252 -157
  121. package/dist/core/store/git.d.ts +13 -0
  122. package/dist/core/store/git.js +24 -0
  123. package/dist/core/templates/workflows/apply-change.js +54 -4
  124. package/dist/core/templates/workflows/archive-change.js +166 -42
  125. package/dist/core/templates/workflows/bulk-archive-change.js +240 -63
  126. package/dist/core/templates/workflows/continue-change.js +32 -40
  127. package/dist/core/templates/workflows/explore.js +12 -0
  128. package/dist/core/templates/workflows/ff-change.js +46 -30
  129. package/dist/core/templates/workflows/new-change.js +2 -2
  130. package/dist/core/templates/workflows/onboard.js +4 -4
  131. package/dist/core/templates/workflows/propose.js +50 -32
  132. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  133. package/dist/core/templates/workflows/store-selection.js +1 -1
  134. package/dist/core/templates/workflows/sync-specs.js +172 -22
  135. package/dist/core/templates/workflows/update-change.js +20 -12
  136. package/dist/core/templates/workflows/verify-change.js +16 -10
  137. package/dist/core/update.d.ts +39 -5
  138. package/dist/core/update.js +288 -71
  139. package/dist/core/validation/constants.d.ts +4 -1
  140. package/dist/core/validation/constants.js +4 -1
  141. package/dist/core/validation/validator.d.ts +1 -7
  142. package/dist/core/validation/validator.js +92 -36
  143. package/dist/core/version-check.d.ts +152 -0
  144. package/dist/core/version-check.js +736 -0
  145. package/dist/core/view.js +12 -17
  146. package/dist/prompts/searchable-multi-select.js +1 -1
  147. package/dist/telemetry/index.d.ts +1 -1
  148. package/dist/telemetry/index.js +55 -33
  149. package/dist/ui/welcome-screen.d.ts +9 -1
  150. package/dist/ui/welcome-screen.js +91 -38
  151. package/dist/utils/change-metadata.d.ts +30 -0
  152. package/dist/utils/change-metadata.js +101 -7
  153. package/dist/utils/change-utils.d.ts +10 -4
  154. package/dist/utils/change-utils.js +20 -13
  155. package/dist/utils/command-references.d.ts +80 -6
  156. package/dist/utils/command-references.js +138 -7
  157. package/dist/utils/date.d.ts +8 -0
  158. package/dist/utils/date.js +13 -0
  159. package/dist/utils/index.d.ts +1 -1
  160. package/dist/utils/index.js +1 -1
  161. package/dist/utils/item-discovery.d.ts +19 -0
  162. package/dist/utils/item-discovery.js +30 -48
  163. package/dist/utils/shell-detection.d.ts +1 -1
  164. package/dist/utils/shell-detection.js +64 -11
  165. package/dist/utils/spec-discovery.d.ts +40 -0
  166. package/dist/utils/spec-discovery.js +102 -0
  167. package/package.json +29 -30
  168. package/schemas/spec-driven/schema.yaml +60 -5
  169. package/schemas/spec-driven/templates/design.md +2 -2
  170. package/schemas/spec-driven/templates/proposal.md +5 -1
  171. package/schemas/spec-driven/templates/spec.md +3 -0
  172. package/dist/core/command-generation/adapters/codex.d.ts +0 -16
  173. package/dist/core/command-generation/adapters/codex.js +0 -39
  174. package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
  175. package/dist/core/command-generation/adapters/windsurf.js +0 -38
@@ -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) {
@@ -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,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;
@@ -55,34 +56,7 @@ export function findMainSpecStructureIssues(content) {
55
56
  }
56
57
  export function stripFencedCodeBlocksPreservingLines(content) {
57
58
  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);
59
+ const mask = buildCodeFenceMask(lines);
60
+ return lines.map((line, i) => (mask[i] ? '' : line)).join('\n');
87
61
  }
88
62
  //# 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,8 @@ 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';
7
8
  /**
8
9
  * Maps workflow IDs to their skill directory names.
9
10
  */
@@ -24,48 +25,11 @@ export const WORKFLOW_TO_SKILL_DIR = {
24
25
  function toKnownWorkflows(workflows) {
25
26
  return workflows.filter((workflow) => ALL_WORKFLOWS.includes(workflow));
26
27
  }
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
28
  /**
63
29
  * Returns tools that are configured via either skills or commands.
64
30
  */
65
31
  export function getConfiguredToolsForProfileSync(projectPath) {
66
- const skillConfigured = getConfiguredTools(projectPath);
67
- const commandConfigured = getCommandConfiguredTools(projectPath);
68
- return [...new Set([...skillConfigured, ...commandConfigured])];
32
+ return getConfiguredTools(projectPath);
69
33
  }
70
34
  /**
71
35
  * Detects if a single tool has profile/delivery drift against the desired state.
@@ -83,8 +47,8 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
83
47
  const desiredWorkflowSet = new Set(knownDesiredWorkflows);
84
48
  const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
85
49
  const adapter = CommandAdapterRegistry.get(toolId);
86
- const shouldGenerateSkills = delivery !== 'commands';
87
- const shouldGenerateCommands = delivery !== 'skills';
50
+ const shouldGenerateSkills = shouldGenerateSkillsForTool(toolId, delivery);
51
+ const shouldGenerateCommands = shouldGenerateCommandsForTool(toolId, delivery);
88
52
  if (shouldGenerateSkills) {
89
53
  for (const workflow of knownDesiredWorkflows) {
90
54
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
@@ -104,7 +68,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
104
68
  }
105
69
  }
106
70
  }
107
- else {
71
+ else if (shouldRemoveSkillsForTool(toolId, delivery)) {
108
72
  for (const workflow of ALL_WORKFLOWS) {
109
73
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
110
74
  const skillDir = path.join(skillsDir, dirName);
@@ -132,7 +96,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
132
96
  }
133
97
  }
134
98
  }
135
- else if (!shouldGenerateCommands && adapter) {
99
+ else if (shouldReconcileCommandFilesForTool(toolId, delivery) && adapter) {
136
100
  for (const workflow of ALL_WORKFLOWS) {
137
101
  const cmdPath = adapter.getFilePath(workflow);
138
102
  const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
@@ -188,9 +152,9 @@ export function hasProjectConfigDrift(projectPath, desiredWorkflows, delivery) {
188
152
  return true;
189
153
  }
190
154
  const desiredSet = new Set(toKnownWorkflows(desiredWorkflows));
191
- const includeSkills = delivery !== 'commands';
192
- const includeCommands = delivery !== 'skills';
193
155
  for (const toolId of configuredTools) {
156
+ const includeSkills = shouldGenerateSkillsForTool(toolId, delivery);
157
+ const includeCommands = shouldGenerateCommandsForTool(toolId, delivery);
194
158
  const installed = getInstalledWorkflowsForTool(projectPath, toolId, { includeSkills, includeCommands });
195
159
  if (installed.some((workflow) => !desiredSet.has(workflow))) {
196
160
  return true;
@@ -1,4 +1,10 @@
1
1
  import { z } from 'zod';
2
+ export declare const OPERATION_IDS: readonly ["apply", "archive"];
3
+ export type OperationId = (typeof OPERATION_IDS)[number];
4
+ export interface OperationConfig {
5
+ guidance?: string[];
6
+ }
7
+ export type OperationsConfig = Partial<Record<OperationId, OperationConfig>>;
2
8
  /**
3
9
  * Zod schema for project configuration.
4
10
  *
@@ -16,6 +22,14 @@ export declare const ProjectConfigSchema: z.ZodObject<{
16
22
  schema: z.ZodString;
17
23
  context: z.ZodOptional<z.ZodString>;
18
24
  rules: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>;
25
+ operations: z.ZodOptional<z.ZodObject<{
26
+ apply: z.ZodOptional<z.ZodObject<{
27
+ guidance: z.ZodOptional<z.ZodArray<z.ZodString>>;
28
+ }, z.core.$strip>>;
29
+ archive: z.ZodOptional<z.ZodObject<{
30
+ guidance: z.ZodOptional<z.ZodArray<z.ZodString>>;
31
+ }, z.core.$strip>>;
32
+ }, z.core.$strip>>;
19
33
  store: z.ZodOptional<z.ZodString>;
20
34
  }, z.core.$strip>;
21
35
  /** Normalized in-memory shape of a referenced store declaration. */
@@ -27,6 +41,11 @@ export interface DeclarationEntry {
27
41
  export type ProjectConfig = z.infer<typeof ProjectConfigSchema> & {
28
42
  references?: DeclarationEntry[];
29
43
  };
44
+ export interface OperationInputs {
45
+ context?: string;
46
+ operationGuidance?: string[];
47
+ }
48
+ export declare function loadOperationInputs(projectConfig: ProjectConfig | null, operationId: OperationId): OperationInputs;
30
49
  export declare const MAX_CONTEXT_SIZE: number;
31
50
  /**
32
51
  * Read and parse openspec/config.yaml from project root.
@@ -49,16 +68,16 @@ export declare const MAX_CONTEXT_SIZE: number;
49
68
  */
50
69
  export declare function readProjectConfig(projectRoot: string): ProjectConfig | null;
51
70
  /**
52
- * Validate artifact IDs in rules against a schema's artifacts.
53
- * Called during instruction loading (when schema is known).
54
- * Returns warnings for unknown artifact IDs.
71
+ * Validate artifact IDs in rules against the artifacts of every available
72
+ * schema. The `rules:` map is global, but each change can use a different
73
+ * schema, so a key is only unknown when it matches no artifact in ANY schema.
74
+ * Returns warnings for keys that are unknown everywhere.
55
75
  *
56
76
  * @param rules - The rules object from config
57
- * @param validArtifactIds - Set of valid artifact IDs from the schema
58
- * @param schemaName - Name of the schema for error messages
77
+ * @param validArtifactIds - Set of valid artifact IDs across all schemas
59
78
  * @returns Array of warning messages for unknown artifact IDs
60
79
  */
61
- export declare function validateConfigRules(rules: Record<string, string[]>, validArtifactIds: Set<string>, schemaName: string): string[];
80
+ export declare function validateConfigRules(rules: Record<string, string[]>, validArtifactIds: Set<string>): string[];
62
81
  /**
63
82
  * Suggest valid schema names when user provides invalid schema.
64
83
  * Uses fuzzy matching to find similar names.
@@ -2,6 +2,10 @@ import { existsSync, readFileSync, statSync } from 'fs';
2
2
  import path from 'path';
3
3
  import { parse as parseYaml } from 'yaml';
4
4
  import { z } from 'zod';
5
+ export const OPERATION_IDS = ['apply', 'archive'];
6
+ const OperationConfigSchema = z.object({
7
+ guidance: z.array(z.string()).optional(),
8
+ });
5
9
  /**
6
10
  * Zod schema for project configuration.
7
11
  *
@@ -34,6 +38,14 @@ export const ProjectConfigSchema = z.object({
34
38
  )
35
39
  .optional()
36
40
  .describe('Per-artifact rules, keyed by artifact ID'),
41
+ // Optional: per-operation advisory guidance, kept separate from artifact rules.
42
+ operations: z
43
+ .object({
44
+ apply: OperationConfigSchema.optional(),
45
+ archive: OperationConfigSchema.optional(),
46
+ })
47
+ .optional()
48
+ .describe('Per-operation advisory guidance'),
37
49
  // Note: the `references` field (id strings or {id, remote} maps) is
38
50
  // deliberately absent here — readProjectConfig parses and normalizes
39
51
  // it by hand (see DeclarationEntry below); a schema entry nothing
@@ -46,6 +58,60 @@ export const ProjectConfigSchema = z.object({
46
58
  .optional()
47
59
  .describe('Store id used as the OpenSpec root when no local planning shape exists'),
48
60
  });
61
+ export function loadOperationInputs(projectConfig, operationId) {
62
+ const context = projectConfig?.context !== undefined && projectConfig.context.trim().length > 0
63
+ ? projectConfig.context
64
+ : undefined;
65
+ const guidance = projectConfig?.operations?.[operationId]?.guidance;
66
+ const operationGuidance = guidance && guidance.length > 0 ? guidance : undefined;
67
+ return {
68
+ ...(context !== undefined ? { context } : {}),
69
+ ...(operationGuidance !== undefined ? { operationGuidance } : {}),
70
+ };
71
+ }
72
+ function parseOperations(raw) {
73
+ if (raw === undefined) {
74
+ return undefined;
75
+ }
76
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
77
+ console.warn(`Invalid 'operations' field in config (must be object)`);
78
+ return undefined;
79
+ }
80
+ const supported = new Set(OPERATION_IDS);
81
+ const operations = {};
82
+ for (const [operationId, value] of Object.entries(raw)) {
83
+ if (!supported.has(operationId)) {
84
+ console.warn(`Unknown operation ID '${operationId}' in config. Supported operation IDs: ${OPERATION_IDS.join(', ')}`);
85
+ continue;
86
+ }
87
+ const typedOperationId = operationId;
88
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
89
+ console.warn(`Invalid 'operations.${operationId}' field in config (must be object), ignoring this operation`);
90
+ continue;
91
+ }
92
+ const operation = value;
93
+ const unknownFields = Object.keys(operation).filter((field) => field !== 'guidance');
94
+ if (unknownFields.length > 0) {
95
+ console.warn(`Unknown field(s) in 'operations.${operationId}': ${unknownFields.join(', ')}. Supported fields: guidance`);
96
+ }
97
+ if (operation.guidance === undefined) {
98
+ continue;
99
+ }
100
+ const guidanceResult = z.array(z.string()).safeParse(operation.guidance);
101
+ if (!guidanceResult.success) {
102
+ console.warn(`Guidance for operation '${operationId}' must be an array of strings, ignoring this operation's guidance`);
103
+ continue;
104
+ }
105
+ const guidance = guidanceResult.data.filter((entry) => entry.length > 0);
106
+ if (guidance.length < guidanceResult.data.length) {
107
+ console.warn(`Some guidance for operation '${operationId}' are empty strings, ignoring them`);
108
+ }
109
+ if (guidance.length > 0) {
110
+ operations[typedOperationId] = { guidance };
111
+ }
112
+ }
113
+ return Object.keys(operations).length > 0 ? operations : undefined;
114
+ }
49
115
  /**
50
116
  * Parser for `references:` declarations: string entries or
51
117
  * {id, remote} maps, normalized to DeclarationEntry[]. Dedup keys on
@@ -196,6 +262,10 @@ export function readProjectConfig(projectRoot) {
196
262
  console.warn(`Invalid 'rules' field in config (must be object)`);
197
263
  }
198
264
  }
265
+ const operations = parseOperations(raw.operations);
266
+ if (operations) {
267
+ config.operations = operations;
268
+ }
199
269
  const references = parseDeclarationList(raw.references);
200
270
  if (references) {
201
271
  config.references = references;
@@ -223,22 +293,22 @@ function configPathForWarnings(projectRoot) {
223
293
  return resolveConfigFilePath(projectRoot) ?? path.join(projectRoot, 'openspec', 'config.yaml');
224
294
  }
225
295
  /**
226
- * Validate artifact IDs in rules against a schema's artifacts.
227
- * Called during instruction loading (when schema is known).
228
- * Returns warnings for unknown artifact IDs.
296
+ * Validate artifact IDs in rules against the artifacts of every available
297
+ * schema. The `rules:` map is global, but each change can use a different
298
+ * schema, so a key is only unknown when it matches no artifact in ANY schema.
299
+ * Returns warnings for keys that are unknown everywhere.
229
300
  *
230
301
  * @param rules - The rules object from config
231
- * @param validArtifactIds - Set of valid artifact IDs from the schema
232
- * @param schemaName - Name of the schema for error messages
302
+ * @param validArtifactIds - Set of valid artifact IDs across all schemas
233
303
  * @returns Array of warning messages for unknown artifact IDs
234
304
  */
235
- export function validateConfigRules(rules, validArtifactIds, schemaName) {
305
+ export function validateConfigRules(rules, validArtifactIds) {
236
306
  const warnings = [];
237
307
  for (const artifactId of Object.keys(rules)) {
238
308
  if (!validArtifactIds.has(artifactId)) {
239
309
  const validIds = Array.from(validArtifactIds).sort().join(', ');
240
310
  warnings.push(`Unknown artifact ID in rules: "${artifactId}". ` +
241
- `Valid IDs for schema "${schemaName}": ${validIds}`);
311
+ `It matches no artifact in any available schema. Known artifact IDs: ${validIds}`);
242
312
  }
243
313
  }
244
314
  return warnings;