@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
@@ -7,9 +7,12 @@
7
7
  import { promises as fs } from 'fs';
8
8
  import path from 'path';
9
9
  import chalk from 'chalk';
10
- import { extractRequirementsSection, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
10
+ import { extractRequirementsSection, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
11
11
  import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
12
- import { Validator } from './validation/validator.js';
12
+ import { buildCodeFenceMask } from './parsers/code-fence.js';
13
+ import { MarkdownParser } from './parsers/markdown-parser.js';
14
+ import { MIN_PURPOSE_LENGTH } from './validation/constants.js';
15
+ import { discoverSpecFiles } from '../utils/spec-discovery.js';
13
16
  // -----------------------------------------------------------------------------
14
17
  // Public API
15
18
  // -----------------------------------------------------------------------------
@@ -19,37 +22,27 @@ import { Validator } from './validation/validator.js';
19
22
  export async function findSpecUpdates(changeDir, mainSpecsDir) {
20
23
  const updates = [];
21
24
  const changeSpecsDir = path.join(changeDir, 'specs');
22
- try {
23
- const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });
24
- for (const entry of entries) {
25
- if (entry.isDirectory()) {
26
- const specFile = path.join(changeSpecsDir, entry.name, 'spec.md');
27
- const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md');
28
- try {
29
- await fs.access(specFile);
30
- // Check if target exists
31
- let exists = false;
32
- try {
33
- await fs.access(targetFile);
34
- exists = true;
35
- }
36
- catch {
37
- exists = false;
38
- }
39
- updates.push({
40
- source: specFile,
41
- target: targetFile,
42
- exists,
43
- });
44
- }
45
- catch {
46
- // Source spec doesn't exist, skip
47
- }
48
- }
25
+ // Discover delta specs recursively so nested layouts like
26
+ // specs/<area>/<capability>/spec.md merge into the same relative path
27
+ // under the main specs directory (#1353)
28
+ const discovered = await discoverSpecFiles(changeSpecsDir);
29
+ for (const { id, specFile } of discovered) {
30
+ const targetFile = path.join(mainSpecsDir, ...id.split('/'), 'spec.md');
31
+ // Check if target exists
32
+ let exists = false;
33
+ try {
34
+ await fs.access(targetFile);
35
+ exists = true;
49
36
  }
50
- }
51
- catch {
52
- // No specs directory in change
37
+ catch {
38
+ exists = false;
39
+ }
40
+ updates.push({
41
+ id,
42
+ source: specFile,
43
+ target: targetFile,
44
+ exists,
45
+ });
53
46
  }
54
47
  return updates;
55
48
  }
@@ -58,11 +51,20 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
58
51
  * Returns the rebuilt content and counts of operations.
59
52
  */
60
53
  export async function buildUpdatedSpec(update, changeName, options = {}) {
54
+ // Collected so silent (JSON) callers can surface them; printed live for
55
+ // human callers at the point they occur.
56
+ const warnings = [];
57
+ const warn = (message) => {
58
+ warnings.push(message);
59
+ if (!options.silent) {
60
+ console.log(chalk.yellow(`⚠️ Warning: ${message}`));
61
+ }
62
+ };
61
63
  // Read change spec content (delta-format expected)
62
64
  const changeContent = await fs.readFile(update.source, 'utf-8');
63
65
  // Parse deltas from the change spec file
64
66
  const plan = parseDeltaSpec(changeContent);
65
- const specName = path.basename(path.dirname(update.target));
67
+ const specName = update.id;
66
68
  // Pre-validate duplicates within sections
67
69
  const addedNames = new Set();
68
70
  for (const add of plan.added) {
@@ -118,6 +120,17 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
118
120
  for (const { from, to } of plan.renamed) {
119
121
  const fromNorm = normalizeRequirementName(from);
120
122
  const toNorm = normalizeRequirementName(to);
123
+ // A REMOVED naming the FROM side contradicts the rename. This used to
124
+ // fail incidentally at apply time (the rename consumed the old header,
125
+ // so REMOVED hit "not found"); now that a missing REMOVED target is a
126
+ // no-op, the conflict must be rejected explicitly. Compared folded, so
127
+ // a case/whitespace variant cannot slip past the guard and degrade
128
+ // into a warned no-op.
129
+ const removedFoldMatch = [...removedNamesSet].find((r) => foldRequirementName(r) === foldRequirementName(fromNorm));
130
+ if (removedFoldMatch !== undefined) {
131
+ throw new Error(`${specName} validation failed - requirement present in multiple sections (RENAMED and REMOVED) for header "### Requirement: ${from}"` +
132
+ (removedFoldMatch === fromNorm ? '' : ` (REMOVED spells it "${removedFoldMatch}")`));
133
+ }
121
134
  if (modifiedNames.has(fromNorm)) {
122
135
  throw new Error(`${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"`);
123
136
  }
@@ -132,14 +145,28 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
132
145
  }
133
146
  const hasAnyDelta = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;
134
147
  if (!hasAnyDelta) {
135
- throw new Error(`Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` +
148
+ throw new Error(`Delta parsing found no operations for ${update.id}. ` +
136
149
  `Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.`);
137
150
  }
138
151
  // Load or create base target content
152
+ const deltaPurpose = extractPurposeSection(changeContent);
139
153
  let targetContent;
140
154
  let isNewSpec = false;
141
155
  try {
142
156
  targetContent = await fs.readFile(update.target, 'utf-8');
157
+ // A delta Purpose only seeds a spec that does not exist yet. Say so rather
158
+ // than dropping it silently - the specs instruction tells authors to write
159
+ // one for new capabilities, and the delta file looks identical either way.
160
+ // Only when the spec really does have a different Purpose: claiming it
161
+ // "already has one" would be false when it has none, and saying anything at
162
+ // all is noise when the two bodies match.
163
+ if (deltaPurpose) {
164
+ const existingPurpose = extractPurposeSection(targetContent);
165
+ if (existingPurpose && existingPurpose !== deltaPurpose) {
166
+ warn(`${specName} - delta Purpose ignored; ${specName} already has one. ` +
167
+ `Edit ${update.target} directly to change it.`);
168
+ }
169
+ }
143
170
  }
144
171
  catch {
145
172
  // Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
@@ -148,11 +175,25 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
148
175
  throw new Error(`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.`);
149
176
  }
150
177
  // Warn about REMOVED requirements being ignored for new specs
151
- if (plan.removed.length > 0 && !options.silent) {
152
- console.log(chalk.yellow(`⚠️ Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`));
178
+ if (plan.removed.length > 0) {
179
+ warn(`${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`);
153
180
  }
154
181
  isNewSpec = true;
155
- targetContent = buildSpecSkeleton(specName, changeName);
182
+ targetContent = buildSpecSkeleton(specName, changeName, deltaPurpose);
183
+ const overview = deltaPurpose ? readableOverview(targetContent, specName) : null;
184
+ if (deltaPurpose && !overview) {
185
+ // Keep the placeholder rather than turning this into a failure: these
186
+ // deltas archived cleanly before the Purpose carry-over existed.
187
+ targetContent = buildSpecSkeleton(specName, changeName);
188
+ warn(`${specName} - delta Purpose ignored (it would leave the new spec unreadable); wrote the placeholder Purpose instead.`);
189
+ }
190
+ else if (overview && overview.length < MIN_PURPOSE_LENGTH) {
191
+ // The placeholder always cleared this threshold, so a carried Purpose is
192
+ // the first way archive can leave a spec that `validate --strict` fails.
193
+ // Measured on the parsed overview, which is what the validator reads.
194
+ warn(`${specName} - carried Purpose is under ${MIN_PURPOSE_LENGTH} characters; ` +
195
+ `openspec validate --strict reports it as too brief.`);
196
+ }
156
197
  }
157
198
  const structureIssues = findMainSpecStructureIssues(targetContent);
158
199
  if (structureIssues.length > 0) {
@@ -169,10 +210,24 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
169
210
  }
170
211
  // Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
171
212
  // RENAMED
213
+ let renamedApplied = 0;
172
214
  for (const r of plan.renamed) {
173
215
  const from = normalizeRequirementName(r.from);
174
216
  const to = normalizeRequirementName(r.to);
175
217
  if (!nameToBlock.has(from)) {
218
+ // Source gone but target present means the rename was already synced
219
+ // to the baseline (early-sync pattern) — re-applying it is a no-op,
220
+ // not a failure. Only a missing source AND target is a genuine error.
221
+ if (nameToBlock.has(to)) {
222
+ // Unless a case/whitespace variant of the source still exists (and is
223
+ // not the target itself, as in a case-only rename): that is a typo'd
224
+ // header, not an early-synced rename — same guard REMOVED applies.
225
+ const nearMiss = [...nameToBlock.keys()].find((k) => k !== to && foldRequirementName(k) === foldRequirementName(from));
226
+ if (nearMiss !== undefined) {
227
+ throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found, but "### Requirement: ${nameToBlock.get(nearMiss).name}" exists; fix the header to match it exactly`);
228
+ }
229
+ continue;
230
+ }
176
231
  throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`);
177
232
  }
178
233
  if (nameToBlock.has(to)) {
@@ -189,22 +244,33 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
189
244
  };
190
245
  nameToBlock.delete(from);
191
246
  nameToBlock.set(to, renamedBlock);
247
+ renamedApplied++;
192
248
  }
193
249
  // REMOVED
250
+ let removedApplied = 0;
194
251
  for (const name of plan.removed) {
195
252
  const key = normalizeRequirementName(name);
196
253
  if (!nameToBlock.has(key)) {
197
- // For new specs, REMOVED requirements are already warned about and ignored
198
- // For existing specs, missing requirements are an error
254
+ // Requirement gone from the baseline means the removal was already
255
+ // synced (early-sync pattern) — re-applying it is a no-op, not a
256
+ // failure. One signal does separate that from a mistyped header: a
257
+ // requirement that differs only in case or interior whitespace still
258
+ // being present. That is a typo, and stays a hard abort.
259
+ // For new specs the skip was already warned about above.
199
260
  if (!isNewSpec) {
200
- throw new Error(`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`);
261
+ const nearMiss = [...nameToBlock.keys()].find((k) => foldRequirementName(k) === foldRequirementName(key));
262
+ if (nearMiss !== undefined) {
263
+ throw new Error(`${specName} REMOVED failed for header "### Requirement: ${name}" - not found, but "### Requirement: ${nameToBlock.get(nearMiss).name}" exists; fix the header to match it exactly`);
264
+ }
265
+ warn(`${specName} - REMOVED requirement "${name}" is not in the current spec; treating it as already removed.`);
201
266
  }
202
- // Skip removal for new specs (already warned above)
203
267
  continue;
204
268
  }
205
269
  nameToBlock.delete(key);
270
+ removedApplied++;
206
271
  }
207
272
  // MODIFIED
273
+ let modifiedApplied = 0;
208
274
  for (const mod of plan.modified) {
209
275
  const key = normalizeRequirementName(mod.name);
210
276
  const currentBlock = nameToBlock.get(key);
@@ -220,15 +286,31 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
220
286
  if (missingScenarios.length > 0) {
221
287
  throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${missingScenarios.map(name => `"${name}"`).join(', ')}. Refresh the change spec before archiving to avoid dropping scenarios.`);
222
288
  }
289
+ // Identical content means the modification was already synced to the
290
+ // baseline (early-sync pattern) — count only real replacements, so a
291
+ // fully synced change still takes the "already in sync" write skip
292
+ // instead of churning normalization differences into the file.
293
+ if (normalizeBlockRaw(currentBlock.raw) !== normalizeBlockRaw(mod.raw)) {
294
+ modifiedApplied++;
295
+ }
223
296
  nameToBlock.set(key, mod);
224
297
  }
225
298
  // ADDED
299
+ let addedApplied = 0;
226
300
  for (const add of plan.added) {
227
301
  const key = normalizeRequirementName(add.name);
228
- if (nameToBlock.has(key)) {
302
+ const existing = nameToBlock.get(key);
303
+ if (existing) {
304
+ // Identical content means the requirement was already synced to the
305
+ // baseline (early-sync pattern) — re-applying it is a no-op, not a
306
+ // conflict. Only differing content is a genuine collision.
307
+ if (normalizeBlockRaw(existing.raw) === normalizeBlockRaw(add.raw)) {
308
+ continue;
309
+ }
229
310
  throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
230
311
  }
231
312
  nameToBlock.set(key, add);
313
+ addedApplied++;
232
314
  }
233
315
  // Duplicates within resulting map are implicitly prevented by key uniqueness.
234
316
  // Recompose requirements section preserving original ordering where possible
@@ -260,13 +342,17 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
260
342
  return {
261
343
  rebuilt,
262
344
  counts: {
263
- added: plan.added.length,
264
- modified: plan.modified.length,
265
- removed: plan.removed.length,
266
- renamed: plan.renamed.length,
345
+ added: addedApplied,
346
+ modified: modifiedApplied,
347
+ removed: removedApplied,
348
+ renamed: renamedApplied,
267
349
  },
350
+ warnings,
268
351
  };
269
352
  }
353
+ function normalizeBlockRaw(raw) {
354
+ return raw.replace(/\r\n?/g, '\n').trim();
355
+ }
270
356
  /**
271
357
  * Write an updated spec to disk.
272
358
  */
@@ -277,7 +363,7 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
277
363
  await fs.writeFile(update.target, rebuilt);
278
364
  if (options.silent)
279
365
  return;
280
- const specName = path.basename(path.dirname(update.target));
366
+ const specName = update.id;
281
367
  console.log(`Applying changes to ${options.displayPath ?? `openspec/specs/${specName}/spec.md`}:`);
282
368
  if (counts.added)
283
369
  console.log(` + ${counts.added} added`);
@@ -288,25 +374,135 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
288
374
  if (counts.renamed)
289
375
  console.log(` → ${counts.renamed} renamed`);
290
376
  }
377
+ /** Blank out `<!-- ... -->` spans, preserving line count so indices stay aligned. */
378
+ function maskHtmlComments(content) {
379
+ const blank = (text) => text.replace(/[^\n]/g, ' ');
380
+ // `--!>` is a comment terminator as well as `-->`.
381
+ const masked = content.replace(/<!--[\s\S]*?--!?>/g, blank);
382
+ // A comment that is never closed runs to end of file, so everything after it
383
+ // is commented out too. Without this an unterminated `<!--` above a
384
+ // `## Purpose` left the commented-out header looking real (#1413).
385
+ const unterminated = masked.indexOf('<!--');
386
+ if (unterminated === -1)
387
+ return masked;
388
+ return masked.slice(0, unterminated) + blank(masked.slice(unterminated));
389
+ }
390
+ /**
391
+ * Read the body of a `## Purpose` section, ignoring markdown that only appears
392
+ * inside fenced code blocks or HTML comments. Returns undefined when the
393
+ * section is absent or its body is empty.
394
+ */
395
+ function extractPurposeSection(content) {
396
+ const normalized = content.replace(/\r\n?/g, '\n');
397
+ const lines = normalized.split('\n');
398
+ // Structure is read from the masked copy so a commented-out or fenced
399
+ // `## Purpose` is not mistaken for the real one; the body is returned from
400
+ // the original lines so an author's own comments and fences survive intact.
401
+ const masked = maskHtmlComments(normalized).split('\n');
402
+ const fenceMask = buildCodeFenceMask(masked);
403
+ const isStructural = (i) => !fenceMask[i];
404
+ const start = masked.findIndex((line, i) => isStructural(i) && /^##\s+Purpose\s*$/i.test(line));
405
+ if (start === -1)
406
+ return undefined;
407
+ let end = masked.length;
408
+ for (let i = start + 1; i < masked.length; i++) {
409
+ if (isStructural(i) && /^##\s+/.test(masked[i])) {
410
+ end = i;
411
+ break;
412
+ }
413
+ }
414
+ // Emptiness is judged with fenced blocks and HTML comments blanked out, so a
415
+ // Purpose that is only a code sample or only an unfilled template comment
416
+ // counts as absent and falls back to the TBD placeholder.
417
+ const hasProse = masked
418
+ .slice(start + 1, end)
419
+ .filter((_, offset) => isStructural(start + 1 + offset))
420
+ .join('\n')
421
+ .trim();
422
+ if (!hasProse)
423
+ return undefined;
424
+ const body = lines.slice(start + 1, end).join('\n').trim();
425
+ return body || undefined;
426
+ }
427
+ /**
428
+ * The Purpose a new main spec would end up with, or null when carrying the
429
+ * delta's body over would leave a spec the readers downstream cannot handle.
430
+ *
431
+ * Returns the parsed overview rather than a boolean so callers measure the same
432
+ * string `validate` measures, not the raw slice out of the delta.
433
+ */
434
+ function readableOverview(skeleton, specName) {
435
+ // HTML comments are invisible to the spec parsers but not to the file itself:
436
+ // markdown hidden in one is skipped by the boundary scan yet still lands in
437
+ // the spec, where it can hide the headers those parsers depend on and blank
438
+ // the document out in any markdown renderer. Refuse rather than write a spec
439
+ // that reads differently depending on who is reading it (#1413).
440
+ //
441
+ // Only the opener is disqualifying, and only because `maskHtmlComments`
442
+ // covers unterminated comments too: a comment starting above the section
443
+ // header therefore always masks the header, leaving no body to carry, so a
444
+ // body can only hide content behind a `<!--` of its own. A bare `-->` hides
445
+ // nothing and renders as text - rejecting it would throw away a Purpose over
446
+ // prose like "ingest --> transform".
447
+ if (skeleton.includes('<!--'))
448
+ return null;
449
+ if (findMainSpecStructureIssues(skeleton).length > 0)
450
+ return null;
451
+ try {
452
+ // A heading or unterminated fence in the body truncates or swallows the
453
+ // sections around it, so archive would abort or write a spec its own
454
+ // validator rejects.
455
+ return new MarkdownParser(skeleton).parseSpec(specName).overview.trim() || null;
456
+ }
457
+ catch {
458
+ return null;
459
+ }
460
+ }
291
461
  /**
292
- * Build a skeleton spec for new capabilities.
462
+ * Build a skeleton spec for new capabilities. When the delta spec authored a
463
+ * `## Purpose`, carry it over instead of the TBD placeholder (#1413) - archive
464
+ * invents the Purpose for a brand-new main spec either way, and the author's
465
+ * own wording beats a placeholder they then have to hand-edit.
293
466
  */
294
- export function buildSpecSkeleton(specFolderName, changeName) {
467
+ export function buildSpecSkeleton(specFolderName, changeName, purpose) {
295
468
  const titleBase = specFolderName;
296
- return `# ${titleBase} Specification\n\n## Purpose\nTBD - created by archiving change ${changeName}. Update Purpose after archive.\n\n## Requirements\n`;
469
+ const purposeBody = purpose?.trim() || `TBD - created by archiving change ${changeName}. Update Purpose after archive.`;
470
+ return `# ${titleBase} Specification\n\n## Purpose\n${purposeBody}\n\n## Requirements\n`;
297
471
  }
298
472
  function findMissingCurrentScenarios(current, incoming) {
299
- const incomingScenarioNames = new Set(parseScenarioBlocks(incoming.raw).map((scenario) => scenario.name));
300
- return parseScenarioBlocks(current.raw)
301
- .filter((scenario) => !incomingScenarioNames.has(scenario.name))
302
- .map((scenario) => scenario.name);
473
+ // Multiplicity-aware: a name present N times in current and M times in
474
+ // incoming means max(0, N - M) instances are missing. Set membership would
475
+ // treat N>M as fully covered and let archive silently drop duplicates
476
+ // (residual #1246 / duplicate-scenario-name blind spot).
477
+ const remainingIncoming = new Map();
478
+ for (const scenario of parseScenarioBlocks(incoming.raw)) {
479
+ const name = scenario.name;
480
+ remainingIncoming.set(name, (remainingIncoming.get(name) ?? 0) + 1);
481
+ }
482
+ const missing = [];
483
+ for (const scenario of parseScenarioBlocks(current.raw)) {
484
+ const name = scenario.name;
485
+ const remaining = remainingIncoming.get(name) ?? 0;
486
+ if (remaining > 0) {
487
+ remainingIncoming.set(name, remaining - 1);
488
+ }
489
+ else {
490
+ missing.push(name);
491
+ }
492
+ }
493
+ return missing;
303
494
  }
304
495
  function parseScenarioBlocks(requirementRaw) {
305
496
  const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n');
497
+ // A `#### Scenario:` inside a fenced example is not a real scenario. The
498
+ // validator's countScenarios already ignores fenced lines; the drift check
499
+ // must agree with it, or a fenced sample can false-abort an archive (or
500
+ // mask a genuinely dropped scenario).
501
+ const mask = buildCodeFenceMask(lines);
306
502
  const scenarios = [];
307
503
  let index = 0;
308
504
  while (index < lines.length) {
309
- const headerMatch = lines[index].match(/^####\s*Scenario:\s*(.+)\s*$/);
505
+ const headerMatch = mask[index] ? null : lines[index].match(/^####\s*Scenario:\s*(.+)\s*$/);
310
506
  if (!headerMatch) {
311
507
  index++;
312
508
  continue;
@@ -314,7 +510,7 @@ function parseScenarioBlocks(requirementRaw) {
314
510
  const start = index;
315
511
  const name = headerMatch[1].trim();
316
512
  index++;
317
- while (index < lines.length && !/^####\s*Scenario:\s*(.+)\s*$/.test(lines[index])) {
513
+ while (index < lines.length && (mask[index] || !/^####\s*Scenario:\s*(.+)\s*$/.test(lines[index]))) {
318
514
  index++;
319
515
  }
320
516
  scenarios.push({
@@ -324,105 +520,4 @@ function parseScenarioBlocks(requirementRaw) {
324
520
  }
325
521
  return scenarios;
326
522
  }
327
- /**
328
- * Apply all delta specs from a change to main specs.
329
- *
330
- * @param projectRoot - The project root directory
331
- * @param changeName - The name of the change to apply
332
- * @param options - Options for the operation
333
- * @returns Result of the operation with counts
334
- */
335
- export async function applySpecs(projectRoot, changeName, options = {}) {
336
- const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
337
- const mainSpecsDir = path.join(projectRoot, 'openspec', 'specs');
338
- // Verify change exists
339
- try {
340
- const stat = await fs.stat(changeDir);
341
- if (!stat.isDirectory()) {
342
- throw new Error(`Change '${changeName}' not found.`);
343
- }
344
- }
345
- catch {
346
- throw new Error(`Change '${changeName}' not found.`);
347
- }
348
- // Find specs to update
349
- const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir);
350
- if (specUpdates.length === 0) {
351
- return {
352
- changeName,
353
- capabilities: [],
354
- totals: { added: 0, modified: 0, removed: 0, renamed: 0 },
355
- noChanges: true,
356
- };
357
- }
358
- // Prepare all updates first (validation pass, no writes)
359
- const prepared = [];
360
- for (const update of specUpdates) {
361
- const built = await buildUpdatedSpec(update, changeName);
362
- prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts });
363
- }
364
- // Validate rebuilt specs unless validation is skipped
365
- if (!options.skipValidation) {
366
- const validator = new Validator();
367
- for (const p of prepared) {
368
- const specName = path.basename(path.dirname(p.update.target));
369
- const report = await validator.validateSpecContent(specName, p.rebuilt);
370
- if (!report.valid) {
371
- const errors = report.issues
372
- .filter((i) => i.level === 'ERROR')
373
- .map((i) => ` ✗ ${i.message}`)
374
- .join('\n');
375
- throw new Error(`Validation errors in rebuilt spec for ${specName}:\n${errors}`);
376
- }
377
- }
378
- }
379
- // Build results
380
- const capabilities = [];
381
- const totals = { added: 0, modified: 0, removed: 0, renamed: 0 };
382
- for (const p of prepared) {
383
- const capability = path.basename(path.dirname(p.update.target));
384
- if (!options.dryRun) {
385
- // Write the updated spec
386
- const targetDir = path.dirname(p.update.target);
387
- await fs.mkdir(targetDir, { recursive: true });
388
- await fs.writeFile(p.update.target, p.rebuilt);
389
- if (!options.silent) {
390
- console.log(`Applying changes to openspec/specs/${capability}/spec.md:`);
391
- if (p.counts.added)
392
- console.log(` + ${p.counts.added} added`);
393
- if (p.counts.modified)
394
- console.log(` ~ ${p.counts.modified} modified`);
395
- if (p.counts.removed)
396
- console.log(` - ${p.counts.removed} removed`);
397
- if (p.counts.renamed)
398
- console.log(` → ${p.counts.renamed} renamed`);
399
- }
400
- }
401
- else if (!options.silent) {
402
- console.log(`Would apply changes to openspec/specs/${capability}/spec.md:`);
403
- if (p.counts.added)
404
- console.log(` + ${p.counts.added} added`);
405
- if (p.counts.modified)
406
- console.log(` ~ ${p.counts.modified} modified`);
407
- if (p.counts.removed)
408
- console.log(` - ${p.counts.removed} removed`);
409
- if (p.counts.renamed)
410
- console.log(` → ${p.counts.renamed} renamed`);
411
- }
412
- capabilities.push({
413
- capability,
414
- ...p.counts,
415
- });
416
- totals.added += p.counts.added;
417
- totals.modified += p.counts.modified;
418
- totals.removed += p.counts.removed;
419
- totals.renamed += p.counts.renamed;
420
- }
421
- return {
422
- changeName,
423
- capabilities,
424
- totals,
425
- noChanges: false,
426
- };
427
- }
428
523
  //# sourceMappingURL=specs-apply.js.map
@@ -19,5 +19,18 @@ export declare function gitHasRemote(storeRoot: string): Promise<boolean | null>
19
19
  * network touch. Null when there is no repository or no origin.
20
20
  */
21
21
  export declare function gitOriginUrl(storeRoot: string): Promise<string | null>;
22
+ export interface GitTrackingDrift {
23
+ ahead: number;
24
+ behind: number;
25
+ }
26
+ /**
27
+ * Ahead/behind counts of HEAD against its configured upstream tracking
28
+ * ref, read from local refs only — no fetch, no network. The comparison
29
+ * is therefore against the current local upstream ref (typically last
30
+ * updated by fetch, but it may be a local branch), not the live remote.
31
+ * Null when there is no repository, no upstream, a detached HEAD, or Git
32
+ * is unavailable: the absence of a comparison is not drift.
33
+ */
34
+ export declare function gitTrackingDrift(storeRoot: string): Promise<GitTrackingDrift | null>;
22
35
  export declare function gitDirectoryHasTrackedFiles(storeRoot: string, relativeDir: string): Promise<boolean | null>;
23
36
  //# sourceMappingURL=git.d.ts.map
@@ -130,6 +130,30 @@ export async function gitOriginUrl(storeRoot) {
130
130
  const url = stdout?.trim();
131
131
  return url ? url : null;
132
132
  }
133
+ /**
134
+ * Ahead/behind counts of HEAD against its configured upstream tracking
135
+ * ref, read from local refs only — no fetch, no network. The comparison
136
+ * is therefore against the current local upstream ref (typically last
137
+ * updated by fetch, but it may be a local branch), not the live remote.
138
+ * Null when there is no repository, no upstream, a detached HEAD, or Git
139
+ * is unavailable: the absence of a comparison is not drift.
140
+ */
141
+ export async function gitTrackingDrift(storeRoot) {
142
+ const stdout = await gitProbe(storeRoot, [
143
+ 'rev-list',
144
+ '--left-right',
145
+ '--count',
146
+ '@{upstream}...HEAD',
147
+ ]);
148
+ if (stdout === null)
149
+ return null;
150
+ const match = stdout.trim().match(/^(\d+)\s+(\d+)$/);
151
+ if (!match)
152
+ return null;
153
+ // `--left-right` orders the counts by side of the `...`: left is @{upstream}
154
+ // (commits we lack = behind), right is HEAD (commits upstream lacks = ahead).
155
+ return { behind: Number(match[1]), ahead: Number(match[2]) };
156
+ }
133
157
  export async function gitDirectoryHasTrackedFiles(storeRoot, relativeDir) {
134
158
  const stdout = await gitProbe(storeRoot, ['ls-files', '--', relativeDir]);
135
159
  return stdout === null ? null : stdout.trim().length > 0;