@seanmars/tospec 0.19.0-beta.7 → 0.20.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 (429) hide show
  1. package/CHANGELOG.md +60 -231
  2. package/README.md +69 -82
  3. package/assets/dashboard/app.js +14 -3
  4. package/assets/dashboard/style.css +7 -0
  5. package/assets/rules/tospec/decision.md +3 -0
  6. package/dist/cli/index.d.ts.map +1 -1
  7. package/dist/cli/index.js +227 -86
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/commands/config.d.ts +9 -17
  10. package/dist/commands/config.d.ts.map +1 -1
  11. package/dist/commands/config.js +366 -107
  12. package/dist/commands/config.js.map +1 -1
  13. package/dist/commands/dashboard.d.ts +57 -99
  14. package/dist/commands/dashboard.d.ts.map +1 -1
  15. package/dist/commands/dashboard.js +224 -282
  16. package/dist/commands/dashboard.js.map +1 -1
  17. package/dist/commands/decision.d.ts +41 -19
  18. package/dist/commands/decision.d.ts.map +1 -1
  19. package/dist/commands/decision.js +385 -79
  20. package/dist/commands/decision.js.map +1 -1
  21. package/dist/commands/metrics.d.ts +28 -51
  22. package/dist/commands/metrics.d.ts.map +1 -1
  23. package/dist/commands/metrics.js +62 -93
  24. package/dist/commands/metrics.js.map +1 -1
  25. package/dist/commands/shared-output.d.ts +18 -21
  26. package/dist/commands/shared-output.d.ts.map +1 -1
  27. package/dist/commands/shared-output.js +49 -23
  28. package/dist/commands/shared-output.js.map +1 -1
  29. package/dist/commands/show.d.ts +7 -0
  30. package/dist/commands/show.d.ts.map +1 -1
  31. package/dist/commands/show.js +29 -7
  32. package/dist/commands/show.js.map +1 -1
  33. package/dist/commands/validate.d.ts +43 -45
  34. package/dist/commands/validate.d.ts.map +1 -1
  35. package/dist/commands/validate.js +262 -129
  36. package/dist/commands/validate.js.map +1 -1
  37. package/dist/commands/workflow/index.d.ts +6 -10
  38. package/dist/commands/workflow/index.d.ts.map +1 -1
  39. package/dist/commands/workflow/index.js +6 -10
  40. package/dist/commands/workflow/index.js.map +1 -1
  41. package/dist/commands/workflow/instructions.d.ts +21 -8
  42. package/dist/commands/workflow/instructions.d.ts.map +1 -1
  43. package/dist/commands/workflow/instructions.js +254 -95
  44. package/dist/commands/workflow/instructions.js.map +1 -1
  45. package/dist/commands/workflow/new-change.d.ts +4 -5
  46. package/dist/commands/workflow/new-change.d.ts.map +1 -1
  47. package/dist/commands/workflow/new-change.js +90 -25
  48. package/dist/commands/workflow/new-change.js.map +1 -1
  49. package/dist/commands/workflow/schemas.d.ts +3 -5
  50. package/dist/commands/workflow/schemas.d.ts.map +1 -1
  51. package/dist/commands/workflow/schemas.js +37 -11
  52. package/dist/commands/workflow/schemas.js.map +1 -1
  53. package/dist/commands/workflow/shared.d.ts +48 -21
  54. package/dist/commands/workflow/shared.d.ts.map +1 -1
  55. package/dist/commands/workflow/shared.js +36 -33
  56. package/dist/commands/workflow/shared.js.map +1 -1
  57. package/dist/commands/workflow/status.d.ts +10 -6
  58. package/dist/commands/workflow/status.d.ts.map +1 -1
  59. package/dist/commands/workflow/status.js +81 -42
  60. package/dist/commands/workflow/status.js.map +1 -1
  61. package/dist/commands/workflow/templates.d.ts +10 -3
  62. package/dist/commands/workflow/templates.d.ts.map +1 -1
  63. package/dist/commands/workflow/templates.js +39 -40
  64. package/dist/commands/workflow/templates.js.map +1 -1
  65. package/dist/core/archive.d.ts +26 -21
  66. package/dist/core/archive.d.ts.map +1 -1
  67. package/dist/core/archive.js +378 -235
  68. package/dist/core/archive.js.map +1 -1
  69. package/dist/core/artifact-graph/graph.d.ts +25 -42
  70. package/dist/core/artifact-graph/graph.d.ts.map +1 -1
  71. package/dist/core/artifact-graph/graph.js +45 -63
  72. package/dist/core/artifact-graph/graph.js.map +1 -1
  73. package/dist/core/artifact-graph/index.d.ts +1 -1
  74. package/dist/core/artifact-graph/index.d.ts.map +1 -1
  75. package/dist/core/artifact-graph/index.js +1 -1
  76. package/dist/core/artifact-graph/index.js.map +1 -1
  77. package/dist/core/artifact-graph/instruction-loader.d.ts +97 -103
  78. package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
  79. package/dist/core/artifact-graph/instruction-loader.js +146 -119
  80. package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
  81. package/dist/core/artifact-graph/outputs.d.ts +9 -23
  82. package/dist/core/artifact-graph/outputs.d.ts.map +1 -1
  83. package/dist/core/artifact-graph/outputs.js +45 -38
  84. package/dist/core/artifact-graph/outputs.js.map +1 -1
  85. package/dist/core/artifact-graph/resolver.d.ts +44 -63
  86. package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
  87. package/dist/core/artifact-graph/resolver.js +85 -86
  88. package/dist/core/artifact-graph/resolver.js.map +1 -1
  89. package/dist/core/artifact-graph/schema.d.ts +0 -6
  90. package/dist/core/artifact-graph/schema.d.ts.map +1 -1
  91. package/dist/core/artifact-graph/schema.js +7 -32
  92. package/dist/core/artifact-graph/schema.js.map +1 -1
  93. package/dist/core/artifact-graph/state.d.ts +1 -8
  94. package/dist/core/artifact-graph/state.d.ts.map +1 -1
  95. package/dist/core/artifact-graph/state.js +2 -17
  96. package/dist/core/artifact-graph/state.js.map +1 -1
  97. package/dist/core/artifact-graph/stub-detection.d.ts +12 -0
  98. package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -0
  99. package/dist/core/artifact-graph/stub-detection.js +39 -0
  100. package/dist/core/artifact-graph/stub-detection.js.map +1 -0
  101. package/dist/core/artifact-graph/types.d.ts +4 -0
  102. package/dist/core/artifact-graph/types.d.ts.map +1 -1
  103. package/dist/core/artifact-graph/types.js +30 -10
  104. package/dist/core/artifact-graph/types.js.map +1 -1
  105. package/dist/core/available-tools.d.ts +3 -12
  106. package/dist/core/available-tools.d.ts.map +1 -1
  107. package/dist/core/available-tools.js +4 -13
  108. package/dist/core/available-tools.js.map +1 -1
  109. package/dist/core/change-metadata/schema.d.ts +1 -1
  110. package/dist/core/change-metadata/schema.d.ts.map +1 -1
  111. package/dist/core/change-metadata/schema.js +10 -7
  112. package/dist/core/change-metadata/schema.js.map +1 -1
  113. package/dist/core/change-presenter.d.ts +23 -18
  114. package/dist/core/change-presenter.d.ts.map +1 -1
  115. package/dist/core/change-presenter.js +102 -43
  116. package/dist/core/change-presenter.js.map +1 -1
  117. package/dist/core/change-status-policy.d.ts +8 -1
  118. package/dist/core/change-status-policy.d.ts.map +1 -1
  119. package/dist/core/change-status-policy.js +25 -1
  120. package/dist/core/change-status-policy.js.map +1 -1
  121. package/dist/core/codex-metrics.d.ts +25 -45
  122. package/dist/core/codex-metrics.d.ts.map +1 -1
  123. package/dist/core/codex-metrics.js +44 -88
  124. package/dist/core/codex-metrics.js.map +1 -1
  125. package/dist/core/codex-residue.d.ts +14 -15
  126. package/dist/core/codex-residue.d.ts.map +1 -1
  127. package/dist/core/codex-residue.js +18 -22
  128. package/dist/core/codex-residue.js.map +1 -1
  129. package/dist/core/command-generation/adapters/claude.d.ts +2 -9
  130. package/dist/core/command-generation/adapters/claude.d.ts.map +1 -1
  131. package/dist/core/command-generation/adapters/claude.js +2 -12
  132. package/dist/core/command-generation/adapters/claude.js.map +1 -1
  133. package/dist/core/command-generation/adapters/index.d.ts +1 -9
  134. package/dist/core/command-generation/adapters/index.d.ts.map +1 -1
  135. package/dist/core/command-generation/adapters/index.js +1 -9
  136. package/dist/core/command-generation/adapters/index.js.map +1 -1
  137. package/dist/core/command-generation/generator.d.ts +0 -17
  138. package/dist/core/command-generation/generator.d.ts.map +1 -1
  139. package/dist/core/command-generation/generator.js +0 -17
  140. package/dist/core/command-generation/generator.js.map +1 -1
  141. package/dist/core/command-generation/index.d.ts +2 -5
  142. package/dist/core/command-generation/index.d.ts.map +1 -1
  143. package/dist/core/command-generation/index.js +0 -9
  144. package/dist/core/command-generation/index.js.map +1 -1
  145. package/dist/core/command-generation/types.d.ts +10 -36
  146. package/dist/core/command-generation/types.d.ts.map +1 -1
  147. package/dist/core/command-generation/types.js +0 -6
  148. package/dist/core/command-generation/types.js.map +1 -1
  149. package/dist/core/command-generation/yaml.d.ts +3 -18
  150. package/dist/core/command-generation/yaml.d.ts.map +1 -1
  151. package/dist/core/command-generation/yaml.js +5 -23
  152. package/dist/core/command-generation/yaml.js.map +1 -1
  153. package/dist/core/config-prompts.d.ts +2 -4
  154. package/dist/core/config-prompts.d.ts.map +1 -1
  155. package/dist/core/config-prompts.js +2 -7
  156. package/dist/core/config-prompts.js.map +1 -1
  157. package/dist/core/config-schema.d.ts +8 -53
  158. package/dist/core/config-schema.d.ts.map +1 -1
  159. package/dist/core/config-schema.js +49 -62
  160. package/dist/core/config-schema.js.map +1 -1
  161. package/dist/core/config.d.ts +39 -26
  162. package/dist/core/config.d.ts.map +1 -1
  163. package/dist/core/config.js +36 -22
  164. package/dist/core/config.js.map +1 -1
  165. package/dist/core/dashboard-activity.d.ts +7 -9
  166. package/dist/core/dashboard-activity.d.ts.map +1 -1
  167. package/dist/core/dashboard-activity.js +26 -24
  168. package/dist/core/dashboard-activity.js.map +1 -1
  169. package/dist/core/dashboard-data.d.ts +40 -22
  170. package/dist/core/dashboard-data.d.ts.map +1 -1
  171. package/dist/core/dashboard-data.js +62 -70
  172. package/dist/core/dashboard-data.js.map +1 -1
  173. package/dist/core/global-config.d.ts +24 -53
  174. package/dist/core/global-config.d.ts.map +1 -1
  175. package/dist/core/global-config.js +38 -67
  176. package/dist/core/global-config.js.map +1 -1
  177. package/dist/core/init.d.ts +25 -19
  178. package/dist/core/init.d.ts.map +1 -1
  179. package/dist/core/init.js +168 -181
  180. package/dist/core/init.js.map +1 -1
  181. package/dist/core/list.d.ts +1 -1
  182. package/dist/core/list.d.ts.map +1 -1
  183. package/dist/core/list.js +121 -28
  184. package/dist/core/list.js.map +1 -1
  185. package/dist/core/local-server.d.ts +41 -83
  186. package/dist/core/local-server.d.ts.map +1 -1
  187. package/dist/core/local-server.js +53 -98
  188. package/dist/core/local-server.js.map +1 -1
  189. package/dist/core/markdown-render.d.ts +25 -0
  190. package/dist/core/markdown-render.d.ts.map +1 -0
  191. package/dist/core/markdown-render.js +94 -0
  192. package/dist/core/markdown-render.js.map +1 -0
  193. package/dist/core/migrate.d.ts +29 -14
  194. package/dist/core/migrate.d.ts.map +1 -1
  195. package/dist/core/migrate.js +184 -122
  196. package/dist/core/migrate.js.map +1 -1
  197. package/dist/core/parsers/change-parser.d.ts +7 -10
  198. package/dist/core/parsers/change-parser.d.ts.map +1 -1
  199. package/dist/core/parsers/change-parser.js +48 -56
  200. package/dist/core/parsers/change-parser.js.map +1 -1
  201. package/dist/core/parsers/markdown-parser.d.ts +8 -9
  202. package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
  203. package/dist/core/parsers/markdown-parser.js +31 -22
  204. package/dist/core/parsers/markdown-parser.js.map +1 -1
  205. package/dist/core/parsers/requirement-blocks.d.ts +43 -15
  206. package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
  207. package/dist/core/parsers/requirement-blocks.js +142 -70
  208. package/dist/core/parsers/requirement-blocks.js.map +1 -1
  209. package/dist/core/parsers/requirement-text.d.ts +73 -79
  210. package/dist/core/parsers/requirement-text.d.ts.map +1 -1
  211. package/dist/core/parsers/requirement-text.js +137 -79
  212. package/dist/core/parsers/requirement-text.js.map +1 -1
  213. package/dist/core/parsers/spec-structure.d.ts.map +1 -1
  214. package/dist/core/parsers/spec-structure.js +10 -6
  215. package/dist/core/parsers/spec-structure.js.map +1 -1
  216. package/dist/core/profiles.d.ts +3 -10
  217. package/dist/core/profiles.d.ts.map +1 -1
  218. package/dist/core/profiles.js +5 -12
  219. package/dist/core/profiles.js.map +1 -1
  220. package/dist/core/project-config.d.ts +43 -44
  221. package/dist/core/project-config.d.ts.map +1 -1
  222. package/dist/core/project-config.js +107 -82
  223. package/dist/core/project-config.js.map +1 -1
  224. package/dist/core/project-layout.d.ts +9 -17
  225. package/dist/core/project-layout.d.ts.map +1 -1
  226. package/dist/core/project-layout.js +16 -26
  227. package/dist/core/project-layout.js.map +1 -1
  228. package/dist/core/root-selection.d.ts +11 -7
  229. package/dist/core/root-selection.d.ts.map +1 -1
  230. package/dist/core/root-selection.js +7 -8
  231. package/dist/core/root-selection.js.map +1 -1
  232. package/dist/core/rules.d.ts +6 -1
  233. package/dist/core/rules.d.ts.map +1 -1
  234. package/dist/core/rules.js +23 -2
  235. package/dist/core/rules.js.map +1 -1
  236. package/dist/core/schema-names.d.ts +16 -0
  237. package/dist/core/schema-names.d.ts.map +1 -0
  238. package/dist/core/schema-names.js +16 -0
  239. package/dist/core/schema-names.js.map +1 -0
  240. package/dist/core/schemas/base.schema.d.ts +3 -0
  241. package/dist/core/schemas/base.schema.d.ts.map +1 -1
  242. package/dist/core/schemas/base.schema.js +22 -6
  243. package/dist/core/schemas/base.schema.js.map +1 -1
  244. package/dist/core/schemas/change.schema.d.ts +16 -0
  245. package/dist/core/schemas/change.schema.d.ts.map +1 -1
  246. package/dist/core/schemas/change.schema.js +41 -10
  247. package/dist/core/schemas/change.schema.js.map +1 -1
  248. package/dist/core/schemas/spec.schema.d.ts +2 -0
  249. package/dist/core/schemas/spec.schema.d.ts.map +1 -1
  250. package/dist/core/shared/index.d.ts +3 -8
  251. package/dist/core/shared/index.d.ts.map +1 -1
  252. package/dist/core/shared/index.js +3 -8
  253. package/dist/core/shared/index.js.map +1 -1
  254. package/dist/core/shared/rules-generation.d.ts +19 -8
  255. package/dist/core/shared/rules-generation.d.ts.map +1 -1
  256. package/dist/core/shared/rules-generation.js +117 -50
  257. package/dist/core/shared/rules-generation.js.map +1 -1
  258. package/dist/core/shared/skill-generation.d.ts +28 -43
  259. package/dist/core/shared/skill-generation.d.ts.map +1 -1
  260. package/dist/core/shared/skill-generation.js +82 -51
  261. package/dist/core/shared/skill-generation.js.map +1 -1
  262. package/dist/core/shared/tool-detection.d.ts +39 -61
  263. package/dist/core/shared/tool-detection.d.ts.map +1 -1
  264. package/dist/core/shared/tool-detection.js +88 -80
  265. package/dist/core/shared/tool-detection.js.map +1 -1
  266. package/dist/core/skill-metrics.d.ts +36 -63
  267. package/dist/core/skill-metrics.d.ts.map +1 -1
  268. package/dist/core/skill-metrics.js +34 -73
  269. package/dist/core/skill-metrics.js.map +1 -1
  270. package/dist/core/spec-presenter.js +6 -6
  271. package/dist/core/spec-presenter.js.map +1 -1
  272. package/dist/core/specs-apply.d.ts +22 -23
  273. package/dist/core/specs-apply.d.ts.map +1 -1
  274. package/dist/core/specs-apply.js +166 -193
  275. package/dist/core/specs-apply.js.map +1 -1
  276. package/dist/core/templates/fragments/interview.d.ts +2 -6
  277. package/dist/core/templates/fragments/interview.d.ts.map +1 -1
  278. package/dist/core/templates/fragments/interview.js +2 -6
  279. package/dist/core/templates/fragments/interview.js.map +1 -1
  280. package/dist/core/templates/fragments/next-step.d.ts +4 -8
  281. package/dist/core/templates/fragments/next-step.d.ts.map +1 -1
  282. package/dist/core/templates/fragments/next-step.js +4 -8
  283. package/dist/core/templates/fragments/next-step.js.map +1 -1
  284. package/dist/core/templates/fragments/validate.d.ts +13 -0
  285. package/dist/core/templates/fragments/validate.d.ts.map +1 -0
  286. package/dist/core/templates/fragments/validate.js +13 -0
  287. package/dist/core/templates/fragments/validate.js.map +1 -0
  288. package/dist/core/templates/fragments/verify.d.ts +9 -12
  289. package/dist/core/templates/fragments/verify.d.ts.map +1 -1
  290. package/dist/core/templates/fragments/verify.js +9 -12
  291. package/dist/core/templates/fragments/verify.js.map +1 -1
  292. package/dist/core/templates/index.d.ts +0 -6
  293. package/dist/core/templates/index.d.ts.map +1 -1
  294. package/dist/core/templates/index.js +0 -7
  295. package/dist/core/templates/index.js.map +1 -1
  296. package/dist/core/templates/skill-templates.d.ts +1 -5
  297. package/dist/core/templates/skill-templates.d.ts.map +1 -1
  298. package/dist/core/templates/skill-templates.js +0 -5
  299. package/dist/core/templates/skill-templates.js.map +1 -1
  300. package/dist/core/templates/types.d.ts +3 -7
  301. package/dist/core/templates/types.d.ts.map +1 -1
  302. package/dist/core/templates/types.js +0 -3
  303. package/dist/core/templates/types.js.map +1 -1
  304. package/dist/core/templates/workflows/apply.d.ts +3 -9
  305. package/dist/core/templates/workflows/apply.d.ts.map +1 -1
  306. package/dist/core/templates/workflows/apply.js +11 -13
  307. package/dist/core/templates/workflows/apply.js.map +1 -1
  308. package/dist/core/templates/workflows/archive.d.ts +0 -6
  309. package/dist/core/templates/workflows/archive.d.ts.map +1 -1
  310. package/dist/core/templates/workflows/archive.js +19 -6
  311. package/dist/core/templates/workflows/archive.js.map +1 -1
  312. package/dist/core/templates/workflows/decision.js +4 -4
  313. package/dist/core/templates/workflows/decision.js.map +1 -1
  314. package/dist/core/templates/workflows/explore.js +1 -1
  315. package/dist/core/templates/workflows/grill.d.ts.map +1 -1
  316. package/dist/core/templates/workflows/grill.js +0 -2
  317. package/dist/core/templates/workflows/grill.js.map +1 -1
  318. package/dist/core/templates/workflows/issue.d.ts +0 -6
  319. package/dist/core/templates/workflows/issue.d.ts.map +1 -1
  320. package/dist/core/templates/workflows/issue.js +3 -2
  321. package/dist/core/templates/workflows/issue.js.map +1 -1
  322. package/dist/core/templates/workflows/propose.d.ts +0 -6
  323. package/dist/core/templates/workflows/propose.d.ts.map +1 -1
  324. package/dist/core/templates/workflows/propose.js +2 -2
  325. package/dist/core/templates/workflows/propose.js.map +1 -1
  326. package/dist/core/templates/workflows/sync.d.ts +2 -8
  327. package/dist/core/templates/workflows/sync.d.ts.map +1 -1
  328. package/dist/core/templates/workflows/sync.js +4 -3
  329. package/dist/core/templates/workflows/sync.js.map +1 -1
  330. package/dist/core/templates/workflows/update.d.ts +0 -6
  331. package/dist/core/templates/workflows/update.d.ts.map +1 -1
  332. package/dist/core/templates/workflows/update.js +9 -2
  333. package/dist/core/templates/workflows/update.js.map +1 -1
  334. package/dist/core/update.d.ts +23 -23
  335. package/dist/core/update.d.ts.map +1 -1
  336. package/dist/core/update.js +139 -118
  337. package/dist/core/update.js.map +1 -1
  338. package/dist/core/user-state-migration.d.ts +13 -15
  339. package/dist/core/user-state-migration.d.ts.map +1 -1
  340. package/dist/core/user-state-migration.js +16 -20
  341. package/dist/core/user-state-migration.js.map +1 -1
  342. package/dist/core/validation/constants.d.ts +4 -10
  343. package/dist/core/validation/constants.d.ts.map +1 -1
  344. package/dist/core/validation/constants.js +25 -20
  345. package/dist/core/validation/constants.js.map +1 -1
  346. package/dist/core/validation/prose-length.d.ts +15 -0
  347. package/dist/core/validation/prose-length.d.ts.map +1 -0
  348. package/dist/core/validation/prose-length.js +29 -0
  349. package/dist/core/validation/prose-length.js.map +1 -0
  350. package/dist/core/validation/purpose-placeholder.d.ts +9 -16
  351. package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
  352. package/dist/core/validation/purpose-placeholder.js +30 -44
  353. package/dist/core/validation/purpose-placeholder.js.map +1 -1
  354. package/dist/core/validation/section-validator.d.ts +4 -4
  355. package/dist/core/validation/section-validator.d.ts.map +1 -1
  356. package/dist/core/validation/section-validator.js +43 -7
  357. package/dist/core/validation/section-validator.js.map +1 -1
  358. package/dist/core/validation/task-numbering.d.ts +6 -3
  359. package/dist/core/validation/task-numbering.d.ts.map +1 -1
  360. package/dist/core/validation/task-numbering.js +23 -11
  361. package/dist/core/validation/task-numbering.js.map +1 -1
  362. package/dist/core/validation/types.d.ts +18 -0
  363. package/dist/core/validation/types.d.ts.map +1 -1
  364. package/dist/core/validation/types.js +12 -1
  365. package/dist/core/validation/types.js.map +1 -1
  366. package/dist/core/validation/validator.d.ts +43 -63
  367. package/dist/core/validation/validator.d.ts.map +1 -1
  368. package/dist/core/validation/validator.js +500 -267
  369. package/dist/core/validation/validator.js.map +1 -1
  370. package/dist/prompts/searchable-multi-select.d.ts +3 -8
  371. package/dist/prompts/searchable-multi-select.d.ts.map +1 -1
  372. package/dist/prompts/searchable-multi-select.js +16 -39
  373. package/dist/prompts/searchable-multi-select.js.map +1 -1
  374. package/dist/utils/change-metadata.d.ts +11 -50
  375. package/dist/utils/change-metadata.d.ts.map +1 -1
  376. package/dist/utils/change-metadata.js +48 -67
  377. package/dist/utils/change-metadata.js.map +1 -1
  378. package/dist/utils/change-utils.d.ts +30 -54
  379. package/dist/utils/change-utils.d.ts.map +1 -1
  380. package/dist/utils/change-utils.js +133 -91
  381. package/dist/utils/change-utils.js.map +1 -1
  382. package/dist/utils/file-lock.d.ts +39 -0
  383. package/dist/utils/file-lock.d.ts.map +1 -0
  384. package/dist/utils/file-lock.js +149 -0
  385. package/dist/utils/file-lock.js.map +1 -0
  386. package/dist/utils/file-system.d.ts +12 -32
  387. package/dist/utils/file-system.d.ts.map +1 -1
  388. package/dist/utils/file-system.js +16 -40
  389. package/dist/utils/file-system.js.map +1 -1
  390. package/dist/utils/frontmatter.d.ts +7 -11
  391. package/dist/utils/frontmatter.d.ts.map +1 -1
  392. package/dist/utils/frontmatter.js +11 -11
  393. package/dist/utils/frontmatter.js.map +1 -1
  394. package/dist/utils/interactive.d.ts +4 -9
  395. package/dist/utils/interactive.d.ts.map +1 -1
  396. package/dist/utils/interactive.js +2 -4
  397. package/dist/utils/interactive.js.map +1 -1
  398. package/dist/utils/item-discovery.d.ts +15 -10
  399. package/dist/utils/item-discovery.d.ts.map +1 -1
  400. package/dist/utils/item-discovery.js +42 -47
  401. package/dist/utils/item-discovery.js.map +1 -1
  402. package/dist/utils/link.d.ts +9 -18
  403. package/dist/utils/link.d.ts.map +1 -1
  404. package/dist/utils/link.js +9 -18
  405. package/dist/utils/link.js.map +1 -1
  406. package/dist/utils/requirement-diff.d.ts +13 -23
  407. package/dist/utils/requirement-diff.d.ts.map +1 -1
  408. package/dist/utils/requirement-diff.js +13 -23
  409. package/dist/utils/requirement-diff.js.map +1 -1
  410. package/dist/utils/spec-files.d.ts +7 -20
  411. package/dist/utils/spec-files.d.ts.map +1 -1
  412. package/dist/utils/spec-files.js +26 -51
  413. package/dist/utils/spec-files.js.map +1 -1
  414. package/dist/utils/task-progress.d.ts +11 -9
  415. package/dist/utils/task-progress.d.ts.map +1 -1
  416. package/dist/utils/task-progress.js +53 -32
  417. package/dist/utils/task-progress.js.map +1 -1
  418. package/dist/utils/timestamp.d.ts +5 -8
  419. package/dist/utils/timestamp.d.ts.map +1 -1
  420. package/dist/utils/timestamp.js +5 -8
  421. package/dist/utils/timestamp.js.map +1 -1
  422. package/package.json +2 -3
  423. package/schemas/decision/templates/decision.md +3 -1
  424. package/schemas/decision/templates/index.md +2 -2
  425. package/schemas/issue/schema.yaml +8 -1
  426. package/schemas/issue/templates/spec.md +27 -2
  427. package/schemas/sdd/schema.yaml +20 -1
  428. package/schemas/sdd/templates/spec.md +27 -2
  429. /package/assets/rules/tospec/{single-sourc-of-truth.md → single-source-of-truth.md} +0 -0
@@ -1,22 +1,11 @@
1
- /**
2
- * Spec Application Logic
3
- *
4
- * Extracted from ArchiveCommand to enable standalone spec application.
5
- * Applies delta specs from a change to main specs without archiving.
6
- */
1
+ /** Applies a change's delta specs to the main specs, independently of archiving. */
7
2
  import { promises as fs } from 'fs';
8
3
  import path from 'path';
9
- import { extractRequirementsSection, parseDeltaSpec, normalizeRequirementName, foldRequirementName, } from './parsers/requirement-blocks.js';
4
+ import { extractRequirementsSection, parseDeltaSpec, normalizeRequirementName, foldRequirementName, REQUIREMENT_HEADER_REGEX, } from './parsers/requirement-blocks.js';
10
5
  import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
11
6
  import { isAbsentDirectoryError, isMissingPathError } from '../utils/file-system.js';
12
- import { buildCodeFenceMask, findMissingScenarios } from './parsers/requirement-text.js';
7
+ import { buildCodeFenceMask, findMissingScenarios, normalizeDocument } from './parsers/requirement-text.js';
13
8
  import { PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX, } from './validation/constants.js';
14
- // -----------------------------------------------------------------------------
15
- // Public API
16
- // -----------------------------------------------------------------------------
17
- /**
18
- * Find all delta spec files that need to be applied from a change.
19
- */
20
9
  export async function findSpecUpdates(changeDir, mainSpecsDir) {
21
10
  const updates = [];
22
11
  const changeSpecsDir = path.join(changeDir, 'specs');
@@ -25,9 +14,8 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
25
14
  entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });
26
15
  }
27
16
  catch (error) {
28
- // An empty result reads as "this change has no deltas", which callers act
29
- // on: archive merges nothing and reports success. Only a directory that is
30
- // genuinely not there may say that.
17
+ // An empty result means "no deltas", and archive merges nothing and reports
18
+ // success on it. Only a genuinely absent directory may say that.
31
19
  if (isAbsentDirectoryError(error))
32
20
  return updates;
33
21
  throw error;
@@ -41,16 +29,14 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
41
29
  await fs.access(specFile);
42
30
  }
43
31
  catch (error) {
44
- // A capability folder with no spec.md is not a delta. A spec.md that
45
- // exists and cannot be reached is one, and skipping it drops that
46
- // capability's requirements without a word.
32
+ // A capability folder with no spec.md is not a delta; an unreachable
33
+ // spec.md is one, and skipping it drops its requirements silently.
47
34
  if (isMissingPathError(error))
48
35
  continue;
49
36
  throw error;
50
37
  }
51
- // Whether the capability is new. Same rule again: an unreachable target is
52
- // not an absent one, and calling it absent sends the merge down the
53
- // new-capability path — which rebuilds the spec from a skeleton.
38
+ // Same rule: calling an unreachable target absent sends the merge down the
39
+ // new-capability path, which rebuilds the spec from a skeleton.
54
40
  let exists = true;
55
41
  try {
56
42
  await fs.access(targetFile);
@@ -69,40 +55,53 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
69
55
  return updates;
70
56
  }
71
57
  /**
72
- * Build an updated spec by applying delta operations.
73
- * Returns the rebuilt content and counts of operations.
58
+ * The capability directory's real name on disk, or `fallback` when absent.
59
+ * Differs only on a case-insensitive filesystem, where `specs/RegexCap` and
60
+ * `specs/regexcap` are one directory — this normalises a message, it does not
61
+ * merge anything.
74
62
  */
63
+ async function resolveOnDiskSpecName(targetPath, fallback) {
64
+ const parent = path.dirname(path.dirname(targetPath));
65
+ try {
66
+ const entries = await fs.readdir(parent);
67
+ return entries.find((entry) => entry.toLowerCase() === fallback.toLowerCase()) ?? fallback;
68
+ }
69
+ catch {
70
+ return fallback;
71
+ }
72
+ }
75
73
  export async function buildUpdatedSpec(update, changeName, options = {}) {
76
- // Read change spec content (delta-format expected)
74
+ const notices = [];
75
+ const notice = (code, message) => {
76
+ notices.push({ code, message });
77
+ if (!options.silent)
78
+ console.log(`Warning: ${message}`);
79
+ };
77
80
  const changeContent = await fs.readFile(update.source, 'utf-8');
78
- // Parse deltas from the change spec file
79
81
  const plan = parseDeltaSpec(changeContent);
82
+ // The capability as the delta spelled it — messages about the delta's own
83
+ // content echo the author's spelling so they can find it in their file.
80
84
  const specName = path.basename(path.dirname(update.target));
81
- // Pre-validate duplicates within sections
82
- const addedNames = new Set();
83
- for (const add of plan.added) {
84
- const name = normalizeRequirementName(add.name);
85
- if (addedNames.has(name)) {
86
- throw new Error(`${specName} validation failed - duplicate requirement in ADDED for header "### Requirement: ${add.name}"`);
87
- }
88
- addedNames.add(name);
89
- }
90
- const modifiedNames = new Set();
91
- for (const mod of plan.modified) {
92
- const name = normalizeRequirementName(mod.name);
93
- if (modifiedNames.has(name)) {
94
- throw new Error(`${specName} validation failed - duplicate requirement in MODIFIED for header "### Requirement: ${mod.name}"`);
95
- }
96
- modifiedNames.add(name);
97
- }
98
- const removedNamesSet = new Set();
99
- for (const rem of plan.removed) {
100
- const name = normalizeRequirementName(rem);
101
- if (removedNamesSet.has(name)) {
102
- throw new Error(`${specName} validation failed - duplicate requirement in REMOVED for header "### Requirement: ${rem}"`);
85
+ // The capability as it exists on disk, which differs on a case-insensitive
86
+ // filesystem: a delta under `specs/RegexCap/` resolves to the existing
87
+ // `tospec/specs/regexcap/`.
88
+ const onDiskSpecName = await resolveOnDiskSpecName(update.target, specName);
89
+ const collectUnique = (names, section) => {
90
+ const seen = new Set();
91
+ for (const original of names) {
92
+ const name = normalizeRequirementName(original);
93
+ if (seen.has(name)) {
94
+ throw new Error(`${specName} validation failed - duplicate requirement in ${section} for header "### Requirement: ${original}"`);
95
+ }
96
+ seen.add(name);
103
97
  }
104
- removedNamesSet.add(name);
105
- }
98
+ return seen;
99
+ };
100
+ const addedNames = collectUnique(plan.added.map((add) => add.name), 'ADDED');
101
+ const modifiedNames = collectUnique(plan.modified.map((mod) => mod.name), 'MODIFIED');
102
+ const removedNamesSet = collectUnique(plan.removed, 'REMOVED');
103
+ // Checked per entry rather than via collectUnique: a pair that duplicates
104
+ // both sides must report the FROM/TO the original interleaving would.
106
105
  const renamedFromSet = new Set();
107
106
  const renamedToSet = new Set();
108
107
  for (const { from, to } of plan.renamed) {
@@ -117,7 +116,6 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
117
116
  renamedFromSet.add(fromNorm);
118
117
  renamedToSet.add(toNorm);
119
118
  }
120
- // Pre-validate cross-section conflicts
121
119
  const conflicts = [];
122
120
  for (const n of modifiedNames) {
123
121
  if (removedNamesSet.has(n))
@@ -129,15 +127,12 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
129
127
  if (removedNamesSet.has(n))
130
128
  conflicts.push({ name: n, a: 'ADDED', b: 'REMOVED' });
131
129
  }
132
- // Renamed interplay: MODIFIED must reference the NEW header, not FROM
133
130
  for (const { from, to } of plan.renamed) {
134
131
  const fromNorm = normalizeRequirementName(from);
135
132
  const toNorm = normalizeRequirementName(to);
136
- // A REMOVED naming the FROM side contradicts the rename. Once a missing
137
- // REMOVED target is treated as a no-op (early-sync), this conflict must be
138
- // rejected explicitly instead of failing incidentally at apply time.
139
- // Compared folded, so a case/whitespace variant cannot slip past into a
140
- // warned no-op.
133
+ // A REMOVED naming the FROM side contradicts the rename. Since a missing
134
+ // REMOVED target is a no-op (early-sync), this must be rejected explicitly.
135
+ // Folded, so a case or whitespace variant cannot slip past as a no-op.
141
136
  const removedFoldMatch = [...removedNamesSet].find((r) => foldRequirementName(r) === foldRequirementName(fromNorm));
142
137
  if (removedFoldMatch !== undefined) {
143
138
  throw new Error(`${specName} validation failed - requirement present in multiple sections (RENAMED and REMOVED) for header "### Requirement: ${from}"` +
@@ -146,7 +141,6 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
146
141
  if (modifiedNames.has(fromNorm)) {
147
142
  throw new Error(`${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"`);
148
143
  }
149
- // Detect ADDED colliding with a RENAMED TO
150
144
  if (addedNames.has(toNorm)) {
151
145
  throw new Error(`${specName} validation failed - RENAMED TO header collides with ADDED for "### Requirement: ${to}"`);
152
146
  }
@@ -160,52 +154,55 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
160
154
  throw new Error(`Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` +
161
155
  `Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.`);
162
156
  }
163
- // Load or create base target content
164
157
  let targetContent;
165
158
  let isNewSpec = false;
166
159
  try {
167
160
  targetContent = await fs.readFile(update.target, 'utf-8');
168
161
  }
169
162
  catch (error) {
170
- // Only "the file is not there" means a new capability. Every other failure —
171
- // a permission denial, a device error, a directory where the file belongs —
172
- // says the spec may well exist with content this path is about to discard.
173
- // For an addition-only delta that is silent data loss: the new-capability
174
- // branch below accepts it and rebuilds the spec from a skeleton.
163
+ // Only "not there" means a new capability. Any other failure says the spec
164
+ // may exist with content the skeleton rebuild below would discard.
175
165
  if (!isMissingPathError(error))
176
166
  throw error;
177
- // Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
178
- // REMOVED will be ignored with a warning since there's nothing to remove
179
167
  if (plan.modified.length > 0 || plan.renamed.length > 0) {
180
168
  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.`);
181
169
  }
182
- // Warn about REMOVED requirements being ignored for new specs
183
- if (plan.removed.length > 0 && !options.silent) {
184
- console.log(`Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`);
170
+ if (plan.removed.length > 0) {
171
+ notice('archive_removed_ignored_new_spec', `${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`);
185
172
  }
186
173
  isNewSpec = true;
187
- // Carry the delta's authored Purpose into the new main spec instead of
188
- // overwriting it with the placeholder. Only plain prose is carried; a
189
- // Purpose that could restructure the spec falls back to the placeholder.
174
+ // Only plain prose is carried; a Purpose that could restructure the spec
175
+ // falls back to the placeholder.
190
176
  const deltaPurpose = extractDeltaPurpose(changeContent);
191
177
  if (deltaPurpose && isPurposeCarrySafe(deltaPurpose)) {
192
178
  targetContent = buildSpecSkeleton(specName, changeName, deltaPurpose);
193
179
  }
194
180
  else {
195
- if (deltaPurpose && !options.silent) {
196
- console.log(`Warning: ${specName} - delta Purpose ignored (it would leave the new spec unreadable); wrote the placeholder Purpose instead.`);
181
+ if (deltaPurpose) {
182
+ notice('archive_delta_purpose_ignored', `${specName} - delta Purpose ignored (it would leave the new spec unreadable); wrote the placeholder Purpose instead.`);
183
+ }
184
+ else {
185
+ // The schema calls `## Purpose` mandatory for a new capability but
186
+ // validation does not check it, so the placeholder reaches the merged
187
+ // spec as a literal "TBD". This is the last moment the change that owns
188
+ // the answer is still in hand.
189
+ notice('archive_purpose_placeholder', `${specName} - delta has no "## Purpose"; wrote a TBD placeholder into the new main spec. Edit tospec/specs/${specName}/spec.md to describe why this capability exists.`);
197
190
  }
198
191
  targetContent = buildSpecSkeleton(specName, changeName);
199
192
  }
200
193
  }
201
- // Record the target's ending convention here, on the raw bytes, because
202
- // everything downstream destroys it: extractRequirementsSection normalizes the
203
- // document to LF before parsing, normalizeBlockRaw does it again per block,
204
- // and the recompose joins with '\n'. Normalizing before parsing is correct —
205
- // every structural regex below assumes LF — so the fix is to carry the
206
- // convention forward, not to stop normalizing. A whole-file flag rather than
207
- // per-line endings: a merged spec contains lines that had no original, so
208
- // per-line fidelity is undefined for them.
194
+ // An existing capability's main spec owns its Purpose, so a delta's is
195
+ // dropped on this path — an author who wrote one must hear it went nowhere.
196
+ if (!isNewSpec) {
197
+ const strayPurpose = extractDeltaPurpose(changeContent);
198
+ if (strayPurpose) {
199
+ notice('archive_delta_purpose_ignored', `${specName} - delta "## Purpose" ignored; ${onDiskSpecName} already has a main spec and owns its Purpose. Edit tospec/specs/${onDiskSpecName}/spec.md directly if it needs changing.`);
200
+ }
201
+ }
202
+ // Recorded on the raw bytes because everything downstream normalizes to LF
203
+ // (every structural regex below assumes it), so the convention has to be
204
+ // carried forward. A whole-file flag, not per-line: a merged spec contains
205
+ // lines that had no original.
209
206
  const crlf = targetContent.includes('\r\n');
210
207
  const structureIssues = findMainSpecStructureIssues(targetContent);
211
208
  if (structureIssues.length > 0) {
@@ -214,27 +211,22 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
214
211
  .join('\n');
215
212
  throw new Error(`${specName}: target spec is structurally invalid and cannot be updated until fixed:\n${details}`);
216
213
  }
217
- // Extract requirements section and build name->block map
218
214
  const parts = extractRequirementsSection(targetContent);
219
215
  const nameToBlock = new Map();
220
216
  for (const block of parts.bodyBlocks) {
221
217
  nameToBlock.set(normalizeRequirementName(block.name), block);
222
218
  }
223
- // The lookup key for each original block, by position. RENAMED changes a
224
- // block's key, so `bodyBlocks` alone can no longer find it at recompose time;
225
- // this list carries the current key in the original slot. `bodyBlocks` stays
226
- // untouched because the salvage pass below reads the ORIGINAL raw content.
219
+ // The current key for each original block, by position: RENAMED changes a
220
+ // block's key, so `bodyBlocks` alone cannot find it at recompose time.
221
+ // `bodyBlocks` stays untouched because the salvage pass reads the original raw.
227
222
  const orderedKeys = parts.bodyBlocks.map((block) => normalizeRequirementName(block.name));
228
- // Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
229
- // RENAMED
223
+ // Order matters: RENAMED → REMOVED → MODIFIED → ADDED.
230
224
  let renamedApplied = 0;
231
225
  for (const r of plan.renamed) {
232
226
  const from = normalizeRequirementName(r.from);
233
227
  const to = normalizeRequirementName(r.to);
234
228
  if (!nameToBlock.has(from)) {
235
- // Source gone but target present means the rename was already synced to
236
- // the baseline (early-sync pattern) - re-applying it is a no-op, not a
237
- // failure. Only a missing source AND target is a genuine error.
229
+ // Source gone but target present: the rename was already synced.
238
230
  if (nameToBlock.has(to)) {
239
231
  continue;
240
232
  }
@@ -255,76 +247,88 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
255
247
  };
256
248
  nameToBlock.delete(from);
257
249
  nameToBlock.set(to, renamedBlock);
258
- // delete+set moves the entry to the Map's insertion-order tail, so a rename
259
- // would otherwise push the requirement to the end of the rebuilt spec.
260
- // Re-key it in place instead; a chain A->B->C updates the same slot twice.
250
+ // delete+set moves the entry to the Map's insertion-order tail, pushing the
251
+ // requirement to the end of the rebuilt spec. Re-key in place.
261
252
  const orderIndex = orderedKeys.indexOf(from);
262
253
  if (orderIndex >= 0) {
263
254
  orderedKeys[orderIndex] = to;
264
255
  }
265
256
  renamedApplied++;
266
257
  }
267
- // REMOVED
268
258
  let removedApplied = 0;
269
259
  // Keys this run actually removed. A renamed requirement also disappears from
270
- // its old key, so "absent from nameToBlock" alone cannot tell the two apart —
271
- // and salvaging a renamed block's tail would duplicate it.
260
+ // its old key, so "absent from nameToBlock" cannot tell the two apart, and
261
+ // salvaging a renamed block's tail would duplicate it.
272
262
  const removedKeys = new Set();
273
263
  for (const name of plan.removed) {
274
264
  const key = normalizeRequirementName(name);
275
265
  if (!nameToBlock.has(key)) {
276
- // Requirement gone from the baseline means the removal was already synced
277
- // (early-sync pattern) - re-applying it is a no-op, not a failure. One
278
- // signal separates that from a mistyped header: a requirement that differs
279
- // only in case or interior whitespace still being present. That is a typo,
280
- // and stays a hard abort. For new specs the skip was already warned above.
266
+ // Already gone means the removal was already synced. A near miss — one
267
+ // still present differing only in case or interior whitespace — is a typo
268
+ // instead, and stays a hard abort.
281
269
  if (!isNewSpec) {
282
270
  const nearMiss = [...nameToBlock.keys()].find((k) => foldRequirementName(k) === foldRequirementName(key));
283
271
  if (nearMiss !== undefined) {
284
272
  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`);
285
273
  }
286
- // ponytail: warning surfaced to humans only; JSON callers get no
287
- // warnings[] channel yet. Add one if agent flows need the signal.
288
- if (!options.silent) {
289
- console.log(`Warning: ${specName} - REMOVED requirement "${name}" is not in the current spec; treating it as already removed.`);
290
- }
274
+ notice('archive_removed_requirement_absent', `${specName} - REMOVED requirement "${name}" is not in the current spec; treating it as already removed. Nothing was deleted — if you meant to remove a requirement, check the header spelling against tospec/specs/${onDiskSpecName}/spec.md.`);
291
275
  }
292
- // Skip removal (nothing to remove)
293
276
  continue;
294
277
  }
295
278
  nameToBlock.delete(key);
296
279
  removedKeys.add(key);
297
280
  removedApplied++;
298
281
  }
299
- // MODIFIED
282
+ // Declarations are consumed as a MODIFIED omission matches them. A declaration
283
+ // still here afterwards removed nothing: the section grants no removal of its
284
+ // own, so without a matching omission the scenario stays in the spec while
285
+ // the change says it went. The validator reports the same leftover as an
286
+ // ERROR; re-checking here keeps `--no-validate` from archiving what validate
287
+ // refuses.
288
+ const unmatchedDeclarations = new Set(plan.removedScenarios);
300
289
  for (const mod of plan.modified) {
301
290
  const key = normalizeRequirementName(mod.name);
302
291
  const currentBlock = nameToBlock.get(key);
303
292
  if (!currentBlock) {
304
293
  throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`);
305
294
  }
306
- // Replace block with provided raw (ensure header line matches key)
307
- const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/i);
295
+ const modHeaderMatch = mod.raw.split('\n')[0].match(REQUIREMENT_HEADER_REGEX);
308
296
  if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
309
297
  throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`);
310
298
  }
311
- // Guard against a stale MODIFIED block silently dropping scenarios that a
312
- // previous archive already added to the current spec.
313
- const missingScenarios = findMissingScenarios(currentBlock.raw, mod.raw);
299
+ // Guards against a stale MODIFIED block dropping scenarios a previous
300
+ // archive added. `## REMOVED Scenarios` exempts them, read from the same
301
+ // declarations the validator uses so the two cannot disagree.
302
+ const declaredRemovals = new Set(plan.removedScenarios.filter((entry) => entry.requirement === key).map((entry) => entry.scenario));
303
+ const missingScenarios = findMissingScenarios(currentBlock.raw, mod.raw).filter((name) => {
304
+ if (!declaredRemovals.has(name))
305
+ return true;
306
+ for (const entry of unmatchedDeclarations) {
307
+ if (entry.requirement === key && entry.scenario === name)
308
+ unmatchedDeclarations.delete(entry);
309
+ }
310
+ return false;
311
+ });
314
312
  if (missingScenarios.length > 0) {
315
313
  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.`);
316
314
  }
317
- nameToBlock.set(key, mod);
315
+ // A plain `### Notes` after the requirement is absorbed into its block (see
316
+ // the recompose pass below), and replacing the block wholesale deleted it
317
+ // with the old body. Same salvage as REMOVED, appended after the new body
318
+ // so the section keeps its place.
319
+ const absorbed = salvageAbsorbedContent(currentBlock.raw);
320
+ nameToBlock.set(key, absorbed ? { ...mod, raw: `${mod.raw.trimEnd()}\n\n${absorbed}` } : mod);
321
+ }
322
+ for (const entry of unmatchedDeclarations) {
323
+ throw new Error(`${specName} REMOVED Scenarios declares "${entry.scenario}" under requirement "${entry.requirement}", but no MODIFIED block for that requirement drops that scenario - the declaration removes nothing on its own. Restate the requirement under "## MODIFIED Requirements" without that scenario, or check both names against the current spec.`);
318
324
  }
319
- // ADDED
320
325
  let addedApplied = 0;
321
326
  for (const add of plan.added) {
322
327
  const key = normalizeRequirementName(add.name);
323
328
  const existing = nameToBlock.get(key);
324
329
  if (existing) {
325
- // Identical content means the requirement was already synced to the
326
- // baseline (early-sync pattern) - re-applying it is a no-op, not a
327
- // conflict. Only differing content is a genuine collision.
330
+ // Identical content means it was already synced; only differing content
331
+ // is a genuine collision.
328
332
  if (normalizeBlockRaw(existing.raw) === normalizeBlockRaw(add.raw)) {
329
333
  continue;
330
334
  }
@@ -333,13 +337,10 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
333
337
  nameToBlock.set(key, add);
334
338
  addedApplied++;
335
339
  }
336
- // Duplicates within resulting map are implicitly prevented by key uniqueness.
337
- // Recompose requirements section preserving original ordering where possible.
338
- // A block's `raw` runs to the next header the parser RECOGNISES, so a heading
339
- // it does not — a plain `### Notes` — is absorbed into the requirement above
340
- // it. Removing that requirement used to delete the absorbed content with it,
341
- // silently: nothing counted it, so nothing warned, and the spec left behind
342
- // still validated. Salvage it back into place instead.
340
+ // Recompose, preserving original ordering. A block's `raw` runs to the next
341
+ // header the parser recognises, so one it does not — a plain `### Notes` — is
342
+ // absorbed into the requirement above, and removing that requirement would
343
+ // delete the absorbed content silently. Salvage it back into place instead.
343
344
  const pieces = [];
344
345
  const seen = new Set();
345
346
  let salvagedCount = 0;
@@ -363,7 +364,7 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
363
364
  }
364
365
  }
365
366
  }
366
- // Append any newly added that were not in original order
367
+ // Whatever was added rather than replacing an original slot.
367
368
  for (const [key, block] of nameToBlock.entries()) {
368
369
  if (!seen.has(key)) {
369
370
  pieces.push(block.raw);
@@ -371,8 +372,7 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
371
372
  }
372
373
  }
373
374
  // Every requirement is gone but absorbed content survived. Retiring would
374
- // delete that content along with the file, which is the silent loss this
375
- // salvage exists to prevent; writing it would produce a spec with no
375
+ // delete that content with the file; writing it would produce a spec with no
376
376
  // requirements, which cannot validate. Neither is safe to choose silently.
377
377
  if (keptRequirements === 0 && removedApplied > 0 && salvagedCount > 0) {
378
378
  throw new Error(`${specName}: removing every requirement would leave content that is not part of any requirement (e.g. a "### Notes" section). Move that content out of ${specName}/spec.md first, then archive again.`);
@@ -382,27 +382,21 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
382
382
  .concat(pieces)
383
383
  .join('\n\n')
384
384
  .trimEnd();
385
- // The blank lines around `## Requirements` belong to no slice: `before` and
386
- // `after` carry at most one boundary newline by construction, `reqBody` and
387
- // every block `raw` are trimEnd()ed. Joining with a bare '\n' therefore glued
388
- // the heading to the Purpose paragraph above it and the first requirement
389
- // below, so every archive rewrote a well-formatted spec into that shape.
390
- // Separate non-empty slices with one blank line, and end the file with
391
- // exactly one newline instead of inheriting whatever `after` happened to hold.
385
+ // Every slice is trimmed, so the blank lines around `## Requirements` belong
386
+ // to no slice: joining with a bare '\n' glues the heading to the Purpose above
387
+ // and the first requirement below.
392
388
  const rebuilt = collapseBlankRunsOutsideFences([parts.before.trimEnd(), parts.headerLine, reqBody, parts.after.trim()]
393
389
  .filter((s) => s !== '')
394
390
  .join('\n\n')).trimEnd() + '\n';
395
391
  return {
396
392
  rebuilt,
397
393
  crlf,
398
- // The capability has no requirements left and this run is what emptied it.
399
- // Both halves matter. "No requirements left" alone would delete a spec on a
400
- // re-run of an already-synced REMOVED, taking with it any requirement added
401
- // since; "removed something" alone would delete a spec that still has
402
- // requirements. An emptied spec cannot be written either way — it fails
403
- // validation ("Spec must have at least one requirement") — so the caller
404
- // retires the capability instead of writing an unusable file.
394
+ // Both halves matter: "no requirements left" alone would delete a spec on a
395
+ // re-run of an already-synced REMOVED; "removed something" alone would
396
+ // delete a spec that still has requirements. An emptied spec cannot be
397
+ // written (it fails "at least one requirement"), so the caller retires it.
405
398
  retired: keptRequirements === 0 && removedApplied > 0,
399
+ notices,
406
400
  counts: {
407
401
  added: addedApplied,
408
402
  modified: plan.modified.length,
@@ -412,18 +406,13 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
412
406
  };
413
407
  }
414
408
  function normalizeBlockRaw(raw) {
415
- return raw.replace(/\r\n?/g, '\n').trim();
409
+ return normalizeDocument(raw).trim();
416
410
  }
417
411
  /**
418
- * Collapse runs of blank lines to a single blank line, skipping fenced code.
419
- *
420
- * The collapse exists because the four slices joined above each carry their own
421
- * boundary newlines, so the seams between them accumulate blank lines. Applied
422
- * to the whole document as `replace(/\n{3,}/g, '\n\n')` it could not tell a seam
423
- * from a blank line an author put inside a code sample deliberately, so every
424
- * archive quietly edited the examples in a spec. The seams are all outside any
425
- * fence by construction, so masking fenced lines loses nothing the collapse was
426
- * written for.
412
+ * The slices joined above each carry boundary newlines, so their seams
413
+ * accumulate blank lines. A whole-document `replace(/\n{3,}/g, '\n\n')` cannot
414
+ * tell a seam from a deliberate blank line inside a code sample. Seams are all
415
+ * outside fences by construction, so masking loses nothing.
427
416
  */
428
417
  function collapseBlankRunsOutsideFences(content) {
429
418
  const lines = content.split('\n');
@@ -433,9 +422,8 @@ function collapseBlankRunsOutsideFences(content) {
433
422
  for (let i = 0; i < lines.length; i++) {
434
423
  if (!mask[i] && lines[i].trim() === '') {
435
424
  const last = kept.length - 1;
436
- // Drop this blank only if the previous kept line is a blank that was also
437
- // outside a fence — a blank line inside a fence is content, and it must
438
- // not license dropping the one that follows it.
425
+ // The previous blank must also be outside a fence: a blank inside one is
426
+ // content, and must not license dropping the line that follows it.
439
427
  if (last >= 0 && !keptMask[last] && kept[last].trim() === '')
440
428
  continue;
441
429
  }
@@ -444,16 +432,11 @@ function collapseBlankRunsOutsideFences(content) {
444
432
  }
445
433
  return kept.join('\n');
446
434
  }
447
- /**
448
- * Write an updated spec to disk.
449
- */
450
435
  export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
451
- // Create target directory if needed
452
436
  const targetDir = path.dirname(update.target);
453
437
  await fs.mkdir(targetDir, { recursive: true });
454
- // `rebuilt` is pure LF by construction; restore the convention the target had
455
- // before the merge so archiving a CRLF spec does not produce a whole-file diff
456
- // on Windows. A brand new spec has no prior convention and stays LF.
438
+ // `rebuilt` is pure LF by construction; restore the target's pre-merge
439
+ // convention so archiving a CRLF spec is not a whole-file diff on Windows.
457
440
  const content = options.crlf ? rebuilt.replace(/\r?\n/g, '\r\n') : rebuilt;
458
441
  await fs.writeFile(update.target, content);
459
442
  if (options.silent)
@@ -471,19 +454,15 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
471
454
  }
472
455
  /**
473
456
  * The tail of a requirement block that is not part of the requirement: content
474
- * from the first `#`/`##`/`###` heading after the block's own header line.
475
- *
476
- * `####` is excluded deliberately — a requirement's `#### Scenario:` headings
477
- * are its own and go with it when it is removed.
457
+ * from the first `#`/`##`/`###` heading after its own header line. `####` is
458
+ * excluded — a requirement's `#### Scenario:` headings go with it.
478
459
  *
479
- * The alternative was to widen every heading pattern in the parsers so these
480
- * headings end the block properly. That was rejected: it reclassifies content,
481
- * so commented-out or indented examples start parsing as real requirements and
482
- * a spec that was valid becomes invalid. Salvaging on removal changes what a
483
- * removal deletes without changing what anything parses.
460
+ * Widening the parsers' heading patterns instead was rejected: it reclassifies
461
+ * content, so commented-out or indented examples begin parsing as requirements.
462
+ * Salvaging changes only what a removal deletes, not what anything parses.
484
463
  */
485
464
  function salvageAbsorbedContent(requirementRaw) {
486
- const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n');
465
+ const lines = normalizeDocument(requirementRaw).split('\n');
487
466
  const mask = buildCodeFenceMask(lines);
488
467
  for (let i = 1; i < lines.length; i++) {
489
468
  if (mask[i])
@@ -495,12 +474,9 @@ function salvageAbsorbedContent(requirementRaw) {
495
474
  return '';
496
475
  }
497
476
  /**
498
- * Retire a capability the change emptied: delete its `spec.md`, then remove any
499
- * directory the deletion left empty.
500
- *
501
- * The pruning walks up from the spec file and stops at `specsRoot`, which is
502
- * never removed even if it ends up empty — a project with no capabilities still
503
- * has a specs directory. It also stops at the first non-empty directory, so a
477
+ * Delete an emptied capability's `spec.md` and prune the directories that
478
+ * leaves empty. Pruning stops at `specsRoot` (a project with no capabilities
479
+ * still has a specs directory) and at the first non-empty directory, so a
504
480
  * sibling capability is never touched.
505
481
  */
506
482
  export async function retireSpec(update, specsRoot, options = {}) {
@@ -525,9 +501,8 @@ export async function retireSpec(update, specsRoot, options = {}) {
525
501
  console.log(`Retiring ${options.displayPath ?? `tospec/specs/${specName}/spec.md`}: no requirements left`);
526
502
  }
527
503
  /**
528
- * Build a skeleton spec for new capabilities. Carries the delta's authored
529
- * Purpose when one is supplied; otherwise writes the TBD placeholder — composed
530
- * from the constants validation recognises it by, so the two cannot drift.
504
+ * Skeleton spec for a new capability. The TBD placeholder is composed from the
505
+ * constants validation recognises it by, so the two cannot drift.
531
506
  */
532
507
  export function buildSpecSkeleton(specFolderName, changeName, purpose) {
533
508
  const titleBase = specFolderName;
@@ -541,8 +516,10 @@ export function buildSpecSkeleton(specFolderName, changeName, purpose) {
541
516
  * is none. Fence-masked so a `## Purpose` inside a code example is not mistaken
542
517
  * for the real section.
543
518
  */
544
- function extractDeltaPurpose(content) {
545
- const lines = content.replace(/\r\n?/g, '\n').split('\n');
519
+ export function extractDeltaPurpose(content) {
520
+ // normalizeDocument, not a hand-rolled newline collapse: it also strips the
521
+ // BOM, without which `<BOM>## Purpose` misses the `^##` anchor.
522
+ const lines = normalizeDocument(content).split('\n');
546
523
  const mask = buildCodeFenceMask(lines);
547
524
  let start = -1;
548
525
  for (let i = 0; i < lines.length; i++) {
@@ -567,13 +544,9 @@ function extractDeltaPurpose(content) {
567
544
  return lines.slice(start, end).join('\n').trim();
568
545
  }
569
546
  /**
570
- * A carried Purpose must not restructure the new spec. Headings, HTML comments,
571
- * and code fences each have ways to corrupt or hide the rest of the spec, so a
572
- * Purpose containing any of them falls back to the placeholder rather than risk
573
- * it.
574
- * ponytail: prose-only heuristic; a Purpose that genuinely needs a fence or
575
- * heading falls back to TBD. Loosen only once those corruption classes are
576
- * handled the way archive's own validator reads the spec.
547
+ * A carried Purpose must not restructure the new spec: headings, HTML comments
548
+ * and code fences can each corrupt or hide the rest of it, so any of them falls
549
+ * back to the placeholder.
577
550
  */
578
551
  function isPurposeCarrySafe(body) {
579
552
  if (!body)