@codewalla_india/openspec 1.3.1 → 1.3.2

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 (333) hide show
  1. package/LICENSE +22 -22
  2. package/README.md +272 -191
  3. package/bin/openspec.js +5 -5
  4. package/dist/cli/index.js +99 -62
  5. package/dist/commands/change.d.ts +0 -2
  6. package/dist/commands/change.js +107 -86
  7. package/dist/commands/config.js +14 -16
  8. package/dist/commands/doctor.js +9 -4
  9. package/dist/commands/feedback.js +79 -30
  10. package/dist/commands/modify.d.ts +26 -0
  11. package/dist/commands/modify.js +147 -0
  12. package/dist/commands/schema.js +170 -91
  13. package/dist/commands/show.js +5 -2
  14. package/dist/commands/spec.js +49 -29
  15. package/dist/commands/store.js +15 -21
  16. package/dist/commands/validate.d.ts +8 -0
  17. package/dist/commands/validate.js +43 -20
  18. package/dist/commands/workflow/index.d.ts +2 -2
  19. package/dist/commands/workflow/index.js +1 -1
  20. package/dist/commands/workflow/instructions.d.ts +6 -26
  21. package/dist/commands/workflow/instructions.js +183 -376
  22. package/dist/commands/workflow/new-change.d.ts +0 -4
  23. package/dist/commands/workflow/new-change.js +10 -28
  24. package/dist/commands/workflow/shared.d.ts +34 -19
  25. package/dist/commands/workflow/shared.js +34 -4
  26. package/dist/commands/workflow/status.js +10 -32
  27. package/dist/commands/workflow/templates.js +15 -5
  28. package/dist/commands/workset.d.ts +2 -2
  29. package/dist/commands/workset.js +19 -22
  30. package/dist/comprehension-quiz/fingerprint.d.ts +39 -0
  31. package/dist/comprehension-quiz/fingerprint.js +84 -0
  32. package/dist/comprehension-quiz/index.d.ts +42 -0
  33. package/dist/comprehension-quiz/index.js +47 -0
  34. package/dist/comprehension-quiz/pass-record.d.ts +44 -0
  35. package/dist/comprehension-quiz/pass-record.js +98 -0
  36. package/dist/comprehension-quiz/providers/anthropic.d.ts +34 -0
  37. package/dist/comprehension-quiz/providers/anthropic.js +53 -0
  38. package/dist/comprehension-quiz/providers/factory.d.ts +44 -0
  39. package/dist/comprehension-quiz/providers/factory.js +174 -0
  40. package/dist/comprehension-quiz/providers/interface.d.ts +59 -0
  41. package/dist/comprehension-quiz/providers/interface.js +63 -0
  42. package/dist/comprehension-quiz/providers/local.d.ts +34 -0
  43. package/dist/comprehension-quiz/providers/local.js +53 -0
  44. package/dist/comprehension-quiz/providers/ollama.d.ts +34 -0
  45. package/dist/comprehension-quiz/providers/ollama.js +53 -0
  46. package/dist/comprehension-quiz/providers/openai.d.ts +34 -0
  47. package/dist/comprehension-quiz/providers/openai.js +53 -0
  48. package/dist/comprehension-quiz/question-generator.d.ts +69 -0
  49. package/dist/comprehension-quiz/question-generator.js +141 -0
  50. package/dist/comprehension-quiz/quiz-executor.d.ts +70 -0
  51. package/dist/comprehension-quiz/quiz-executor.js +165 -0
  52. package/dist/comprehension-quiz/types.d.ts +115 -0
  53. package/dist/comprehension-quiz/types.js +11 -0
  54. package/dist/core/archive.d.ts +14 -1
  55. package/dist/core/archive.js +1350 -190
  56. package/dist/core/artifact-graph/graph.d.ts +28 -9
  57. package/dist/core/artifact-graph/graph.js +94 -43
  58. package/dist/core/artifact-graph/index.d.ts +1 -1
  59. package/dist/core/artifact-graph/index.js +1 -1
  60. package/dist/core/artifact-graph/instruction-loader.d.ts +30 -3
  61. package/dist/core/artifact-graph/instruction-loader.js +70 -12
  62. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  63. package/dist/core/artifact-graph/outputs.js +67 -5
  64. package/dist/core/artifact-graph/resolver.d.ts +14 -0
  65. package/dist/core/artifact-graph/resolver.js +69 -15
  66. package/dist/core/artifact-graph/types.js +21 -3
  67. package/dist/core/available-tools.d.ts +2 -2
  68. package/dist/core/available-tools.js +14 -3
  69. package/dist/core/change-metadata/schema.d.ts +2 -0
  70. package/dist/core/change-metadata/schema.js +13 -0
  71. package/dist/core/change-status-policy.d.ts +10 -1
  72. package/dist/core/change-status-policy.js +30 -2
  73. package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
  74. package/dist/core/command-generation/adapters/amazon-q.js +11 -5
  75. package/dist/core/command-generation/adapters/antigravity.js +6 -5
  76. package/dist/core/command-generation/adapters/auggie.js +7 -6
  77. package/dist/core/command-generation/adapters/bob.d.ts +5 -1
  78. package/dist/core/command-generation/adapters/bob.js +11 -10
  79. package/dist/core/command-generation/adapters/claude.d.ts +1 -1
  80. package/dist/core/command-generation/adapters/claude.js +12 -17
  81. package/dist/core/command-generation/adapters/cline.js +5 -5
  82. package/dist/core/command-generation/adapters/codebuddy.js +8 -7
  83. package/dist/core/command-generation/adapters/continue.js +8 -7
  84. package/dist/core/command-generation/adapters/costrict.js +7 -6
  85. package/dist/core/command-generation/adapters/crush.js +9 -9
  86. package/dist/core/command-generation/adapters/cursor.js +8 -8
  87. package/dist/core/command-generation/adapters/devin.d.ts +19 -0
  88. package/dist/core/command-generation/adapters/devin.js +36 -0
  89. package/dist/core/command-generation/adapters/factory.js +7 -6
  90. package/dist/core/command-generation/adapters/gemini.js +40 -5
  91. package/dist/core/command-generation/adapters/github-copilot.js +6 -5
  92. package/dist/core/command-generation/adapters/iflow.js +9 -8
  93. package/dist/core/command-generation/adapters/index.d.ts +4 -2
  94. package/dist/core/command-generation/adapters/index.js +4 -2
  95. package/dist/core/command-generation/adapters/junie.js +6 -5
  96. package/dist/core/command-generation/adapters/kilocode.js +1 -1
  97. package/dist/core/command-generation/adapters/kiro.js +6 -5
  98. package/dist/core/command-generation/adapters/lingma.js +9 -9
  99. package/dist/core/command-generation/adapters/oh-my-pi.d.ts +21 -0
  100. package/dist/core/command-generation/adapters/oh-my-pi.js +42 -0
  101. package/dist/core/command-generation/adapters/opencode.js +6 -8
  102. package/dist/core/command-generation/adapters/pi.d.ts +2 -2
  103. package/dist/core/command-generation/adapters/pi.js +7 -10
  104. package/dist/core/command-generation/adapters/qoder.js +9 -9
  105. package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
  106. package/dist/core/command-generation/adapters/qwen.js +14 -9
  107. package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
  108. package/dist/core/command-generation/adapters/roocode.js +9 -9
  109. package/dist/core/command-generation/adapters/trae.d.ts +13 -0
  110. package/dist/core/command-generation/adapters/trae.js +28 -0
  111. package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
  112. package/dist/core/command-generation/adapters/zcode.js +33 -0
  113. package/dist/core/command-generation/generator.d.ts +8 -0
  114. package/dist/core/command-generation/generator.js +15 -1
  115. package/dist/core/command-generation/index.d.ts +1 -1
  116. package/dist/core/command-generation/index.js +1 -1
  117. package/dist/core/command-generation/invocation.d.ts +73 -0
  118. package/dist/core/command-generation/invocation.js +77 -0
  119. package/dist/core/command-generation/registry.js +8 -4
  120. package/dist/core/command-generation/types.d.ts +9 -2
  121. package/dist/core/command-generation/yaml.d.ts +20 -6
  122. package/dist/core/command-generation/yaml.js +31 -21
  123. package/dist/core/command-surface.d.ts +15 -0
  124. package/dist/core/command-surface.js +33 -0
  125. package/dist/core/completions/command-registry.js +16 -61
  126. package/dist/core/completions/generators/bash-generator.js +41 -41
  127. package/dist/core/completions/generators/fish-generator.js +7 -7
  128. package/dist/core/completions/generators/powershell-generator.js +44 -37
  129. package/dist/core/completions/generators/zsh-generator.js +33 -33
  130. package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
  131. package/dist/core/completions/installers/zsh-installer.js +20 -4
  132. package/dist/core/completions/templates/bash-templates.js +24 -24
  133. package/dist/core/completions/templates/fish-templates.js +38 -38
  134. package/dist/core/completions/templates/powershell-templates.js +28 -28
  135. package/dist/core/completions/templates/zsh-templates.js +39 -39
  136. package/dist/core/config-prompts.d.ts +1 -1
  137. package/dist/core/config-prompts.js +14 -1
  138. package/dist/core/config-schema.d.ts +10 -1
  139. package/dist/core/config-schema.js +67 -1
  140. package/dist/core/config.d.ts +15 -0
  141. package/dist/core/config.js +46 -5
  142. package/dist/core/file-state.js +56 -24
  143. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  144. package/dist/core/github-copilot/cloud-agent.js +497 -0
  145. package/dist/core/global-config.d.ts +16 -0
  146. package/dist/core/init.d.ts +58 -0
  147. package/dist/core/init.js +474 -73
  148. package/dist/core/legacy-cleanup.d.ts +59 -0
  149. package/dist/core/legacy-cleanup.js +253 -24
  150. package/dist/core/list.js +23 -16
  151. package/dist/core/migration.d.ts +73 -1
  152. package/dist/core/migration.js +360 -11
  153. package/dist/core/modify/conflict-detector.d.ts +26 -0
  154. package/dist/core/modify/conflict-detector.js +62 -0
  155. package/dist/core/modify/dependency-propagator.d.ts +38 -0
  156. package/dist/core/modify/dependency-propagator.js +103 -0
  157. package/dist/core/modify/history-tracker.d.ts +34 -0
  158. package/dist/core/modify/history-tracker.js +114 -0
  159. package/dist/core/modify/index.d.ts +7 -0
  160. package/dist/core/modify/index.js +7 -0
  161. package/dist/core/onboarding-commands.d.ts +30 -0
  162. package/dist/core/onboarding-commands.js +38 -0
  163. package/dist/core/openspec-root.js +18 -15
  164. package/dist/core/parsers/change-parser.d.ts +17 -2
  165. package/dist/core/parsers/change-parser.js +35 -21
  166. package/dist/core/parsers/code-fence.d.ts +15 -0
  167. package/dist/core/parsers/code-fence.js +49 -0
  168. package/dist/core/parsers/markdown-parser.d.ts +0 -3
  169. package/dist/core/parsers/markdown-parser.js +7 -60
  170. package/dist/core/parsers/requirement-blocks.d.ts +29 -0
  171. package/dist/core/parsers/requirement-blocks.js +150 -31
  172. package/dist/core/parsers/requirement-text.d.ts +48 -0
  173. package/dist/core/parsers/requirement-text.js +98 -0
  174. package/dist/core/parsers/spec-structure.d.ts +1 -1
  175. package/dist/core/parsers/spec-structure.js +20 -30
  176. package/dist/core/profile-sync-drift.d.ts +0 -8
  177. package/dist/core/profile-sync-drift.js +49 -50
  178. package/dist/core/profiles.d.ts +2 -2
  179. package/dist/core/profiles.js +2 -2
  180. package/dist/core/project-config.d.ts +27 -18
  181. package/dist/core/project-config.js +101 -34
  182. package/dist/core/references.js +42 -4
  183. package/dist/core/relationship-health.d.ts +8 -0
  184. package/dist/core/relationship-health.js +13 -0
  185. package/dist/core/root-selection.d.ts +9 -6
  186. package/dist/core/root-selection.js +42 -5
  187. package/dist/core/schemas/base.schema.js +7 -2
  188. package/dist/core/shared/allowed-tools.d.ts +12 -0
  189. package/dist/core/shared/allowed-tools.js +12 -0
  190. package/dist/core/shared/index.d.ts +1 -0
  191. package/dist/core/shared/index.js +1 -0
  192. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  193. package/dist/core/shared/skill-content-equivalence.js +41 -0
  194. package/dist/core/shared/skill-generation.js +17 -13
  195. package/dist/core/shared/skill-paths.d.ts +13 -0
  196. package/dist/core/shared/skill-paths.js +23 -0
  197. package/dist/core/shared/tool-detection.d.ts +27 -7
  198. package/dist/core/shared/tool-detection.js +167 -37
  199. package/dist/core/shared-skill-target.d.ts +16 -0
  200. package/dist/core/shared-skill-target.js +156 -0
  201. package/dist/core/specs-apply.d.ts +80 -33
  202. package/dist/core/specs-apply.js +667 -145
  203. package/dist/core/store/git.d.ts +13 -0
  204. package/dist/core/store/git.js +24 -0
  205. package/dist/core/store/operations.js +20 -0
  206. package/dist/core/templates/skill-templates.d.ts +3 -2
  207. package/dist/core/templates/skill-templates.js +3 -2
  208. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  209. package/dist/core/templates/workflows/apply-change.js +186 -224
  210. package/dist/core/templates/workflows/archive-change.js +387 -260
  211. package/dist/core/templates/workflows/bulk-archive-change.js +655 -475
  212. package/dist/core/templates/workflows/continue-change.js +210 -219
  213. package/dist/core/templates/workflows/explore.js +479 -443
  214. package/dist/core/templates/workflows/feedback.js +97 -97
  215. package/dist/core/templates/workflows/ff-change.js +200 -191
  216. package/dist/core/templates/workflows/modify-change.d.ts +4 -1
  217. package/dist/core/templates/workflows/modify-change.js +237 -115
  218. package/dist/core/templates/workflows/new-change.js +127 -134
  219. package/dist/core/templates/workflows/onboard.js +548 -542
  220. package/dist/core/templates/workflows/propose.js +273 -217
  221. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  222. package/dist/core/templates/workflows/store-selection.js +1 -1
  223. package/dist/core/templates/workflows/sync-specs.js +500 -273
  224. package/dist/core/templates/workflows/update-change.d.ts +10 -0
  225. package/dist/core/templates/workflows/update-change.js +177 -0
  226. package/dist/core/templates/workflows/verify-change.js +324 -326
  227. package/dist/core/update.d.ts +40 -5
  228. package/dist/core/update.js +411 -96
  229. package/dist/core/validation/constants.d.ts +4 -1
  230. package/dist/core/validation/constants.js +4 -1
  231. package/dist/core/validation/plan-validator.d.ts +8 -0
  232. package/dist/core/validation/plan-validator.js +45 -0
  233. package/dist/core/validation/task-numbering.d.ts +16 -0
  234. package/dist/core/validation/task-numbering.js +57 -0
  235. package/dist/core/validation/validator.d.ts +27 -3
  236. package/dist/core/validation/validator.js +337 -48
  237. package/dist/core/version-check.d.ts +152 -0
  238. package/dist/core/version-check.js +731 -0
  239. package/dist/core/view.js +13 -18
  240. package/dist/prompts/searchable-multi-select.js +1 -1
  241. package/dist/telemetry/caller-detection.d.ts +27 -0
  242. package/dist/telemetry/caller-detection.js +85 -0
  243. package/dist/telemetry/caller-detection.test.d.ts +5 -0
  244. package/dist/telemetry/config.d.ts +48 -2
  245. package/dist/telemetry/config.js +113 -11
  246. package/dist/telemetry/config.test.d.ts +5 -0
  247. package/dist/telemetry/deduplication.d.ts +35 -0
  248. package/dist/telemetry/deduplication.js +82 -0
  249. package/dist/telemetry/deduplication.test.d.ts +5 -0
  250. package/dist/telemetry/identity.d.ts +62 -22
  251. package/dist/telemetry/identity.js +147 -102
  252. package/dist/telemetry/index.d.ts +126 -15
  253. package/dist/telemetry/index.js +569 -37
  254. package/dist/telemetry/sanitization.d.ts +40 -0
  255. package/dist/telemetry/sanitization.js +114 -0
  256. package/dist/telemetry/sanitization.test.d.ts +5 -0
  257. package/dist/telemetry/throttling.d.ts +20 -0
  258. package/dist/telemetry/throttling.js +70 -0
  259. package/dist/ui/welcome-screen.d.ts +9 -1
  260. package/dist/ui/welcome-screen.js +91 -38
  261. package/dist/utils/change-metadata.d.ts +43 -0
  262. package/dist/utils/change-metadata.js +123 -7
  263. package/dist/utils/change-utils.d.ts +10 -4
  264. package/dist/utils/change-utils.js +20 -13
  265. package/dist/utils/ci.d.ts +12 -0
  266. package/dist/utils/ci.js +16 -0
  267. package/dist/utils/command-references.d.ts +92 -6
  268. package/dist/utils/command-references.js +180 -7
  269. package/dist/utils/date.d.ts +8 -0
  270. package/dist/utils/date.js +13 -0
  271. package/dist/utils/file-system.d.ts +10 -0
  272. package/dist/utils/file-system.js +70 -0
  273. package/dist/utils/index.d.ts +1 -1
  274. package/dist/utils/index.js +1 -1
  275. package/dist/utils/interactive.d.ts +23 -0
  276. package/dist/utils/interactive.js +32 -0
  277. package/dist/utils/item-discovery.d.ts +19 -0
  278. package/dist/utils/item-discovery.js +30 -48
  279. package/dist/utils/shell-detection.d.ts +1 -1
  280. package/dist/utils/shell-detection.js +64 -11
  281. package/dist/utils/spec-discovery.d.ts +40 -0
  282. package/dist/utils/spec-discovery.js +115 -0
  283. package/dist/utils/task-progress.d.ts +29 -1
  284. package/dist/utils/task-progress.js +109 -15
  285. package/package.json +96 -84
  286. package/schemas/spec-driven/schema.yaml +252 -184
  287. package/schemas/spec-driven/templates/design.md +19 -19
  288. package/schemas/spec-driven/templates/plan-template.md +91 -0
  289. package/schemas/spec-driven/templates/proposal.md +29 -24
  290. package/schemas/spec-driven/templates/spec.md +11 -8
  291. package/schemas/spec-driven/templates/tasks.md +9 -9
  292. package/scripts/postinstall.js +83 -83
  293. package/dist/core/command-generation/adapters/codex.d.ts +0 -16
  294. package/dist/core/command-generation/adapters/codex.js +0 -39
  295. package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
  296. package/dist/core/command-generation/adapters/windsurf.js +0 -38
  297. package/dist/core/comprehension/config.d.ts +0 -20
  298. package/dist/core/comprehension/config.js +0 -23
  299. package/dist/core/comprehension/fingerprint.d.ts +0 -14
  300. package/dist/core/comprehension/fingerprint.js +0 -33
  301. package/dist/core/comprehension/index.d.ts +0 -57
  302. package/dist/core/comprehension/index.js +0 -87
  303. package/dist/core/comprehension/pass-record.d.ts +0 -29
  304. package/dist/core/comprehension/pass-record.js +0 -64
  305. package/dist/core/comprehension/stats.d.ts +0 -35
  306. package/dist/core/comprehension/stats.js +0 -133
  307. package/dist/core/templates/workflows/comprehension-guidance.d.ts +0 -9
  308. package/dist/core/templates/workflows/comprehension-guidance.js +0 -73
  309. package/dist/core/templates/workflows/mcp-guidance.d.ts +0 -13
  310. package/dist/core/templates/workflows/mcp-guidance.js +0 -131
  311. package/dist/core/templates/workflows/user-prompt-guidance.d.ts +0 -16
  312. package/dist/core/templates/workflows/user-prompt-guidance.js +0 -48
  313. package/dist/telemetry/caller.d.ts +0 -5
  314. package/dist/telemetry/caller.js +0 -29
  315. package/dist/telemetry/client.d.ts +0 -27
  316. package/dist/telemetry/client.js +0 -127
  317. package/dist/telemetry/command-context.d.ts +0 -13
  318. package/dist/telemetry/command-context.js +0 -59
  319. package/dist/telemetry/comprehension.d.ts +0 -44
  320. package/dist/telemetry/comprehension.js +0 -105
  321. package/dist/telemetry/content.d.ts +0 -10
  322. package/dist/telemetry/content.js +0 -56
  323. package/dist/telemetry/git-stats.d.ts +0 -12
  324. package/dist/telemetry/git-stats.js +0 -69
  325. package/dist/telemetry/identify-cache.d.ts +0 -7
  326. package/dist/telemetry/identify-cache.js +0 -47
  327. package/dist/telemetry/input.d.ts +0 -17
  328. package/dist/telemetry/input.js +0 -68
  329. package/dist/telemetry/marker.d.ts +0 -37
  330. package/dist/telemetry/marker.js +0 -67
  331. package/dist/telemetry/workflow.d.ts +0 -85
  332. package/dist/telemetry/workflow.js +0 -318
  333. package/schemas/spec-driven/templates/plan.md +0 -19
@@ -5,11 +5,54 @@
5
5
  * Applies delta specs from a change to main specs without archiving.
6
6
  */
7
7
  import { promises as fs } from 'fs';
8
+ import { randomUUID } from 'crypto';
8
9
  import path from 'path';
9
10
  import chalk from 'chalk';
10
- import { extractRequirementsSection, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
11
+ import { extractRequirementsSection, findMissingCurrentScenarios, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
11
12
  import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
12
- import { Validator } from './validation/validator.js';
13
+ import { buildCodeFenceMask } from './parsers/code-fence.js';
14
+ import { MarkdownParser } from './parsers/markdown-parser.js';
15
+ import { MIN_PURPOSE_LENGTH } from './validation/constants.js';
16
+ import { discoverSpecFiles } from '../utils/spec-discovery.js';
17
+ import { FileSystemUtils } from '../utils/file-system.js';
18
+ function isLexicallyWithin(allowedDirectory, targetPath) {
19
+ const relative = path.relative(path.resolve(allowedDirectory), path.resolve(targetPath));
20
+ return (relative === '' ||
21
+ (relative !== '..' &&
22
+ !relative.startsWith(`..${path.sep}`) &&
23
+ !path.isAbsolute(relative)));
24
+ }
25
+ function resolveTrustedSpecPath(specsRoot, specPath) {
26
+ if (!isLexicallyWithin(specsRoot, specPath)) {
27
+ throw new Error(`Path is outside the allowed directory: ${specPath}`);
28
+ }
29
+ try {
30
+ // Preserve spec.md links that remain inside the overall specs tree.
31
+ FileSystemUtils.assertPathWithin(specsRoot, specPath);
32
+ const root = FileSystemUtils.canonicalizeExistingPath(specsRoot);
33
+ return {
34
+ root,
35
+ // Rebase onto the canonical root so missing targets also work when the
36
+ // project is reached through an OS path alias (for example /var on macOS).
37
+ file: path.join(root, path.relative(path.resolve(specsRoot), path.resolve(specPath))),
38
+ };
39
+ }
40
+ catch {
41
+ // Direct capability directories may intentionally be monorepo symlinks.
42
+ // Freeze their canonical location as the trust root so later swaps are
43
+ // rejected while a nested spec.md link still cannot escape.
44
+ const root = FileSystemUtils.canonicalizeExistingPath(path.dirname(specPath));
45
+ const file = path.join(root, path.basename(specPath));
46
+ FileSystemUtils.assertPathWithin(root, file);
47
+ return { root, file };
48
+ }
49
+ }
50
+ function assertTrustedSpecPath(root, specPath) {
51
+ if (FileSystemUtils.canonicalizeExistingPath(root) !== path.resolve(root)) {
52
+ throw new Error(`Path is outside the allowed directory: ${specPath}`);
53
+ }
54
+ FileSystemUtils.assertPathWithin(root, specPath);
55
+ }
13
56
  // -----------------------------------------------------------------------------
14
57
  // Public API
15
58
  // -----------------------------------------------------------------------------
@@ -19,37 +62,31 @@ import { Validator } from './validation/validator.js';
19
62
  export async function findSpecUpdates(changeDir, mainSpecsDir) {
20
63
  const updates = [];
21
64
  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
- }
65
+ // Discover delta specs recursively so nested layouts like
66
+ // specs/<area>/<capability>/spec.md merge into the same relative path
67
+ // under the main specs directory (#1353)
68
+ const discovered = await discoverSpecFiles(changeSpecsDir);
69
+ for (const { id, specFile } of discovered) {
70
+ const targetFile = path.join(mainSpecsDir, ...id.split('/'), 'spec.md');
71
+ const source = resolveTrustedSpecPath(changeSpecsDir, specFile);
72
+ const target = resolveTrustedSpecPath(mainSpecsDir, targetFile);
73
+ // Check if target exists
74
+ let exists = false;
75
+ try {
76
+ await fs.access(target.file);
77
+ exists = true;
49
78
  }
50
- }
51
- catch {
52
- // No specs directory in change
79
+ catch {
80
+ exists = false;
81
+ }
82
+ updates.push({
83
+ id,
84
+ sourceRoot: source.root,
85
+ source: source.file,
86
+ targetRoot: target.root,
87
+ target: target.file,
88
+ exists,
89
+ });
53
90
  }
54
91
  return updates;
55
92
  }
@@ -58,11 +95,21 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
58
95
  * Returns the rebuilt content and counts of operations.
59
96
  */
60
97
  export async function buildUpdatedSpec(update, changeName, options = {}) {
98
+ // Collected so silent (JSON) callers can surface them; printed live for
99
+ // human callers at the point they occur.
100
+ const warnings = [];
101
+ const warn = (message) => {
102
+ warnings.push(message);
103
+ if (!options.silent) {
104
+ console.log(chalk.yellow(`⚠️ Warning: ${message}`));
105
+ }
106
+ };
61
107
  // Read change spec content (delta-format expected)
108
+ assertTrustedSpecPath(update.sourceRoot, update.source);
62
109
  const changeContent = await fs.readFile(update.source, 'utf-8');
63
110
  // Parse deltas from the change spec file
64
111
  const plan = parseDeltaSpec(changeContent);
65
- const specName = path.basename(path.dirname(update.target));
112
+ const specName = update.id;
66
113
  // Pre-validate duplicates within sections
67
114
  const addedNames = new Set();
68
115
  for (const add of plan.added) {
@@ -118,6 +165,17 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
118
165
  for (const { from, to } of plan.renamed) {
119
166
  const fromNorm = normalizeRequirementName(from);
120
167
  const toNorm = normalizeRequirementName(to);
168
+ // A REMOVED naming the FROM side contradicts the rename. This used to
169
+ // fail incidentally at apply time (the rename consumed the old header,
170
+ // so REMOVED hit "not found"); now that a missing REMOVED target is a
171
+ // no-op, the conflict must be rejected explicitly. Compared folded, so
172
+ // a case/whitespace variant cannot slip past the guard and degrade
173
+ // into a warned no-op.
174
+ const removedFoldMatch = [...removedNamesSet].find((r) => foldRequirementName(r) === foldRequirementName(fromNorm));
175
+ if (removedFoldMatch !== undefined) {
176
+ throw new Error(`${specName} validation failed - requirement present in multiple sections (RENAMED and REMOVED) for header "### Requirement: ${from}"` +
177
+ (removedFoldMatch === fromNorm ? '' : ` (REMOVED spells it "${removedFoldMatch}")`));
178
+ }
121
179
  if (modifiedNames.has(fromNorm)) {
122
180
  throw new Error(`${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"`);
123
181
  }
@@ -132,14 +190,29 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
132
190
  }
133
191
  const hasAnyDelta = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;
134
192
  if (!hasAnyDelta) {
135
- throw new Error(`Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` +
193
+ throw new Error(`Delta parsing found no operations for ${update.id}. ` +
136
194
  `Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.`);
137
195
  }
138
196
  // Load or create base target content
197
+ const deltaPurpose = extractPurposeSection(changeContent);
139
198
  let targetContent;
140
199
  let isNewSpec = false;
200
+ assertTrustedSpecPath(update.targetRoot, update.target);
141
201
  try {
142
202
  targetContent = await fs.readFile(update.target, 'utf-8');
203
+ // A delta Purpose only seeds a spec that does not exist yet. Say so rather
204
+ // than dropping it silently - the specs instruction tells authors to write
205
+ // one for new capabilities, and the delta file looks identical either way.
206
+ // Only when the spec really does have a different Purpose: claiming it
207
+ // "already has one" would be false when it has none, and saying anything at
208
+ // all is noise when the two bodies match.
209
+ if (deltaPurpose) {
210
+ const existingPurpose = extractPurposeSection(targetContent);
211
+ if (existingPurpose && existingPurpose !== deltaPurpose) {
212
+ warn(`${specName} - delta Purpose ignored; ${specName} already has one. ` +
213
+ `Edit ${update.target} directly to change it.`);
214
+ }
215
+ }
143
216
  }
144
217
  catch {
145
218
  // Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
@@ -148,11 +221,25 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
148
221
  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
222
  }
150
223
  // 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).`));
224
+ if (plan.removed.length > 0) {
225
+ warn(`${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`);
153
226
  }
154
227
  isNewSpec = true;
155
- targetContent = buildSpecSkeleton(specName, changeName);
228
+ targetContent = buildSpecSkeleton(specName, changeName, deltaPurpose);
229
+ const overview = deltaPurpose ? readableOverview(targetContent, specName) : null;
230
+ if (deltaPurpose && !overview) {
231
+ // Keep the placeholder rather than turning this into a failure: these
232
+ // deltas archived cleanly before the Purpose carry-over existed.
233
+ targetContent = buildSpecSkeleton(specName, changeName);
234
+ warn(`${specName} - delta Purpose ignored (it would leave the new spec unreadable); wrote the placeholder Purpose instead.`);
235
+ }
236
+ else if (overview && overview.length < MIN_PURPOSE_LENGTH) {
237
+ // The placeholder always cleared this threshold, so a carried Purpose is
238
+ // the first way archive can leave a spec that `validate --strict` fails.
239
+ // Measured on the parsed overview, which is what the validator reads.
240
+ warn(`${specName} - carried Purpose is under ${MIN_PURPOSE_LENGTH} characters; ` +
241
+ `openspec validate --strict reports it as too brief.`);
242
+ }
156
243
  }
157
244
  const structureIssues = findMainSpecStructureIssues(targetContent);
158
245
  if (structureIssues.length > 0) {
@@ -169,10 +256,25 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
169
256
  }
170
257
  // Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
171
258
  // RENAMED
259
+ let renamedApplied = 0;
260
+ const renamedTargets = new Map();
172
261
  for (const r of plan.renamed) {
173
262
  const from = normalizeRequirementName(r.from);
174
263
  const to = normalizeRequirementName(r.to);
175
264
  if (!nameToBlock.has(from)) {
265
+ // Source gone but target present means the rename was already synced
266
+ // to the baseline (early-sync pattern) — re-applying it is a no-op,
267
+ // not a failure. Only a missing source AND target is a genuine error.
268
+ if (nameToBlock.has(to)) {
269
+ // Unless a case/whitespace variant of the source still exists (and is
270
+ // not the target itself, as in a case-only rename): that is a typo'd
271
+ // header, not an early-synced rename — same guard REMOVED applies.
272
+ const nearMiss = [...nameToBlock.keys()].find((k) => k !== to && foldRequirementName(k) === foldRequirementName(from));
273
+ if (nearMiss !== undefined) {
274
+ 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`);
275
+ }
276
+ continue;
277
+ }
176
278
  throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`);
177
279
  }
178
280
  if (nameToBlock.has(to)) {
@@ -189,25 +291,38 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
189
291
  };
190
292
  nameToBlock.delete(from);
191
293
  nameToBlock.set(to, renamedBlock);
294
+ renamedTargets.set(from, to);
295
+ renamedApplied++;
192
296
  }
193
297
  // REMOVED
298
+ let removedApplied = 0;
194
299
  for (const name of plan.removed) {
195
300
  const key = normalizeRequirementName(name);
196
301
  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
302
+ // Requirement gone from the baseline means the removal was already
303
+ // synced (early-sync pattern) re-applying it is a no-op, not a
304
+ // failure. One signal does separate that from a mistyped header: a
305
+ // requirement that differs only in case or interior whitespace still
306
+ // being present. That is a typo, and stays a hard abort.
307
+ // For new specs the skip was already warned about above.
199
308
  if (!isNewSpec) {
200
- throw new Error(`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`);
309
+ const nearMiss = [...nameToBlock.keys()].find((k) => foldRequirementName(k) === foldRequirementName(key));
310
+ if (nearMiss !== undefined) {
311
+ 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`);
312
+ }
313
+ warn(`${specName} - REMOVED requirement "${name}" is not in the current spec; treating it as already removed.`);
201
314
  }
202
- // Skip removal for new specs (already warned above)
203
315
  continue;
204
316
  }
205
317
  nameToBlock.delete(key);
318
+ removedApplied++;
206
319
  }
207
320
  // MODIFIED
321
+ let modifiedApplied = 0;
208
322
  for (const mod of plan.modified) {
209
323
  const key = normalizeRequirementName(mod.name);
210
- if (!nameToBlock.has(key)) {
324
+ const currentBlock = nameToBlock.get(key);
325
+ if (!currentBlock) {
211
326
  throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`);
212
327
  }
213
328
  // Replace block with provided raw (ensure header line matches key)
@@ -215,15 +330,35 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
215
330
  if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
216
331
  throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`);
217
332
  }
333
+ const missingScenarios = findMissingCurrentScenarios(currentBlock, mod);
334
+ if (missingScenarios.length > 0) {
335
+ 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.`);
336
+ }
337
+ // Identical content means the modification was already synced to the
338
+ // baseline (early-sync pattern) — count only real replacements, so a
339
+ // fully synced change still takes the "already in sync" write skip
340
+ // instead of churning normalization differences into the file.
341
+ if (normalizeBlockRaw(currentBlock.raw) !== normalizeBlockRaw(mod.raw)) {
342
+ modifiedApplied++;
343
+ }
218
344
  nameToBlock.set(key, mod);
219
345
  }
220
346
  // ADDED
347
+ let addedApplied = 0;
221
348
  for (const add of plan.added) {
222
349
  const key = normalizeRequirementName(add.name);
223
- if (nameToBlock.has(key)) {
350
+ const existing = nameToBlock.get(key);
351
+ if (existing) {
352
+ // Identical content means the requirement was already synced to the
353
+ // baseline (early-sync pattern) — re-applying it is a no-op, not a
354
+ // conflict. Only differing content is a genuine collision.
355
+ if (normalizeBlockRaw(existing.raw) === normalizeBlockRaw(add.raw)) {
356
+ continue;
357
+ }
224
358
  throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
225
359
  }
226
360
  nameToBlock.set(key, add);
361
+ addedApplied++;
227
362
  }
228
363
  // Duplicates within resulting map are implicitly prevented by key uniqueness.
229
364
  // Recompose requirements section preserving original ordering where possible
@@ -236,6 +371,27 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
236
371
  keptOrder.push(replacement);
237
372
  seen.add(key);
238
373
  }
374
+ // A block's raw runs to the next header the parser RECOGNISES, so a note
375
+ // under an unrecognized heading can be absorbed into the requirement.
376
+ // Warn only when the replacement from this same original block drops the
377
+ // full absorbed suffix. RENAMED carries the original raw content under a
378
+ // new map key, and MODIFIED may repeat the suffix deliberately; neither is
379
+ // data loss.
380
+ const renamedTarget = renamedTargets.get(key);
381
+ const replacementFromOriginal = replacement ?? (renamedTarget ? nameToBlock.get(renamedTarget) : undefined);
382
+ if (replacementFromOriginal !== block) {
383
+ const foreign = firstForeignTail(block.raw);
384
+ const replacementRaw = replacementFromOriginal?.raw;
385
+ const normalizedForeign = foreign ? normalizeBlockRaw(foreign.raw) : '';
386
+ const keepsForeignTail = foreign !== undefined &&
387
+ replacementRaw !== undefined &&
388
+ countOccurrences(normalizeBlockRaw(replacementRaw), normalizedForeign) >=
389
+ countOccurrences(normalizeBlockRaw(block.raw), normalizedForeign);
390
+ if (foreign && !keepsForeignTail) {
391
+ warn(`${specName} - "${foreign.heading}" sits inside requirement "${block.name}" and goes with it. ` +
392
+ 'Move it under its own requirement, or above `## Requirements`, to keep it.');
393
+ }
394
+ }
239
395
  }
240
396
  // Append any newly added that were not in original order
241
397
  for (const [key, block] of nameToBlock.entries()) {
@@ -255,24 +411,403 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
255
411
  return {
256
412
  rebuilt,
257
413
  counts: {
258
- added: plan.added.length,
259
- modified: plan.modified.length,
260
- removed: plan.removed.length,
261
- renamed: plan.renamed.length,
414
+ added: addedApplied,
415
+ modified: modifiedApplied,
416
+ removed: removedApplied,
417
+ renamed: renamedApplied,
262
418
  },
419
+ warnings,
420
+ noRequirementBlocks: keptOrder.length === 0,
421
+ // Read off the ORIGINAL requirements section, not the rebuilt one. Anything
422
+ // after the last `### Requirement:` header belongs to that block's raw and
423
+ // is discarded with it, so a rebuilt-body scan only ever sees headings above
424
+ // the first requirement - it would veto `### Notes` written before the
425
+ // requirements and miss the identical heading written after them.
426
+ unaccountedContent: contentTheMergeCannotName(parts),
263
427
  };
264
428
  }
429
+ /**
430
+ * The suffix of a requirement block that begins with content the requirement
431
+ * parser did not recognize as a boundary: a `#`, `##`, or `###` heading after
432
+ * the block's own header.
433
+ *
434
+ * `####` is excluded: a requirement's `#### Scenario:` headings are its own.
435
+ * Fenced lines are skipped, so a heading inside an example does not count.
436
+ *
437
+ * Approximate on purpose, and only ever used to WARN. A `#` line inside a
438
+ * scenario looks the same as a note written below the requirement, and no
439
+ * line-based rule separates them; a wrong warning costs a line of output, while
440
+ * acting on a wrong answer would rewrite the spec.
441
+ */
442
+ function firstForeignTail(raw) {
443
+ const lines = raw.replace(/\r\n?/g, '\n').split('\n');
444
+ const fenceMask = buildCodeFenceMask(lines);
445
+ for (let index = 1; index < lines.length; index++) {
446
+ if (fenceMask[index])
447
+ continue;
448
+ if (/^ {0,3}#{1,3}(?:[ \t]|$)/.test(lines[index])) {
449
+ return {
450
+ heading: lines[index].trim(),
451
+ raw: lines.slice(index).join('\n').trimEnd(),
452
+ };
453
+ }
454
+ }
455
+ return undefined;
456
+ }
457
+ /**
458
+ * The non-blank lines of a spec that are not part of what a retirement is able
459
+ * to name: the title, the `## Purpose` section, the `## Requirements` header,
460
+ * and each requirement block's own header, statement and scenario bullets.
461
+ *
462
+ * Deliberately whole-file. Auditing a subset of the slices is what let authored
463
+ * prose inside a removed block, and content above the requirements section, be
464
+ * deleted unmentioned.
465
+ */
466
+ function contentTheMergeCannotName(parts) {
467
+ const leftovers = [];
468
+ // Above the requirements section: the title and the Purpose section are
469
+ // expected; anything else is authored content the deletion would take.
470
+ const beforeLines = parts.before.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').split('\n');
471
+ const beforeMask = buildCodeFenceMask(beforeLines);
472
+ let inPurpose = false;
473
+ let titleSeen = false;
474
+ let previousLine = '';
475
+ for (let index = 0; index < beforeLines.length; index++) {
476
+ const line = beforeLines[index];
477
+ if (!line.trim()) {
478
+ previousLine = '';
479
+ continue;
480
+ }
481
+ if (!beforeMask[index]) {
482
+ const section = line.match(/^ {0,3}##\s+(.+?)\s*$/);
483
+ if (section) {
484
+ inPurpose = /^purpose$/i.test(section[1].trim());
485
+ if (!inPurpose)
486
+ leftovers.push(line.trim());
487
+ previousLine = line;
488
+ continue;
489
+ }
490
+ // `##` is not the only way to open a section. A setext underline turns
491
+ // the line above it into a heading, and raw HTML says so outright - a
492
+ // reader sees a sibling of `## Purpose`, not more of its body. Treating
493
+ // everything up to the next ATX `##` as Purpose swallowed those whole and
494
+ // deleted them, reported as nothing but "Purpose".
495
+ const setext = inPurpose && previousLine.trim() && /^ {0,3}(=+|-+)\s*$/.test(line);
496
+ const htmlHeading = /^ {0,3}<h[1-6]\b/i.test(line);
497
+ if (setext || htmlHeading) {
498
+ leftovers.push((setext ? previousLine : line).trim());
499
+ inPurpose = false;
500
+ previousLine = line;
501
+ continue;
502
+ }
503
+ if (/^ {0,3}#\s+.+$/.test(line)) {
504
+ if (!titleSeen && !inPurpose) {
505
+ titleSeen = true;
506
+ }
507
+ else {
508
+ leftovers.push(line.trim());
509
+ inPurpose = false;
510
+ }
511
+ previousLine = line;
512
+ continue;
513
+ }
514
+ }
515
+ previousLine = line;
516
+ if (inPurpose)
517
+ continue;
518
+ leftovers.push(line.trim());
519
+ }
520
+ // Between the header and the first requirement, and past the section's end.
521
+ for (const slice of [parts.preamble, parts.after]) {
522
+ for (const line of slice.split('\n')) {
523
+ if (line.trim())
524
+ leftovers.push(line.trim());
525
+ }
526
+ }
527
+ // Inside each requirement block, everything the block parser did not treat as
528
+ // a new header rides along in `raw` - tables, fences, comments, prose written
529
+ // below the scenarios. Only a requirement's own parts are expected here.
530
+ for (const block of parts.bodyBlocks) {
531
+ const foreignTail = firstForeignTail(block.raw);
532
+ if (foreignTail)
533
+ leftovers.push(foreignTail.heading);
534
+ const lines = block.raw.replace(/\r\n?/g, '\n').split('\n');
535
+ const mask = buildCodeFenceMask(lines);
536
+ let seenScenario = false;
537
+ // A scenario's bullets run unbroken beneath its header. A blank line after
538
+ // them ends the scenario, so bullets written past that point are a note the
539
+ // author added, not part of the scenario - and deleting the file would take
540
+ // them. Treating every bullet as a scenario's own is what let an
541
+ // operational note below the last scenario be deleted unmentioned.
542
+ let inScenarioBullets = false;
543
+ let bulletsSeen = false;
544
+ for (let index = 0; index < lines.length; index++) {
545
+ const line = lines[index];
546
+ if (!line.trim()) {
547
+ // Only a blank that follows actual bullets closes the run, so a blank
548
+ // between a scenario header and its first bullet is not a boundary.
549
+ if (bulletsSeen)
550
+ inScenarioBullets = false;
551
+ continue;
552
+ }
553
+ if (index === 0)
554
+ continue; // the `### Requirement:` header itself
555
+ // Fenced lines render as a code block inside the requirement, so they are
556
+ // its own content however they are spelled - a `### Requirement:` in an
557
+ // example is not a heading to any reader. Flagging them made a spec that
558
+ // merely documents a command unretirable.
559
+ if (mask[index])
560
+ continue;
561
+ if (index > 1 &&
562
+ /^ {0,3}(?:=+|-+)\s*$/.test(line) &&
563
+ lines[index - 1].trim()) {
564
+ leftovers.push(lines[index - 1].trim());
565
+ continue;
566
+ }
567
+ if (/^ {0,3}####\s+Scenario:/i.test(line)) {
568
+ seenScenario = true;
569
+ inScenarioBullets = true;
570
+ bulletsSeen = false;
571
+ continue;
572
+ }
573
+ if (/^\s*(?:[-*]|\d+[.)])\s/.test(line)) {
574
+ if (inScenarioBullets) {
575
+ bulletsSeen = true;
576
+ continue;
577
+ }
578
+ // A bullet outside a scenario. Before the first scenario it is part of
579
+ // the requirement statement; after one it is the author's own note.
580
+ if (!seenScenario)
581
+ continue;
582
+ leftovers.push(line.trim());
583
+ continue;
584
+ }
585
+ // Free prose above the first scenario is the requirement statement.
586
+ if (!seenScenario && !/^\s*[|<]/.test(line))
587
+ continue;
588
+ leftovers.push(line.trim());
589
+ }
590
+ }
591
+ return [...new Set(leftovers)];
592
+ }
593
+ function normalizeBlockRaw(raw) {
594
+ return raw.replace(/\r\n?/g, '\n').trim();
595
+ }
596
+ /** Count non-overlapping copies so one retained duplicate cannot mask another copy's loss. */
597
+ function countOccurrences(haystack, needle) {
598
+ if (!needle)
599
+ return 0;
600
+ let count = 0;
601
+ let start = 0;
602
+ while ((start = haystack.indexOf(needle, start)) !== -1) {
603
+ count++;
604
+ start += needle.length;
605
+ }
606
+ return count;
607
+ }
608
+ /**
609
+ * Retire a capability whose last requirement a delta removed: delete its main
610
+ * spec and prune any directories the deletion leaves empty. Returns false when
611
+ * there was nothing to delete.
612
+ *
613
+ * Gated by the caller on the change's `retire_capabilities` marker, so the one
614
+ * archive action that removes a file from `openspec/specs/` is always something
615
+ * the author asked for rather than something inferred from a delta's shape. The
616
+ * file is recoverable from git, which the report names; applying REMOVED already
617
+ * deletes requirement content from a main spec, so deleting the spec once
618
+ * nothing is left is the same operation carried to its end rather than a new
619
+ * kind of act.
620
+ *
621
+ * Only the generated `spec.md` is removed - a directory holding anything else (a
622
+ * nested capability, a hand-kept note) is left in place.
623
+ *
624
+ * The target must resolve inside the selected specs root. A capability-directory
625
+ * symlink must not turn a retirement marker into authorization to delete an
626
+ * unrelated external file. A symlinked `spec.md` itself is safe: unlink removes
627
+ * the link and leaves its target alone.
628
+ *
629
+ * Directory pruning IS bounded, by REAL paths rather than string prefixes:
630
+ * `path.resolve` collapses `..` but does not resolve symlinks, and `readdir` and
631
+ * `rmdir` both follow them, so a symlinked capability directory would otherwise
632
+ * let the walk delete directories outside the specs root entirely.
633
+ */
634
+ export async function retireSpec(update, mainSpecsDir, options = {}) {
635
+ if (options.deferDelete && options.verifyDisplaced === undefined) {
636
+ throw new Error('Deferred retirement requires displaced-file verification.');
637
+ }
638
+ // Resolved before the unlink, while the link still exists, so the report can
639
+ // name the file that actually goes when a symlink points out of the tree.
640
+ // A symlinked `spec.md` is excluded: `realpath` would follow it, but `unlink`
641
+ // removes the link and leaves the target alone, so naming the target would
642
+ // claim a file was deleted that is still there.
643
+ let realSource;
644
+ try {
645
+ const link = await fs.lstat(update.target);
646
+ realSource = link.isSymbolicLink() ? undefined : await fs.realpath(update.target);
647
+ }
648
+ catch (error) {
649
+ if (error.code === 'ENOENT')
650
+ return { retired: false };
651
+ throw new Error(`Could not retire capability '${update.id}': could not verify ${update.target} ` +
652
+ `before deletion (${error instanceof Error ? error.message : String(error)}).`);
653
+ }
654
+ if (realSource !== undefined) {
655
+ let inside;
656
+ try {
657
+ inside = await isInsideRealDir(realSource, mainSpecsDir);
658
+ }
659
+ catch (error) {
660
+ throw new Error(`Could not retire capability '${update.id}': could not verify that ${update.target} ` +
661
+ `is inside ${mainSpecsDir} (${error instanceof Error ? error.message : String(error)}).`);
662
+ }
663
+ if (!inside) {
664
+ throw new Error(`Could not retire capability '${update.id}': ${update.target} resolves outside ` +
665
+ `${mainSpecsDir}. Remove the external file by hand, or replace the symlink and rerun.`);
666
+ }
667
+ }
668
+ let displacedPath;
669
+ try {
670
+ await options.beforeMutate?.();
671
+ if (options.verifyDisplaced) {
672
+ const displaced = `${update.target}.openspec-retire-${randomUUID()}`;
673
+ displacedPath = displaced;
674
+ await fs.rename(update.target, displaced);
675
+ try {
676
+ await options.verifyDisplaced(displaced);
677
+ try {
678
+ await fs.lstat(update.target);
679
+ throw new Error(`A concurrent file appeared at ${update.target} while archive was retiring it.`);
680
+ }
681
+ catch (targetError) {
682
+ if (targetError.code !== 'ENOENT')
683
+ throw targetError;
684
+ }
685
+ if (!options.deferDelete)
686
+ await fs.unlink(displaced);
687
+ }
688
+ catch (error) {
689
+ try {
690
+ await fs.lstat(update.target);
691
+ throw new Error(`${error instanceof Error ? error.message : String(error)} ` +
692
+ `A concurrent file now occupies ${update.target}; the displaced spec was retained at ${displaced}.`);
693
+ }
694
+ catch (targetError) {
695
+ if (targetError.code !== 'ENOENT')
696
+ throw targetError;
697
+ }
698
+ await fs.rename(displaced, update.target);
699
+ throw error;
700
+ }
701
+ }
702
+ else {
703
+ await fs.unlink(update.target);
704
+ }
705
+ }
706
+ catch (error) {
707
+ if (error.code === 'ENOENT')
708
+ return { retired: false };
709
+ // A bare errno here reads as an internal failure; say what was being
710
+ // attempted so the message is actionable on its own.
711
+ throw new Error(`Could not retire capability '${update.id}': failed to delete ${update.target} ` +
712
+ `(${error.message}). Remove it by hand, then rerun the archive.`);
713
+ }
714
+ if (!options.deferDelete) {
715
+ await pruneEmptyDirs(path.dirname(update.target), mainSpecsDir);
716
+ }
717
+ const nominal = options.displayPath ?? `openspec/specs/${update.id}/spec.md`;
718
+ if (!options.silent) {
719
+ console.log(`Retiring ${nominal}: all requirements removed.`);
720
+ }
721
+ // `resolvedPath` is always the file that was actually unlinked - callers need
722
+ // it to report a path git will accept, since the nominal one is built from
723
+ // the capability id and can differ in case, or point through a symlink.
724
+ return {
725
+ retired: true,
726
+ ...(realSource ? { resolvedPath: realSource } : {}),
727
+ ...(options.deferDelete && displacedPath ? { displacedPath } : {}),
728
+ };
729
+ }
730
+ export async function finalizeRetiredSpec(target, displacedPath, mainSpecsDir) {
731
+ await fs.unlink(displacedPath);
732
+ await pruneEmptyDirs(path.dirname(target), mainSpecsDir);
733
+ }
734
+ /** Whether `realPath` (already canonical) sits under the real `dir`. */
735
+ async function isInsideRealDir(realPath, dir) {
736
+ const realDir = await fs.realpath(dir);
737
+ return realPath.startsWith(realDir + path.sep);
738
+ }
739
+ /**
740
+ * Remove now-empty directories from `startDir` upward, never leaving the real
741
+ * `boundaryDir` and never removing that directory itself.
742
+ *
743
+ * The boundary is a parameter rather than the specs root directly so the walk's
744
+ * containment is stated at the call site, where the root it must not escape is
745
+ * the thing being reasoned about.
746
+ *
747
+ * The guard re-runs every iteration, so stepping to the LEXICAL parent is safe:
748
+ * a parent that is not the real one is simply re-resolved and rejected. Errors
749
+ * are swallowed and end the walk - ENOTEMPTY and ENOENT are correct outcomes (a
750
+ * file arriving mid-walk must win), and a permissions failure leaves an empty
751
+ * directory behind, which the next successful archive clears.
752
+ *
753
+ * Not race-free: an attacker who can swap an ancestor between the check and the
754
+ * `rmdir` could get an empty directory outside the root removed. Closing that
755
+ * needs fd-relative syscalls Node does not expose, and it requires local write
756
+ * access to `openspec/specs` during an archive.
757
+ */
758
+ async function pruneEmptyDirs(startDir, boundaryDir) {
759
+ let boundary;
760
+ try {
761
+ boundary = await fs.realpath(boundaryDir);
762
+ }
763
+ catch {
764
+ return;
765
+ }
766
+ let dir = startDir;
767
+ for (;;) {
768
+ let realDir;
769
+ try {
770
+ // lstat first: rmdir on a symlink fails anyway, but resolving one would
771
+ // walk us out of the tree, and the parent we then step to would be wrong.
772
+ const link = await fs.lstat(dir);
773
+ if (link.isSymbolicLink())
774
+ return;
775
+ realDir = await fs.realpath(dir);
776
+ }
777
+ catch {
778
+ return;
779
+ }
780
+ // Strictly inside the real boundary - the boundary itself is never pruned.
781
+ if (realDir === boundary || !realDir.startsWith(boundary + path.sep))
782
+ return;
783
+ try {
784
+ const entries = await fs.readdir(dir);
785
+ if (entries.length > 0)
786
+ return;
787
+ await fs.rmdir(dir);
788
+ }
789
+ catch {
790
+ return;
791
+ }
792
+ dir = path.dirname(dir);
793
+ }
794
+ }
265
795
  /**
266
796
  * Write an updated spec to disk.
267
797
  */
268
798
  export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
799
+ assertTrustedSpecPath(update.targetRoot, update.target);
269
800
  // Create target directory if needed
270
801
  const targetDir = path.dirname(update.target);
271
802
  await fs.mkdir(targetDir, { recursive: true });
803
+ await options.beforeMutate?.();
804
+ // Preserve the established in-place write semantics: symlink referents,
805
+ // hard-linked specs, ACLs, extended attributes, and filesystems without hard
806
+ // links must continue to behave as they did before capability retirement.
272
807
  await fs.writeFile(update.target, rebuilt);
273
808
  if (options.silent)
274
809
  return;
275
- const specName = path.basename(path.dirname(update.target));
810
+ const specName = update.id;
276
811
  console.log(`Applying changes to ${options.displayPath ?? `openspec/specs/${specName}/spec.md`}:`);
277
812
  if (counts.added)
278
813
  console.log(` + ${counts.added} added`);
@@ -283,112 +818,99 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
283
818
  if (counts.renamed)
284
819
  console.log(` → ${counts.renamed} renamed`);
285
820
  }
821
+ /** Blank out `<!-- ... -->` spans, preserving line count so indices stay aligned. */
822
+ function maskHtmlComments(content) {
823
+ const blank = (text) => text.replace(/[^\n]/g, ' ');
824
+ // `--!>` is a comment terminator as well as `-->`.
825
+ const masked = content.replace(/<!--[\s\S]*?--!?>/g, blank);
826
+ // A comment that is never closed runs to end of file, so everything after it
827
+ // is commented out too. Without this an unterminated `<!--` above a
828
+ // `## Purpose` left the commented-out header looking real (#1413).
829
+ const unterminated = masked.indexOf('<!--');
830
+ if (unterminated === -1)
831
+ return masked;
832
+ return masked.slice(0, unterminated) + blank(masked.slice(unterminated));
833
+ }
286
834
  /**
287
- * Build a skeleton spec for new capabilities.
835
+ * Read the body of a `## Purpose` section, ignoring markdown that only appears
836
+ * inside fenced code blocks or HTML comments. Returns undefined when the
837
+ * section is absent or its body is empty.
288
838
  */
289
- export function buildSpecSkeleton(specFolderName, changeName) {
290
- const titleBase = specFolderName;
291
- return `# ${titleBase} Specification\n\n## Purpose\nTBD - created by archiving change ${changeName}. Update Purpose after archive.\n\n## Requirements\n`;
839
+ function extractPurposeSection(content) {
840
+ const normalized = content.replace(/\r\n?/g, '\n');
841
+ const lines = normalized.split('\n');
842
+ // Structure is read from the masked copy so a commented-out or fenced
843
+ // `## Purpose` is not mistaken for the real one; the body is returned from
844
+ // the original lines so an author's own comments and fences survive intact.
845
+ const masked = maskHtmlComments(normalized).split('\n');
846
+ const fenceMask = buildCodeFenceMask(masked);
847
+ const isStructural = (i) => !fenceMask[i];
848
+ const start = masked.findIndex((line, i) => isStructural(i) && /^##\s+Purpose\s*$/i.test(line));
849
+ if (start === -1)
850
+ return undefined;
851
+ let end = masked.length;
852
+ for (let i = start + 1; i < masked.length; i++) {
853
+ if (isStructural(i) && /^##\s+/.test(masked[i])) {
854
+ end = i;
855
+ break;
856
+ }
857
+ }
858
+ // Emptiness is judged with fenced blocks and HTML comments blanked out, so a
859
+ // Purpose that is only a code sample or only an unfilled template comment
860
+ // counts as absent and falls back to the TBD placeholder.
861
+ const hasProse = masked
862
+ .slice(start + 1, end)
863
+ .filter((_, offset) => isStructural(start + 1 + offset))
864
+ .join('\n')
865
+ .trim();
866
+ if (!hasProse)
867
+ return undefined;
868
+ const body = lines.slice(start + 1, end).join('\n').trim();
869
+ return body || undefined;
292
870
  }
293
871
  /**
294
- * Apply all delta specs from a change to main specs.
872
+ * The Purpose a new main spec would end up with, or null when carrying the
873
+ * delta's body over would leave a spec the readers downstream cannot handle.
295
874
  *
296
- * @param projectRoot - The project root directory
297
- * @param changeName - The name of the change to apply
298
- * @param options - Options for the operation
299
- * @returns Result of the operation with counts
875
+ * Returns the parsed overview rather than a boolean so callers measure the same
876
+ * string `validate` measures, not the raw slice out of the delta.
300
877
  */
301
- export async function applySpecs(projectRoot, changeName, options = {}) {
302
- const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
303
- const mainSpecsDir = path.join(projectRoot, 'openspec', 'specs');
304
- // Verify change exists
878
+ function readableOverview(skeleton, specName) {
879
+ // HTML comments are invisible to the spec parsers but not to the file itself:
880
+ // markdown hidden in one is skipped by the boundary scan yet still lands in
881
+ // the spec, where it can hide the headers those parsers depend on and blank
882
+ // the document out in any markdown renderer. Refuse rather than write a spec
883
+ // that reads differently depending on who is reading it (#1413).
884
+ //
885
+ // Only the opener is disqualifying, and only because `maskHtmlComments`
886
+ // covers unterminated comments too: a comment starting above the section
887
+ // header therefore always masks the header, leaving no body to carry, so a
888
+ // body can only hide content behind a `<!--` of its own. A bare `-->` hides
889
+ // nothing and renders as text - rejecting it would throw away a Purpose over
890
+ // prose like "ingest --> transform".
891
+ if (skeleton.includes('<!--'))
892
+ return null;
893
+ if (findMainSpecStructureIssues(skeleton).length > 0)
894
+ return null;
305
895
  try {
306
- const stat = await fs.stat(changeDir);
307
- if (!stat.isDirectory()) {
308
- throw new Error(`Change '${changeName}' not found.`);
309
- }
896
+ // A heading or unterminated fence in the body truncates or swallows the
897
+ // sections around it, so archive would abort or write a spec its own
898
+ // validator rejects.
899
+ return new MarkdownParser(skeleton).parseSpec(specName).overview.trim() || null;
310
900
  }
311
901
  catch {
312
- throw new Error(`Change '${changeName}' not found.`);
902
+ return null;
313
903
  }
314
- // Find specs to update
315
- const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir);
316
- if (specUpdates.length === 0) {
317
- return {
318
- changeName,
319
- capabilities: [],
320
- totals: { added: 0, modified: 0, removed: 0, renamed: 0 },
321
- noChanges: true,
322
- };
323
- }
324
- // Prepare all updates first (validation pass, no writes)
325
- const prepared = [];
326
- for (const update of specUpdates) {
327
- const built = await buildUpdatedSpec(update, changeName);
328
- prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts });
329
- }
330
- // Validate rebuilt specs unless validation is skipped
331
- if (!options.skipValidation) {
332
- const validator = new Validator();
333
- for (const p of prepared) {
334
- const specName = path.basename(path.dirname(p.update.target));
335
- const report = await validator.validateSpecContent(specName, p.rebuilt);
336
- if (!report.valid) {
337
- const errors = report.issues
338
- .filter((i) => i.level === 'ERROR')
339
- .map((i) => ` ✗ ${i.message}`)
340
- .join('\n');
341
- throw new Error(`Validation errors in rebuilt spec for ${specName}:\n${errors}`);
342
- }
343
- }
344
- }
345
- // Build results
346
- const capabilities = [];
347
- const totals = { added: 0, modified: 0, removed: 0, renamed: 0 };
348
- for (const p of prepared) {
349
- const capability = path.basename(path.dirname(p.update.target));
350
- if (!options.dryRun) {
351
- // Write the updated spec
352
- const targetDir = path.dirname(p.update.target);
353
- await fs.mkdir(targetDir, { recursive: true });
354
- await fs.writeFile(p.update.target, p.rebuilt);
355
- if (!options.silent) {
356
- console.log(`Applying changes to openspec/specs/${capability}/spec.md:`);
357
- if (p.counts.added)
358
- console.log(` + ${p.counts.added} added`);
359
- if (p.counts.modified)
360
- console.log(` ~ ${p.counts.modified} modified`);
361
- if (p.counts.removed)
362
- console.log(` - ${p.counts.removed} removed`);
363
- if (p.counts.renamed)
364
- console.log(` → ${p.counts.renamed} renamed`);
365
- }
366
- }
367
- else if (!options.silent) {
368
- console.log(`Would apply changes to openspec/specs/${capability}/spec.md:`);
369
- if (p.counts.added)
370
- console.log(` + ${p.counts.added} added`);
371
- if (p.counts.modified)
372
- console.log(` ~ ${p.counts.modified} modified`);
373
- if (p.counts.removed)
374
- console.log(` - ${p.counts.removed} removed`);
375
- if (p.counts.renamed)
376
- console.log(` → ${p.counts.renamed} renamed`);
377
- }
378
- capabilities.push({
379
- capability,
380
- ...p.counts,
381
- });
382
- totals.added += p.counts.added;
383
- totals.modified += p.counts.modified;
384
- totals.removed += p.counts.removed;
385
- totals.renamed += p.counts.renamed;
386
- }
387
- return {
388
- changeName,
389
- capabilities,
390
- totals,
391
- noChanges: false,
392
- };
904
+ }
905
+ /**
906
+ * Build a skeleton spec for new capabilities. When the delta spec authored a
907
+ * `## Purpose`, carry it over instead of the TBD placeholder (#1413) - archive
908
+ * invents the Purpose for a brand-new main spec either way, and the author's
909
+ * own wording beats a placeholder they then have to hand-edit.
910
+ */
911
+ export function buildSpecSkeleton(specFolderName, changeName, purpose) {
912
+ const titleBase = specFolderName;
913
+ const purposeBody = purpose?.trim() || `TBD - created by archiving change ${changeName}. Update Purpose after archive.`;
914
+ return `# ${titleBase} Specification\n\n## Purpose\n${purposeBody}\n\n## Requirements\n`;
393
915
  }
394
916
  //# sourceMappingURL=specs-apply.js.map