@seanmars/tospec 0.19.0-beta.0 → 0.19.0-beta.13

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 (431) hide show
  1. package/CHANGELOG.md +578 -0
  2. package/README.md +71 -77
  3. package/assets/dashboard/app.js +14 -3
  4. package/assets/dashboard/style.css +7 -0
  5. package/assets/metrics/app.js +22 -0
  6. package/assets/metrics/style.css +8 -0
  7. package/assets/rules/tospec/decision.md +3 -0
  8. package/assets/rules/tospec/single-source-of-truth.md +19 -0
  9. package/dist/cli/index.d.ts.map +1 -1
  10. package/dist/cli/index.js +266 -82
  11. package/dist/cli/index.js.map +1 -1
  12. package/dist/commands/config.d.ts +9 -17
  13. package/dist/commands/config.d.ts.map +1 -1
  14. package/dist/commands/config.js +374 -95
  15. package/dist/commands/config.js.map +1 -1
  16. package/dist/commands/dashboard.d.ts +64 -81
  17. package/dist/commands/dashboard.d.ts.map +1 -1
  18. package/dist/commands/dashboard.js +350 -218
  19. package/dist/commands/dashboard.js.map +1 -1
  20. package/dist/commands/decision.d.ts +41 -19
  21. package/dist/commands/decision.d.ts.map +1 -1
  22. package/dist/commands/decision.js +400 -70
  23. package/dist/commands/decision.js.map +1 -1
  24. package/dist/commands/metrics.d.ts +34 -48
  25. package/dist/commands/metrics.d.ts.map +1 -1
  26. package/dist/commands/metrics.js +74 -84
  27. package/dist/commands/metrics.js.map +1 -1
  28. package/dist/commands/shared-output.d.ts +24 -10
  29. package/dist/commands/shared-output.d.ts.map +1 -1
  30. package/dist/commands/shared-output.js +62 -11
  31. package/dist/commands/shared-output.js.map +1 -1
  32. package/dist/commands/show.d.ts +7 -0
  33. package/dist/commands/show.d.ts.map +1 -1
  34. package/dist/commands/show.js +39 -8
  35. package/dist/commands/show.js.map +1 -1
  36. package/dist/commands/validate.d.ts +47 -30
  37. package/dist/commands/validate.d.ts.map +1 -1
  38. package/dist/commands/validate.js +282 -108
  39. package/dist/commands/validate.js.map +1 -1
  40. package/dist/commands/workflow/index.d.ts +6 -10
  41. package/dist/commands/workflow/index.d.ts.map +1 -1
  42. package/dist/commands/workflow/index.js +6 -10
  43. package/dist/commands/workflow/index.js.map +1 -1
  44. package/dist/commands/workflow/instructions.d.ts +21 -8
  45. package/dist/commands/workflow/instructions.d.ts.map +1 -1
  46. package/dist/commands/workflow/instructions.js +254 -95
  47. package/dist/commands/workflow/instructions.js.map +1 -1
  48. package/dist/commands/workflow/new-change.d.ts +4 -5
  49. package/dist/commands/workflow/new-change.d.ts.map +1 -1
  50. package/dist/commands/workflow/new-change.js +90 -25
  51. package/dist/commands/workflow/new-change.js.map +1 -1
  52. package/dist/commands/workflow/schemas.d.ts +3 -5
  53. package/dist/commands/workflow/schemas.d.ts.map +1 -1
  54. package/dist/commands/workflow/schemas.js +37 -11
  55. package/dist/commands/workflow/schemas.js.map +1 -1
  56. package/dist/commands/workflow/shared.d.ts +48 -21
  57. package/dist/commands/workflow/shared.d.ts.map +1 -1
  58. package/dist/commands/workflow/shared.js +36 -33
  59. package/dist/commands/workflow/shared.js.map +1 -1
  60. package/dist/commands/workflow/status.d.ts +10 -6
  61. package/dist/commands/workflow/status.d.ts.map +1 -1
  62. package/dist/commands/workflow/status.js +84 -42
  63. package/dist/commands/workflow/status.js.map +1 -1
  64. package/dist/commands/workflow/templates.d.ts +10 -3
  65. package/dist/commands/workflow/templates.d.ts.map +1 -1
  66. package/dist/commands/workflow/templates.js +39 -40
  67. package/dist/commands/workflow/templates.js.map +1 -1
  68. package/dist/core/archive.d.ts +26 -21
  69. package/dist/core/archive.d.ts.map +1 -1
  70. package/dist/core/archive.js +415 -201
  71. package/dist/core/archive.js.map +1 -1
  72. package/dist/core/artifact-graph/graph.d.ts +29 -36
  73. package/dist/core/artifact-graph/graph.d.ts.map +1 -1
  74. package/dist/core/artifact-graph/graph.js +50 -58
  75. package/dist/core/artifact-graph/graph.js.map +1 -1
  76. package/dist/core/artifact-graph/index.d.ts +2 -2
  77. package/dist/core/artifact-graph/index.d.ts.map +1 -1
  78. package/dist/core/artifact-graph/index.js +2 -2
  79. package/dist/core/artifact-graph/index.js.map +1 -1
  80. package/dist/core/artifact-graph/instruction-loader.d.ts +105 -100
  81. package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
  82. package/dist/core/artifact-graph/instruction-loader.js +176 -110
  83. package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
  84. package/dist/core/artifact-graph/outputs.d.ts +13 -6
  85. package/dist/core/artifact-graph/outputs.d.ts.map +1 -1
  86. package/dist/core/artifact-graph/outputs.js +146 -8
  87. package/dist/core/artifact-graph/outputs.js.map +1 -1
  88. package/dist/core/artifact-graph/resolver.d.ts +44 -63
  89. package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
  90. package/dist/core/artifact-graph/resolver.js +85 -86
  91. package/dist/core/artifact-graph/resolver.js.map +1 -1
  92. package/dist/core/artifact-graph/schema.d.ts +0 -6
  93. package/dist/core/artifact-graph/schema.d.ts.map +1 -1
  94. package/dist/core/artifact-graph/schema.js +7 -32
  95. package/dist/core/artifact-graph/schema.js.map +1 -1
  96. package/dist/core/artifact-graph/state.d.ts +1 -8
  97. package/dist/core/artifact-graph/state.d.ts.map +1 -1
  98. package/dist/core/artifact-graph/state.js +2 -17
  99. package/dist/core/artifact-graph/state.js.map +1 -1
  100. package/dist/core/artifact-graph/stub-detection.d.ts +12 -0
  101. package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -0
  102. package/dist/core/artifact-graph/stub-detection.js +39 -0
  103. package/dist/core/artifact-graph/stub-detection.js.map +1 -0
  104. package/dist/core/artifact-graph/types.d.ts +4 -0
  105. package/dist/core/artifact-graph/types.d.ts.map +1 -1
  106. package/dist/core/artifact-graph/types.js +30 -10
  107. package/dist/core/artifact-graph/types.js.map +1 -1
  108. package/dist/core/available-tools.d.ts +3 -12
  109. package/dist/core/available-tools.d.ts.map +1 -1
  110. package/dist/core/available-tools.js +4 -13
  111. package/dist/core/available-tools.js.map +1 -1
  112. package/dist/core/change-metadata/schema.d.ts +1 -1
  113. package/dist/core/change-metadata/schema.d.ts.map +1 -1
  114. package/dist/core/change-metadata/schema.js +10 -7
  115. package/dist/core/change-metadata/schema.js.map +1 -1
  116. package/dist/core/change-presenter.d.ts +23 -18
  117. package/dist/core/change-presenter.d.ts.map +1 -1
  118. package/dist/core/change-presenter.js +102 -43
  119. package/dist/core/change-presenter.js.map +1 -1
  120. package/dist/core/change-status-policy.d.ts +8 -1
  121. package/dist/core/change-status-policy.d.ts.map +1 -1
  122. package/dist/core/change-status-policy.js +25 -1
  123. package/dist/core/change-status-policy.js.map +1 -1
  124. package/dist/core/codex-metrics.d.ts +25 -45
  125. package/dist/core/codex-metrics.d.ts.map +1 -1
  126. package/dist/core/codex-metrics.js +44 -88
  127. package/dist/core/codex-metrics.js.map +1 -1
  128. package/dist/core/codex-residue.d.ts +22 -0
  129. package/dist/core/codex-residue.d.ts.map +1 -0
  130. package/dist/core/codex-residue.js +61 -0
  131. package/dist/core/codex-residue.js.map +1 -0
  132. package/dist/core/command-generation/adapters/claude.d.ts +2 -9
  133. package/dist/core/command-generation/adapters/claude.d.ts.map +1 -1
  134. package/dist/core/command-generation/adapters/claude.js +2 -12
  135. package/dist/core/command-generation/adapters/claude.js.map +1 -1
  136. package/dist/core/command-generation/adapters/index.d.ts +1 -9
  137. package/dist/core/command-generation/adapters/index.d.ts.map +1 -1
  138. package/dist/core/command-generation/adapters/index.js +1 -9
  139. package/dist/core/command-generation/adapters/index.js.map +1 -1
  140. package/dist/core/command-generation/generator.d.ts +0 -17
  141. package/dist/core/command-generation/generator.d.ts.map +1 -1
  142. package/dist/core/command-generation/generator.js +0 -17
  143. package/dist/core/command-generation/generator.js.map +1 -1
  144. package/dist/core/command-generation/index.d.ts +2 -5
  145. package/dist/core/command-generation/index.d.ts.map +1 -1
  146. package/dist/core/command-generation/index.js +0 -9
  147. package/dist/core/command-generation/index.js.map +1 -1
  148. package/dist/core/command-generation/types.d.ts +10 -36
  149. package/dist/core/command-generation/types.d.ts.map +1 -1
  150. package/dist/core/command-generation/types.js +0 -6
  151. package/dist/core/command-generation/types.js.map +1 -1
  152. package/dist/core/command-generation/yaml.d.ts +3 -18
  153. package/dist/core/command-generation/yaml.d.ts.map +1 -1
  154. package/dist/core/command-generation/yaml.js +5 -23
  155. package/dist/core/command-generation/yaml.js.map +1 -1
  156. package/dist/core/config-prompts.d.ts +2 -4
  157. package/dist/core/config-prompts.d.ts.map +1 -1
  158. package/dist/core/config-prompts.js +2 -7
  159. package/dist/core/config-prompts.js.map +1 -1
  160. package/dist/core/config-schema.d.ts +8 -53
  161. package/dist/core/config-schema.d.ts.map +1 -1
  162. package/dist/core/config-schema.js +49 -62
  163. package/dist/core/config-schema.js.map +1 -1
  164. package/dist/core/config.d.ts +56 -0
  165. package/dist/core/config.d.ts.map +1 -1
  166. package/dist/core/config.js +73 -2
  167. package/dist/core/config.js.map +1 -1
  168. package/dist/core/converters/json-converter.d.ts.map +1 -1
  169. package/dist/core/dashboard-activity.d.ts +7 -9
  170. package/dist/core/dashboard-activity.d.ts.map +1 -1
  171. package/dist/core/dashboard-activity.js +26 -24
  172. package/dist/core/dashboard-activity.js.map +1 -1
  173. package/dist/core/dashboard-data.d.ts +40 -22
  174. package/dist/core/dashboard-data.d.ts.map +1 -1
  175. package/dist/core/dashboard-data.js +84 -68
  176. package/dist/core/dashboard-data.js.map +1 -1
  177. package/dist/core/global-config.d.ts +24 -53
  178. package/dist/core/global-config.d.ts.map +1 -1
  179. package/dist/core/global-config.js +43 -62
  180. package/dist/core/global-config.js.map +1 -1
  181. package/dist/core/init.d.ts +29 -11
  182. package/dist/core/init.d.ts.map +1 -1
  183. package/dist/core/init.js +232 -164
  184. package/dist/core/init.js.map +1 -1
  185. package/dist/core/list.d.ts +1 -1
  186. package/dist/core/list.d.ts.map +1 -1
  187. package/dist/core/list.js +121 -28
  188. package/dist/core/list.js.map +1 -1
  189. package/dist/core/local-server.d.ts +63 -39
  190. package/dist/core/local-server.d.ts.map +1 -1
  191. package/dist/core/local-server.js +99 -53
  192. package/dist/core/local-server.js.map +1 -1
  193. package/dist/core/markdown-render.d.ts +25 -0
  194. package/dist/core/markdown-render.d.ts.map +1 -0
  195. package/dist/core/markdown-render.js +94 -0
  196. package/dist/core/markdown-render.js.map +1 -0
  197. package/dist/core/migrate.d.ts +32 -15
  198. package/dist/core/migrate.d.ts.map +1 -1
  199. package/dist/core/migrate.js +220 -108
  200. package/dist/core/migrate.js.map +1 -1
  201. package/dist/core/parsers/change-parser.d.ts +7 -10
  202. package/dist/core/parsers/change-parser.d.ts.map +1 -1
  203. package/dist/core/parsers/change-parser.js +48 -56
  204. package/dist/core/parsers/change-parser.js.map +1 -1
  205. package/dist/core/parsers/markdown-parser.d.ts +8 -9
  206. package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
  207. package/dist/core/parsers/markdown-parser.js +31 -22
  208. package/dist/core/parsers/markdown-parser.js.map +1 -1
  209. package/dist/core/parsers/requirement-blocks.d.ts +53 -11
  210. package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
  211. package/dist/core/parsers/requirement-blocks.js +200 -60
  212. package/dist/core/parsers/requirement-blocks.js.map +1 -1
  213. package/dist/core/parsers/requirement-text.d.ts +73 -79
  214. package/dist/core/parsers/requirement-text.d.ts.map +1 -1
  215. package/dist/core/parsers/requirement-text.js +137 -79
  216. package/dist/core/parsers/requirement-text.js.map +1 -1
  217. package/dist/core/parsers/spec-structure.d.ts +1 -1
  218. package/dist/core/parsers/spec-structure.d.ts.map +1 -1
  219. package/dist/core/parsers/spec-structure.js +30 -3
  220. package/dist/core/parsers/spec-structure.js.map +1 -1
  221. package/dist/core/planning-home.js.map +1 -1
  222. package/dist/core/profiles.d.ts +3 -10
  223. package/dist/core/profiles.d.ts.map +1 -1
  224. package/dist/core/profiles.js +5 -12
  225. package/dist/core/profiles.js.map +1 -1
  226. package/dist/core/project-config.d.ts +43 -44
  227. package/dist/core/project-config.d.ts.map +1 -1
  228. package/dist/core/project-config.js +107 -82
  229. package/dist/core/project-config.js.map +1 -1
  230. package/dist/core/project-layout.d.ts +10 -18
  231. package/dist/core/project-layout.d.ts.map +1 -1
  232. package/dist/core/project-layout.js +16 -26
  233. package/dist/core/project-layout.js.map +1 -1
  234. package/dist/core/root-selection.d.ts +11 -7
  235. package/dist/core/root-selection.d.ts.map +1 -1
  236. package/dist/core/root-selection.js +7 -8
  237. package/dist/core/root-selection.js.map +1 -1
  238. package/dist/core/rules.d.ts +10 -0
  239. package/dist/core/rules.d.ts.map +1 -0
  240. package/dist/core/rules.js +43 -0
  241. package/dist/core/rules.js.map +1 -0
  242. package/dist/core/schema-names.d.ts +16 -0
  243. package/dist/core/schema-names.d.ts.map +1 -0
  244. package/dist/core/schema-names.js +16 -0
  245. package/dist/core/schema-names.js.map +1 -0
  246. package/dist/core/schemas/base.schema.d.ts +3 -0
  247. package/dist/core/schemas/base.schema.d.ts.map +1 -1
  248. package/dist/core/schemas/base.schema.js +22 -6
  249. package/dist/core/schemas/base.schema.js.map +1 -1
  250. package/dist/core/schemas/change.schema.d.ts +16 -0
  251. package/dist/core/schemas/change.schema.d.ts.map +1 -1
  252. package/dist/core/schemas/change.schema.js +41 -10
  253. package/dist/core/schemas/change.schema.js.map +1 -1
  254. package/dist/core/schemas/spec.schema.d.ts +2 -0
  255. package/dist/core/schemas/spec.schema.d.ts.map +1 -1
  256. package/dist/core/shared/index.d.ts +3 -8
  257. package/dist/core/shared/index.d.ts.map +1 -1
  258. package/dist/core/shared/index.js +3 -8
  259. package/dist/core/shared/index.js.map +1 -1
  260. package/dist/core/shared/rules-generation.d.ts +27 -8
  261. package/dist/core/shared/rules-generation.d.ts.map +1 -1
  262. package/dist/core/shared/rules-generation.js +151 -16
  263. package/dist/core/shared/rules-generation.js.map +1 -1
  264. package/dist/core/shared/skill-generation.d.ts +38 -53
  265. package/dist/core/shared/skill-generation.d.ts.map +1 -1
  266. package/dist/core/shared/skill-generation.js +82 -51
  267. package/dist/core/shared/skill-generation.js.map +1 -1
  268. package/dist/core/shared/tool-detection.d.ts +40 -62
  269. package/dist/core/shared/tool-detection.d.ts.map +1 -1
  270. package/dist/core/shared/tool-detection.js +88 -80
  271. package/dist/core/shared/tool-detection.js.map +1 -1
  272. package/dist/core/skill-metrics.d.ts +36 -63
  273. package/dist/core/skill-metrics.d.ts.map +1 -1
  274. package/dist/core/skill-metrics.js +34 -73
  275. package/dist/core/skill-metrics.js.map +1 -1
  276. package/dist/core/spec-presenter.d.ts.map +1 -1
  277. package/dist/core/spec-presenter.js +6 -6
  278. package/dist/core/spec-presenter.js.map +1 -1
  279. package/dist/core/specs-apply.d.ts +24 -23
  280. package/dist/core/specs-apply.d.ts.map +1 -1
  281. package/dist/core/specs-apply.js +188 -173
  282. package/dist/core/specs-apply.js.map +1 -1
  283. package/dist/core/templates/fragments/interview.d.ts +2 -6
  284. package/dist/core/templates/fragments/interview.d.ts.map +1 -1
  285. package/dist/core/templates/fragments/interview.js +2 -6
  286. package/dist/core/templates/fragments/interview.js.map +1 -1
  287. package/dist/core/templates/fragments/next-step.d.ts +4 -8
  288. package/dist/core/templates/fragments/next-step.d.ts.map +1 -1
  289. package/dist/core/templates/fragments/next-step.js +4 -8
  290. package/dist/core/templates/fragments/next-step.js.map +1 -1
  291. package/dist/core/templates/fragments/verify.d.ts +9 -12
  292. package/dist/core/templates/fragments/verify.d.ts.map +1 -1
  293. package/dist/core/templates/fragments/verify.js +9 -12
  294. package/dist/core/templates/fragments/verify.js.map +1 -1
  295. package/dist/core/templates/index.d.ts +0 -6
  296. package/dist/core/templates/index.d.ts.map +1 -1
  297. package/dist/core/templates/index.js +0 -7
  298. package/dist/core/templates/index.js.map +1 -1
  299. package/dist/core/templates/skill-templates.d.ts +1 -5
  300. package/dist/core/templates/skill-templates.d.ts.map +1 -1
  301. package/dist/core/templates/skill-templates.js +0 -5
  302. package/dist/core/templates/skill-templates.js.map +1 -1
  303. package/dist/core/templates/types.d.ts +3 -7
  304. package/dist/core/templates/types.d.ts.map +1 -1
  305. package/dist/core/templates/types.js +0 -3
  306. package/dist/core/templates/types.js.map +1 -1
  307. package/dist/core/templates/workflows/apply.d.ts +3 -9
  308. package/dist/core/templates/workflows/apply.d.ts.map +1 -1
  309. package/dist/core/templates/workflows/apply.js +9 -12
  310. package/dist/core/templates/workflows/apply.js.map +1 -1
  311. package/dist/core/templates/workflows/archive.d.ts +0 -6
  312. package/dist/core/templates/workflows/archive.d.ts.map +1 -1
  313. package/dist/core/templates/workflows/archive.js +7 -5
  314. package/dist/core/templates/workflows/archive.js.map +1 -1
  315. package/dist/core/templates/workflows/decision.js +4 -4
  316. package/dist/core/templates/workflows/decision.js.map +1 -1
  317. package/dist/core/templates/workflows/explore.js +1 -1
  318. package/dist/core/templates/workflows/grill.d.ts.map +1 -1
  319. package/dist/core/templates/workflows/grill.js +0 -2
  320. package/dist/core/templates/workflows/grill.js.map +1 -1
  321. package/dist/core/templates/workflows/issue.d.ts +0 -6
  322. package/dist/core/templates/workflows/issue.d.ts.map +1 -1
  323. package/dist/core/templates/workflows/issue.js +3 -0
  324. package/dist/core/templates/workflows/issue.js.map +1 -1
  325. package/dist/core/templates/workflows/propose.d.ts +0 -6
  326. package/dist/core/templates/workflows/propose.d.ts.map +1 -1
  327. package/dist/core/templates/workflows/propose.js +0 -1
  328. package/dist/core/templates/workflows/propose.js.map +1 -1
  329. package/dist/core/templates/workflows/sync.d.ts +2 -8
  330. package/dist/core/templates/workflows/sync.d.ts.map +1 -1
  331. package/dist/core/templates/workflows/sync.js +2 -2
  332. package/dist/core/templates/workflows/sync.js.map +1 -1
  333. package/dist/core/templates/workflows/update.d.ts +0 -6
  334. package/dist/core/templates/workflows/update.d.ts.map +1 -1
  335. package/dist/core/templates/workflows/update.js.map +1 -1
  336. package/dist/core/update.d.ts +26 -21
  337. package/dist/core/update.d.ts.map +1 -1
  338. package/dist/core/update.js +165 -116
  339. package/dist/core/update.js.map +1 -1
  340. package/dist/core/user-state-migration.d.ts +13 -15
  341. package/dist/core/user-state-migration.d.ts.map +1 -1
  342. package/dist/core/user-state-migration.js +16 -20
  343. package/dist/core/user-state-migration.js.map +1 -1
  344. package/dist/core/validation/constants.d.ts +19 -25
  345. package/dist/core/validation/constants.d.ts.map +1 -1
  346. package/dist/core/validation/constants.js +25 -20
  347. package/dist/core/validation/constants.js.map +1 -1
  348. package/dist/core/validation/prose-length.d.ts +15 -0
  349. package/dist/core/validation/prose-length.d.ts.map +1 -0
  350. package/dist/core/validation/prose-length.js +29 -0
  351. package/dist/core/validation/prose-length.js.map +1 -0
  352. package/dist/core/validation/purpose-placeholder.d.ts +9 -16
  353. package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
  354. package/dist/core/validation/purpose-placeholder.js +30 -44
  355. package/dist/core/validation/purpose-placeholder.js.map +1 -1
  356. package/dist/core/validation/section-validator.d.ts +4 -4
  357. package/dist/core/validation/section-validator.d.ts.map +1 -1
  358. package/dist/core/validation/section-validator.js +43 -7
  359. package/dist/core/validation/section-validator.js.map +1 -1
  360. package/dist/core/validation/task-numbering.d.ts +6 -3
  361. package/dist/core/validation/task-numbering.d.ts.map +1 -1
  362. package/dist/core/validation/task-numbering.js +23 -11
  363. package/dist/core/validation/task-numbering.js.map +1 -1
  364. package/dist/core/validation/types.d.ts +18 -0
  365. package/dist/core/validation/types.d.ts.map +1 -1
  366. package/dist/core/validation/types.js +12 -1
  367. package/dist/core/validation/types.js.map +1 -1
  368. package/dist/core/validation/validator.d.ts +50 -51
  369. package/dist/core/validation/validator.d.ts.map +1 -1
  370. package/dist/core/validation/validator.js +486 -263
  371. package/dist/core/validation/validator.js.map +1 -1
  372. package/dist/prompts/searchable-multi-select.d.ts +3 -8
  373. package/dist/prompts/searchable-multi-select.d.ts.map +1 -1
  374. package/dist/prompts/searchable-multi-select.js +16 -39
  375. package/dist/prompts/searchable-multi-select.js.map +1 -1
  376. package/dist/utils/change-metadata.d.ts +11 -50
  377. package/dist/utils/change-metadata.d.ts.map +1 -1
  378. package/dist/utils/change-metadata.js +48 -67
  379. package/dist/utils/change-metadata.js.map +1 -1
  380. package/dist/utils/change-utils.d.ts +31 -54
  381. package/dist/utils/change-utils.d.ts.map +1 -1
  382. package/dist/utils/change-utils.js +143 -100
  383. package/dist/utils/change-utils.js.map +1 -1
  384. package/dist/utils/file-lock.d.ts +39 -0
  385. package/dist/utils/file-lock.d.ts.map +1 -0
  386. package/dist/utils/file-lock.js +149 -0
  387. package/dist/utils/file-lock.js.map +1 -0
  388. package/dist/utils/file-system.d.ts +12 -32
  389. package/dist/utils/file-system.d.ts.map +1 -1
  390. package/dist/utils/file-system.js +16 -40
  391. package/dist/utils/file-system.js.map +1 -1
  392. package/dist/utils/frontmatter.d.ts +7 -11
  393. package/dist/utils/frontmatter.d.ts.map +1 -1
  394. package/dist/utils/frontmatter.js +11 -11
  395. package/dist/utils/frontmatter.js.map +1 -1
  396. package/dist/utils/interactive.d.ts +4 -9
  397. package/dist/utils/interactive.d.ts.map +1 -1
  398. package/dist/utils/interactive.js +2 -4
  399. package/dist/utils/interactive.js.map +1 -1
  400. package/dist/utils/item-discovery.d.ts +15 -10
  401. package/dist/utils/item-discovery.d.ts.map +1 -1
  402. package/dist/utils/item-discovery.js +42 -47
  403. package/dist/utils/item-discovery.js.map +1 -1
  404. package/dist/utils/link.d.ts +13 -4
  405. package/dist/utils/link.d.ts.map +1 -1
  406. package/dist/utils/link.js +13 -4
  407. package/dist/utils/link.js.map +1 -1
  408. package/dist/utils/match.js.map +1 -1
  409. package/dist/utils/requirement-diff.d.ts +13 -23
  410. package/dist/utils/requirement-diff.d.ts.map +1 -1
  411. package/dist/utils/requirement-diff.js +13 -23
  412. package/dist/utils/requirement-diff.js.map +1 -1
  413. package/dist/utils/spec-files.d.ts +10 -11
  414. package/dist/utils/spec-files.d.ts.map +1 -1
  415. package/dist/utils/spec-files.js +31 -22
  416. package/dist/utils/spec-files.js.map +1 -1
  417. package/dist/utils/task-progress.d.ts +11 -9
  418. package/dist/utils/task-progress.d.ts.map +1 -1
  419. package/dist/utils/task-progress.js +53 -32
  420. package/dist/utils/task-progress.js.map +1 -1
  421. package/dist/utils/timestamp.d.ts +5 -8
  422. package/dist/utils/timestamp.d.ts.map +1 -1
  423. package/dist/utils/timestamp.js +5 -8
  424. package/dist/utils/timestamp.js.map +1 -1
  425. package/package.json +9 -10
  426. package/schemas/decision/templates/decision.md +3 -1
  427. package/schemas/decision/templates/index.md +2 -2
  428. package/schemas/issue/schema.yaml +11 -2
  429. package/schemas/issue/templates/spec.md +37 -3
  430. package/schemas/sdd/schema.yaml +24 -1
  431. package/schemas/sdd/templates/spec.md +37 -3
@@ -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,44 +154,56 @@ 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
  }
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.
206
+ const crlf = targetContent.includes('\r\n');
201
207
  const structureIssues = findMainSpecStructureIssues(targetContent);
202
208
  if (structureIssues.length > 0) {
203
209
  const details = structureIssues
@@ -205,27 +211,22 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
205
211
  .join('\n');
206
212
  throw new Error(`${specName}: target spec is structurally invalid and cannot be updated until fixed:\n${details}`);
207
213
  }
208
- // Extract requirements section and build name->block map
209
214
  const parts = extractRequirementsSection(targetContent);
210
215
  const nameToBlock = new Map();
211
216
  for (const block of parts.bodyBlocks) {
212
217
  nameToBlock.set(normalizeRequirementName(block.name), block);
213
218
  }
214
- // The lookup key for each original block, by position. RENAMED changes a
215
- // block's key, so `bodyBlocks` alone can no longer find it at recompose time;
216
- // this list carries the current key in the original slot. `bodyBlocks` stays
217
- // 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.
218
222
  const orderedKeys = parts.bodyBlocks.map((block) => normalizeRequirementName(block.name));
219
- // Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
220
- // RENAMED
223
+ // Order matters: RENAMED → REMOVED → MODIFIED → ADDED.
221
224
  let renamedApplied = 0;
222
225
  for (const r of plan.renamed) {
223
226
  const from = normalizeRequirementName(r.from);
224
227
  const to = normalizeRequirementName(r.to);
225
228
  if (!nameToBlock.has(from)) {
226
- // Source gone but target present means the rename was already synced to
227
- // the baseline (early-sync pattern) - re-applying it is a no-op, not a
228
- // failure. Only a missing source AND target is a genuine error.
229
+ // Source gone but target present: the rename was already synced.
229
230
  if (nameToBlock.has(to)) {
230
231
  continue;
231
232
  }
@@ -246,76 +247,88 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
246
247
  };
247
248
  nameToBlock.delete(from);
248
249
  nameToBlock.set(to, renamedBlock);
249
- // delete+set moves the entry to the Map's insertion-order tail, so a rename
250
- // would otherwise push the requirement to the end of the rebuilt spec.
251
- // 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.
252
252
  const orderIndex = orderedKeys.indexOf(from);
253
253
  if (orderIndex >= 0) {
254
254
  orderedKeys[orderIndex] = to;
255
255
  }
256
256
  renamedApplied++;
257
257
  }
258
- // REMOVED
259
258
  let removedApplied = 0;
260
259
  // Keys this run actually removed. A renamed requirement also disappears from
261
- // its old key, so "absent from nameToBlock" alone cannot tell the two apart —
262
- // 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.
263
262
  const removedKeys = new Set();
264
263
  for (const name of plan.removed) {
265
264
  const key = normalizeRequirementName(name);
266
265
  if (!nameToBlock.has(key)) {
267
- // Requirement gone from the baseline means the removal was already synced
268
- // (early-sync pattern) - re-applying it is a no-op, not a failure. One
269
- // signal separates that from a mistyped header: a requirement that differs
270
- // only in case or interior whitespace still being present. That is a typo,
271
- // 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.
272
269
  if (!isNewSpec) {
273
270
  const nearMiss = [...nameToBlock.keys()].find((k) => foldRequirementName(k) === foldRequirementName(key));
274
271
  if (nearMiss !== undefined) {
275
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`);
276
273
  }
277
- // ponytail: warning surfaced to humans only; JSON callers get no
278
- // warnings[] channel yet. Add one if agent flows need the signal.
279
- if (!options.silent) {
280
- console.log(`Warning: ${specName} - REMOVED requirement "${name}" is not in the current spec; treating it as already removed.`);
281
- }
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.`);
282
275
  }
283
- // Skip removal (nothing to remove)
284
276
  continue;
285
277
  }
286
278
  nameToBlock.delete(key);
287
279
  removedKeys.add(key);
288
280
  removedApplied++;
289
281
  }
290
- // 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);
291
289
  for (const mod of plan.modified) {
292
290
  const key = normalizeRequirementName(mod.name);
293
291
  const currentBlock = nameToBlock.get(key);
294
292
  if (!currentBlock) {
295
293
  throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`);
296
294
  }
297
- // Replace block with provided raw (ensure header line matches key)
298
- 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);
299
296
  if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
300
297
  throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`);
301
298
  }
302
- // Guard against a stale MODIFIED block silently dropping scenarios that a
303
- // previous archive already added to the current spec.
304
- 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
+ });
305
312
  if (missingScenarios.length > 0) {
306
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.`);
307
314
  }
308
- 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.`);
309
324
  }
310
- // ADDED
311
325
  let addedApplied = 0;
312
326
  for (const add of plan.added) {
313
327
  const key = normalizeRequirementName(add.name);
314
328
  const existing = nameToBlock.get(key);
315
329
  if (existing) {
316
- // Identical content means the requirement was already synced to the
317
- // baseline (early-sync pattern) - re-applying it is a no-op, not a
318
- // conflict. Only differing content is a genuine collision.
330
+ // Identical content means it was already synced; only differing content
331
+ // is a genuine collision.
319
332
  if (normalizeBlockRaw(existing.raw) === normalizeBlockRaw(add.raw)) {
320
333
  continue;
321
334
  }
@@ -324,13 +337,10 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
324
337
  nameToBlock.set(key, add);
325
338
  addedApplied++;
326
339
  }
327
- // Duplicates within resulting map are implicitly prevented by key uniqueness.
328
- // Recompose requirements section preserving original ordering where possible.
329
- // A block's `raw` runs to the next header the parser RECOGNISES, so a heading
330
- // it does not — a plain `### Notes` — is absorbed into the requirement above
331
- // it. Removing that requirement used to delete the absorbed content with it,
332
- // silently: nothing counted it, so nothing warned, and the spec left behind
333
- // 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.
334
344
  const pieces = [];
335
345
  const seen = new Set();
336
346
  let salvagedCount = 0;
@@ -354,7 +364,7 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
354
364
  }
355
365
  }
356
366
  }
357
- // Append any newly added that were not in original order
367
+ // Whatever was added rather than replacing an original slot.
358
368
  for (const [key, block] of nameToBlock.entries()) {
359
369
  if (!seen.has(key)) {
360
370
  pieces.push(block.raw);
@@ -362,8 +372,7 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
362
372
  }
363
373
  }
364
374
  // Every requirement is gone but absorbed content survived. Retiring would
365
- // delete that content along with the file, which is the silent loss this
366
- // 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
367
376
  // requirements, which cannot validate. Neither is safe to choose silently.
368
377
  if (keptRequirements === 0 && removedApplied > 0 && salvagedCount > 0) {
369
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.`);
@@ -373,28 +382,21 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
373
382
  .concat(pieces)
374
383
  .join('\n\n')
375
384
  .trimEnd();
376
- // The blank lines around `## Requirements` belong to no slice: `before` and
377
- // `after` carry at most one boundary newline by construction, `reqBody` and
378
- // every block `raw` are trimEnd()ed. Joining with a bare '\n' therefore glued
379
- // the heading to the Purpose paragraph above it and the first requirement
380
- // below, so every archive rewrote a well-formatted spec into that shape.
381
- // Separate non-empty slices with one blank line, and end the file with
382
- // exactly one newline instead of inheriting whatever `after` happened to hold.
383
- const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after.trim()]
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.
388
+ const rebuilt = collapseBlankRunsOutsideFences([parts.before.trimEnd(), parts.headerLine, reqBody, parts.after.trim()]
384
389
  .filter((s) => s !== '')
385
- .join('\n\n')
386
- .replace(/\n{3,}/g, '\n\n')
387
- .trimEnd() + '\n';
390
+ .join('\n\n')).trimEnd() + '\n';
388
391
  return {
389
392
  rebuilt,
390
- // The capability has no requirements left and this run is what emptied it.
391
- // Both halves matter. "No requirements left" alone would delete a spec on a
392
- // re-run of an already-synced REMOVED, taking with it any requirement added
393
- // since; "removed something" alone would delete a spec that still has
394
- // requirements. An emptied spec cannot be written either way — it fails
395
- // validation ("Spec must have at least one requirement") — so the caller
396
- // retires the capability instead of writing an unusable file.
393
+ crlf,
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.
397
398
  retired: keptRequirements === 0 && removedApplied > 0,
399
+ notices,
398
400
  counts: {
399
401
  added: addedApplied,
400
402
  modified: plan.modified.length,
@@ -404,16 +406,39 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
404
406
  };
405
407
  }
406
408
  function normalizeBlockRaw(raw) {
407
- return raw.replace(/\r\n?/g, '\n').trim();
409
+ return normalizeDocument(raw).trim();
408
410
  }
409
411
  /**
410
- * Write an updated spec to disk.
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.
411
416
  */
417
+ function collapseBlankRunsOutsideFences(content) {
418
+ const lines = content.split('\n');
419
+ const mask = buildCodeFenceMask(lines);
420
+ const kept = [];
421
+ const keptMask = [];
422
+ for (let i = 0; i < lines.length; i++) {
423
+ if (!mask[i] && lines[i].trim() === '') {
424
+ const last = kept.length - 1;
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.
427
+ if (last >= 0 && !keptMask[last] && kept[last].trim() === '')
428
+ continue;
429
+ }
430
+ kept.push(lines[i]);
431
+ keptMask.push(mask[i]);
432
+ }
433
+ return kept.join('\n');
434
+ }
412
435
  export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
413
- // Create target directory if needed
414
436
  const targetDir = path.dirname(update.target);
415
437
  await fs.mkdir(targetDir, { recursive: true });
416
- await fs.writeFile(update.target, rebuilt);
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.
440
+ const content = options.crlf ? rebuilt.replace(/\r?\n/g, '\r\n') : rebuilt;
441
+ await fs.writeFile(update.target, content);
417
442
  if (options.silent)
418
443
  return;
419
444
  const specName = path.basename(path.dirname(update.target));
@@ -429,19 +454,15 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
429
454
  }
430
455
  /**
431
456
  * The tail of a requirement block that is not part of the requirement: content
432
- * from the first `#`/`##`/`###` heading after the block's own header line.
457
+ * from the first `#`/`##`/`###` heading after its own header line. `####` is
458
+ * excluded — a requirement's `#### Scenario:` headings go with it.
433
459
  *
434
- * `####` is excluded deliberately — a requirement's `#### Scenario:` headings
435
- * are its own and go with it when it is removed.
436
- *
437
- * The alternative was to widen every heading pattern in the parsers so these
438
- * headings end the block properly. That was rejected: it reclassifies content,
439
- * so commented-out or indented examples start parsing as real requirements and
440
- * a spec that was valid becomes invalid. Salvaging on removal changes what a
441
- * 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.
442
463
  */
443
464
  function salvageAbsorbedContent(requirementRaw) {
444
- const lines = requirementRaw.replace(/\r\n?/g, '\n').split('\n');
465
+ const lines = normalizeDocument(requirementRaw).split('\n');
445
466
  const mask = buildCodeFenceMask(lines);
446
467
  for (let i = 1; i < lines.length; i++) {
447
468
  if (mask[i])
@@ -453,12 +474,9 @@ function salvageAbsorbedContent(requirementRaw) {
453
474
  return '';
454
475
  }
455
476
  /**
456
- * Retire a capability the change emptied: delete its `spec.md`, then remove any
457
- * directory the deletion left empty.
458
- *
459
- * The pruning walks up from the spec file and stops at `specsRoot`, which is
460
- * never removed even if it ends up empty — a project with no capabilities still
461
- * 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
462
480
  * sibling capability is never touched.
463
481
  */
464
482
  export async function retireSpec(update, specsRoot, options = {}) {
@@ -483,9 +501,8 @@ export async function retireSpec(update, specsRoot, options = {}) {
483
501
  console.log(`Retiring ${options.displayPath ?? `tospec/specs/${specName}/spec.md`}: no requirements left`);
484
502
  }
485
503
  /**
486
- * Build a skeleton spec for new capabilities. Carries the delta's authored
487
- * Purpose when one is supplied; otherwise writes the TBD placeholder — composed
488
- * 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.
489
506
  */
490
507
  export function buildSpecSkeleton(specFolderName, changeName, purpose) {
491
508
  const titleBase = specFolderName;
@@ -499,8 +516,10 @@ export function buildSpecSkeleton(specFolderName, changeName, purpose) {
499
516
  * is none. Fence-masked so a `## Purpose` inside a code example is not mistaken
500
517
  * for the real section.
501
518
  */
502
- function extractDeltaPurpose(content) {
503
- 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');
504
523
  const mask = buildCodeFenceMask(lines);
505
524
  let start = -1;
506
525
  for (let i = 0; i < lines.length; i++) {
@@ -525,13 +544,9 @@ function extractDeltaPurpose(content) {
525
544
  return lines.slice(start, end).join('\n').trim();
526
545
  }
527
546
  /**
528
- * A carried Purpose must not restructure the new spec. Headings, HTML comments,
529
- * and code fences each have ways to corrupt or hide the rest of the spec, so a
530
- * Purpose containing any of them falls back to the placeholder rather than risk
531
- * it.
532
- * ponytail: prose-only heuristic; a Purpose that genuinely needs a fence or
533
- * heading falls back to TBD. Loosen only once those corruption classes are
534
- * 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.
535
550
  */
536
551
  function isPurposeCarrySafe(body) {
537
552
  if (!body)