@seanmars/tospec 0.24.0 → 0.26.0-beta

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 (332) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +10 -6
  3. package/assets/dashboard/lib/derive.js +11 -5
  4. package/assets/dashboard/lib/store.js +4 -2
  5. package/assets/dashboard/views/change.js +5 -3
  6. package/dist/cli/index.d.ts.map +1 -1
  7. package/dist/cli/index.js +42 -7
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/commands/config.d.ts.map +1 -1
  10. package/dist/commands/config.js +213 -100
  11. package/dist/commands/config.js.map +1 -1
  12. package/dist/commands/dashboard.d.ts +9 -0
  13. package/dist/commands/dashboard.d.ts.map +1 -1
  14. package/dist/commands/dashboard.js +77 -21
  15. package/dist/commands/dashboard.js.map +1 -1
  16. package/dist/commands/decision.d.ts +38 -1
  17. package/dist/commands/decision.d.ts.map +1 -1
  18. package/dist/commands/decision.js +303 -27
  19. package/dist/commands/decision.js.map +1 -1
  20. package/dist/commands/metrics.d.ts +3 -4
  21. package/dist/commands/metrics.d.ts.map +1 -1
  22. package/dist/commands/metrics.js +3 -5
  23. package/dist/commands/metrics.js.map +1 -1
  24. package/dist/commands/shared-output.d.ts +24 -0
  25. package/dist/commands/shared-output.d.ts.map +1 -1
  26. package/dist/commands/shared-output.js +21 -2
  27. package/dist/commands/shared-output.js.map +1 -1
  28. package/dist/commands/show.d.ts.map +1 -1
  29. package/dist/commands/show.js +12 -9
  30. package/dist/commands/show.js.map +1 -1
  31. package/dist/commands/validate.d.ts +19 -0
  32. package/dist/commands/validate.d.ts.map +1 -1
  33. package/dist/commands/validate.js +243 -64
  34. package/dist/commands/validate.js.map +1 -1
  35. package/dist/commands/workflow/instructions.d.ts +7 -1
  36. package/dist/commands/workflow/instructions.d.ts.map +1 -1
  37. package/dist/commands/workflow/instructions.js +299 -81
  38. package/dist/commands/workflow/instructions.js.map +1 -1
  39. package/dist/commands/workflow/new-change.d.ts.map +1 -1
  40. package/dist/commands/workflow/new-change.js +26 -17
  41. package/dist/commands/workflow/new-change.js.map +1 -1
  42. package/dist/commands/workflow/prompt-escape.d.ts +25 -0
  43. package/dist/commands/workflow/prompt-escape.d.ts.map +1 -0
  44. package/dist/commands/workflow/prompt-escape.js +62 -0
  45. package/dist/commands/workflow/prompt-escape.js.map +1 -0
  46. package/dist/commands/workflow/schemas.d.ts.map +1 -1
  47. package/dist/commands/workflow/schemas.js +34 -16
  48. package/dist/commands/workflow/schemas.js.map +1 -1
  49. package/dist/commands/workflow/shared.d.ts +46 -10
  50. package/dist/commands/workflow/shared.d.ts.map +1 -1
  51. package/dist/commands/workflow/shared.js +66 -30
  52. package/dist/commands/workflow/shared.js.map +1 -1
  53. package/dist/commands/workflow/status.d.ts +5 -2
  54. package/dist/commands/workflow/status.d.ts.map +1 -1
  55. package/dist/commands/workflow/status.js +62 -28
  56. package/dist/commands/workflow/status.js.map +1 -1
  57. package/dist/core/archive.d.ts +38 -0
  58. package/dist/core/archive.d.ts.map +1 -1
  59. package/dist/core/archive.js +492 -75
  60. package/dist/core/archive.js.map +1 -1
  61. package/dist/core/artifact-graph/index.d.ts +2 -2
  62. package/dist/core/artifact-graph/index.d.ts.map +1 -1
  63. package/dist/core/artifact-graph/index.js +2 -2
  64. package/dist/core/artifact-graph/index.js.map +1 -1
  65. package/dist/core/artifact-graph/instruction-loader.d.ts +8 -0
  66. package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
  67. package/dist/core/artifact-graph/instruction-loader.js +58 -36
  68. package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
  69. package/dist/core/artifact-graph/resolver.d.ts +39 -12
  70. package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
  71. package/dist/core/artifact-graph/resolver.js +82 -36
  72. package/dist/core/artifact-graph/resolver.js.map +1 -1
  73. package/dist/core/artifact-graph/schema.d.ts +24 -0
  74. package/dist/core/artifact-graph/schema.d.ts.map +1 -1
  75. package/dist/core/artifact-graph/schema.js +32 -1
  76. package/dist/core/artifact-graph/schema.js.map +1 -1
  77. package/dist/core/artifact-graph/stub-detection.d.ts +10 -2
  78. package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -1
  79. package/dist/core/artifact-graph/stub-detection.js +18 -11
  80. package/dist/core/artifact-graph/stub-detection.js.map +1 -1
  81. package/dist/core/artifact-graph/types.d.ts +2 -2
  82. package/dist/core/artifact-graph/types.d.ts.map +1 -1
  83. package/dist/core/artifact-graph/types.js +10 -1
  84. package/dist/core/artifact-graph/types.js.map +1 -1
  85. package/dist/core/change-metadata/schema.d.ts.map +1 -1
  86. package/dist/core/change-metadata/schema.js +18 -4
  87. package/dist/core/change-metadata/schema.js.map +1 -1
  88. package/dist/core/change-presenter.d.ts.map +1 -1
  89. package/dist/core/change-presenter.js +13 -8
  90. package/dist/core/change-presenter.js.map +1 -1
  91. package/dist/core/change-status-policy.d.ts.map +1 -1
  92. package/dist/core/change-status-policy.js +3 -1
  93. package/dist/core/change-status-policy.js.map +1 -1
  94. package/dist/core/codex-metrics.d.ts +1 -1
  95. package/dist/core/codex-metrics.js +1 -1
  96. package/dist/core/config-schema.d.ts +8 -0
  97. package/dist/core/config-schema.d.ts.map +1 -1
  98. package/dist/core/config-schema.js +10 -0
  99. package/dist/core/config-schema.js.map +1 -1
  100. package/dist/core/dashboard-activity.d.ts +4 -4
  101. package/dist/core/dashboard-activity.js +4 -4
  102. package/dist/core/dashboard-data.d.ts +11 -5
  103. package/dist/core/dashboard-data.d.ts.map +1 -1
  104. package/dist/core/dashboard-data.js +54 -28
  105. package/dist/core/dashboard-data.js.map +1 -1
  106. package/dist/core/dashboard-hub.d.ts +3 -3
  107. package/dist/core/dashboard-hub.d.ts.map +1 -1
  108. package/dist/core/dashboard-hub.js +16 -4
  109. package/dist/core/dashboard-hub.js.map +1 -1
  110. package/dist/core/dashboard-routes.d.ts +2 -1
  111. package/dist/core/dashboard-routes.d.ts.map +1 -1
  112. package/dist/core/dashboard-routes.js +102 -33
  113. package/dist/core/dashboard-routes.js.map +1 -1
  114. package/dist/core/global-config.d.ts +71 -2
  115. package/dist/core/global-config.d.ts.map +1 -1
  116. package/dist/core/global-config.js +149 -4
  117. package/dist/core/global-config.js.map +1 -1
  118. package/dist/core/id.d.ts +9 -0
  119. package/dist/core/id.d.ts.map +1 -1
  120. package/dist/core/id.js +37 -0
  121. package/dist/core/id.js.map +1 -1
  122. package/dist/core/init.d.ts +9 -2
  123. package/dist/core/init.d.ts.map +1 -1
  124. package/dist/core/init.js +116 -33
  125. package/dist/core/init.js.map +1 -1
  126. package/dist/core/list.d.ts.map +1 -1
  127. package/dist/core/list.js +69 -45
  128. package/dist/core/list.js.map +1 -1
  129. package/dist/core/local-server.d.ts +9 -2
  130. package/dist/core/local-server.d.ts.map +1 -1
  131. package/dist/core/local-server.js +24 -4
  132. package/dist/core/local-server.js.map +1 -1
  133. package/dist/core/markdown-render.d.ts.map +1 -1
  134. package/dist/core/markdown-render.js +7 -2
  135. package/dist/core/markdown-render.js.map +1 -1
  136. package/dist/core/migrate.d.ts +24 -2
  137. package/dist/core/migrate.d.ts.map +1 -1
  138. package/dist/core/migrate.js +335 -59
  139. package/dist/core/migrate.js.map +1 -1
  140. package/dist/core/parsers/change-parser.d.ts +20 -1
  141. package/dist/core/parsers/change-parser.d.ts.map +1 -1
  142. package/dist/core/parsers/change-parser.js +76 -73
  143. package/dist/core/parsers/change-parser.js.map +1 -1
  144. package/dist/core/parsers/markdown-parser.d.ts +24 -0
  145. package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
  146. package/dist/core/parsers/markdown-parser.js +65 -10
  147. package/dist/core/parsers/markdown-parser.js.map +1 -1
  148. package/dist/core/parsers/requirement-blocks.d.ts +127 -6
  149. package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
  150. package/dist/core/parsers/requirement-blocks.js +349 -56
  151. package/dist/core/parsers/requirement-blocks.js.map +1 -1
  152. package/dist/core/parsers/requirement-text.d.ts +25 -1
  153. package/dist/core/parsers/requirement-text.d.ts.map +1 -1
  154. package/dist/core/parsers/requirement-text.js +62 -6
  155. package/dist/core/parsers/requirement-text.js.map +1 -1
  156. package/dist/core/parsers/spec-structure.d.ts.map +1 -1
  157. package/dist/core/parsers/spec-structure.js +36 -24
  158. package/dist/core/parsers/spec-structure.js.map +1 -1
  159. package/dist/core/project-config.d.ts +9 -2
  160. package/dist/core/project-config.d.ts.map +1 -1
  161. package/dist/core/project-config.js +30 -7
  162. package/dist/core/project-config.js.map +1 -1
  163. package/dist/core/project-layout.d.ts +26 -0
  164. package/dist/core/project-layout.d.ts.map +1 -1
  165. package/dist/core/project-layout.js +51 -1
  166. package/dist/core/project-layout.js.map +1 -1
  167. package/dist/core/schema-names.d.ts +17 -0
  168. package/dist/core/schema-names.d.ts.map +1 -1
  169. package/dist/core/schema-names.js +24 -0
  170. package/dist/core/schema-names.js.map +1 -1
  171. package/dist/core/shared/index.d.ts +1 -1
  172. package/dist/core/shared/index.d.ts.map +1 -1
  173. package/dist/core/shared/index.js +1 -1
  174. package/dist/core/shared/index.js.map +1 -1
  175. package/dist/core/shared/rules-generation.d.ts +12 -0
  176. package/dist/core/shared/rules-generation.d.ts.map +1 -1
  177. package/dist/core/shared/rules-generation.js +26 -2
  178. package/dist/core/shared/rules-generation.js.map +1 -1
  179. package/dist/core/shared/skill-generation.d.ts +7 -0
  180. package/dist/core/shared/skill-generation.d.ts.map +1 -1
  181. package/dist/core/shared/skill-generation.js +19 -0
  182. package/dist/core/shared/skill-generation.js.map +1 -1
  183. package/dist/core/shared/tool-detection.d.ts +10 -1
  184. package/dist/core/shared/tool-detection.d.ts.map +1 -1
  185. package/dist/core/shared/tool-detection.js +37 -6
  186. package/dist/core/shared/tool-detection.js.map +1 -1
  187. package/dist/core/spec-presenter.d.ts.map +1 -1
  188. package/dist/core/spec-presenter.js +23 -1
  189. package/dist/core/spec-presenter.js.map +1 -1
  190. package/dist/core/specs-apply.d.ts +98 -1
  191. package/dist/core/specs-apply.d.ts.map +1 -1
  192. package/dist/core/specs-apply.js +426 -63
  193. package/dist/core/specs-apply.js.map +1 -1
  194. package/dist/core/templates/fragments/change-diff.d.ts +37 -0
  195. package/dist/core/templates/fragments/change-diff.d.ts.map +1 -0
  196. package/dist/core/templates/fragments/change-diff.js +37 -0
  197. package/dist/core/templates/fragments/change-diff.js.map +1 -0
  198. package/dist/core/templates/fragments/sync-fail.d.ts +8 -0
  199. package/dist/core/templates/fragments/sync-fail.d.ts.map +1 -0
  200. package/dist/core/templates/fragments/sync-fail.js +8 -0
  201. package/dist/core/templates/fragments/sync-fail.js.map +1 -0
  202. package/dist/core/templates/fragments/validate.d.ts +14 -1
  203. package/dist/core/templates/fragments/validate.d.ts.map +1 -1
  204. package/dist/core/templates/fragments/validate.js +14 -1
  205. package/dist/core/templates/fragments/validate.js.map +1 -1
  206. package/dist/core/templates/fragments/verify.d.ts +2 -2
  207. package/dist/core/templates/fragments/verify.d.ts.map +1 -1
  208. package/dist/core/templates/fragments/verify.js +8 -5
  209. package/dist/core/templates/fragments/verify.js.map +1 -1
  210. package/dist/core/templates/workflows/apply.d.ts +1 -1
  211. package/dist/core/templates/workflows/apply.d.ts.map +1 -1
  212. package/dist/core/templates/workflows/apply.js +29 -10
  213. package/dist/core/templates/workflows/apply.js.map +1 -1
  214. package/dist/core/templates/workflows/archive.d.ts.map +1 -1
  215. package/dist/core/templates/workflows/archive.js +27 -9
  216. package/dist/core/templates/workflows/archive.js.map +1 -1
  217. package/dist/core/templates/workflows/decision.d.ts.map +1 -1
  218. package/dist/core/templates/workflows/decision.js +10 -4
  219. package/dist/core/templates/workflows/decision.js.map +1 -1
  220. package/dist/core/templates/workflows/explore.d.ts +2 -2
  221. package/dist/core/templates/workflows/explore.d.ts.map +1 -1
  222. package/dist/core/templates/workflows/explore.js +7 -3
  223. package/dist/core/templates/workflows/explore.js.map +1 -1
  224. package/dist/core/templates/workflows/grill.d.ts +2 -2
  225. package/dist/core/templates/workflows/grill.d.ts.map +1 -1
  226. package/dist/core/templates/workflows/grill.js +5 -4
  227. package/dist/core/templates/workflows/grill.js.map +1 -1
  228. package/dist/core/templates/workflows/issue.d.ts.map +1 -1
  229. package/dist/core/templates/workflows/issue.js +9 -3
  230. package/dist/core/templates/workflows/issue.js.map +1 -1
  231. package/dist/core/templates/workflows/propose.d.ts.map +1 -1
  232. package/dist/core/templates/workflows/propose.js +4 -1
  233. package/dist/core/templates/workflows/propose.js.map +1 -1
  234. package/dist/core/templates/workflows/sync.d.ts.map +1 -1
  235. package/dist/core/templates/workflows/sync.js +25 -11
  236. package/dist/core/templates/workflows/sync.js.map +1 -1
  237. package/dist/core/templates/workflows/update.d.ts.map +1 -1
  238. package/dist/core/templates/workflows/update.js +17 -10
  239. package/dist/core/templates/workflows/update.js.map +1 -1
  240. package/dist/core/update.d.ts +5 -0
  241. package/dist/core/update.d.ts.map +1 -1
  242. package/dist/core/update.js +66 -29
  243. package/dist/core/update.js.map +1 -1
  244. package/dist/core/validation/constants.d.ts +7 -1
  245. package/dist/core/validation/constants.d.ts.map +1 -1
  246. package/dist/core/validation/constants.js +16 -2
  247. package/dist/core/validation/constants.js.map +1 -1
  248. package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
  249. package/dist/core/validation/purpose-placeholder.js +35 -11
  250. package/dist/core/validation/purpose-placeholder.js.map +1 -1
  251. package/dist/core/validation/section-validator.d.ts +7 -0
  252. package/dist/core/validation/section-validator.d.ts.map +1 -1
  253. package/dist/core/validation/section-validator.js +22 -2
  254. package/dist/core/validation/section-validator.js.map +1 -1
  255. package/dist/core/validation/sister-changes.d.ts +93 -0
  256. package/dist/core/validation/sister-changes.d.ts.map +1 -0
  257. package/dist/core/validation/sister-changes.js +180 -0
  258. package/dist/core/validation/sister-changes.js.map +1 -0
  259. package/dist/core/validation/task-markers.d.ts +9 -0
  260. package/dist/core/validation/task-markers.d.ts.map +1 -0
  261. package/dist/core/validation/task-markers.js +71 -0
  262. package/dist/core/validation/task-markers.js.map +1 -0
  263. package/dist/core/validation/types.d.ts +58 -0
  264. package/dist/core/validation/types.d.ts.map +1 -1
  265. package/dist/core/validation/types.js +22 -0
  266. package/dist/core/validation/types.js.map +1 -1
  267. package/dist/core/validation/validator.d.ts +79 -2
  268. package/dist/core/validation/validator.d.ts.map +1 -1
  269. package/dist/core/validation/validator.js +565 -84
  270. package/dist/core/validation/validator.js.map +1 -1
  271. package/dist/utils/change-metadata.d.ts +62 -5
  272. package/dist/utils/change-metadata.d.ts.map +1 -1
  273. package/dist/utils/change-metadata.js +193 -31
  274. package/dist/utils/change-metadata.js.map +1 -1
  275. package/dist/utils/change-utils.d.ts +21 -0
  276. package/dist/utils/change-utils.d.ts.map +1 -1
  277. package/dist/utils/change-utils.js +168 -53
  278. package/dist/utils/change-utils.js.map +1 -1
  279. package/dist/utils/command-line.d.ts +11 -0
  280. package/dist/utils/command-line.d.ts.map +1 -0
  281. package/dist/utils/command-line.js +43 -0
  282. package/dist/utils/command-line.js.map +1 -0
  283. package/dist/utils/display-width.d.ts +10 -0
  284. package/dist/utils/display-width.d.ts.map +1 -0
  285. package/dist/utils/display-width.js +15 -0
  286. package/dist/utils/display-width.js.map +1 -0
  287. package/dist/utils/file-system.d.ts +16 -0
  288. package/dist/utils/file-system.d.ts.map +1 -1
  289. package/dist/utils/file-system.js +49 -2
  290. package/dist/utils/file-system.js.map +1 -1
  291. package/dist/utils/frontmatter.d.ts.map +1 -1
  292. package/dist/utils/frontmatter.js +11 -5
  293. package/dist/utils/frontmatter.js.map +1 -1
  294. package/dist/utils/interactive.d.ts.map +1 -1
  295. package/dist/utils/interactive.js +3 -1
  296. package/dist/utils/interactive.js.map +1 -1
  297. package/dist/utils/item-discovery.d.ts +3 -2
  298. package/dist/utils/item-discovery.d.ts.map +1 -1
  299. package/dist/utils/item-discovery.js +34 -16
  300. package/dist/utils/item-discovery.js.map +1 -1
  301. package/dist/utils/link.d.ts +0 -1
  302. package/dist/utils/link.d.ts.map +1 -1
  303. package/dist/utils/link.js +0 -1
  304. package/dist/utils/link.js.map +1 -1
  305. package/dist/utils/match.d.ts.map +1 -1
  306. package/dist/utils/match.js +10 -1
  307. package/dist/utils/match.js.map +1 -1
  308. package/dist/utils/option-flags.d.ts +9 -0
  309. package/dist/utils/option-flags.d.ts.map +1 -0
  310. package/dist/utils/option-flags.js +12 -0
  311. package/dist/utils/option-flags.js.map +1 -0
  312. package/dist/utils/sentence.d.ts +9 -0
  313. package/dist/utils/sentence.d.ts.map +1 -0
  314. package/dist/utils/sentence.js +12 -0
  315. package/dist/utils/sentence.js.map +1 -0
  316. package/dist/utils/spec-files.d.ts +84 -1
  317. package/dist/utils/spec-files.d.ts.map +1 -1
  318. package/dist/utils/spec-files.js +192 -3
  319. package/dist/utils/spec-files.js.map +1 -1
  320. package/dist/utils/task-progress.d.ts.map +1 -1
  321. package/dist/utils/task-progress.js +32 -9
  322. package/dist/utils/task-progress.js.map +1 -1
  323. package/dist/utils/timestamp.d.ts +27 -0
  324. package/dist/utils/timestamp.d.ts.map +1 -1
  325. package/dist/utils/timestamp.js +37 -0
  326. package/dist/utils/timestamp.js.map +1 -1
  327. package/package.json +4 -1
  328. package/schemas/decision/templates/decision.md +2 -2
  329. package/schemas/issue/schema.yaml +12 -6
  330. package/schemas/issue/templates/task.md +1 -1
  331. package/schemas/sdd/schema.yaml +21 -10
  332. package/schemas/sdd/templates/proposal.md +1 -1
@@ -1,16 +1,20 @@
1
1
  import { readFileSync, promises as fs } from 'fs';
2
2
  import path from 'path';
3
3
  import { SpecSchema } from '../schemas/index.js';
4
- import { MarkdownParser } from '../parsers/markdown-parser.js';
5
- import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, MAX_DELTAS_PER_CHANGE, VALIDATION_MESSAGES } from './constants.js';
4
+ import { MarkdownParser, SpecHeadingLevelError } from '../parsers/markdown-parser.js';
5
+ import { ARCHIVE_REFUSAL_CODE, SISTER_CHANGE_OVERLAP_CODE, REQUIREMENT_HEADER_MISMATCH_CODE, } from './types.js';
6
+ import { appendSentence } from '../../utils/sentence.js';
7
+ import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, MAX_DELTAS_PER_CHANGE, VALIDATION_MESSAGES, noDeltasGuide } from './constants.js';
6
8
  import { proseLength } from './prose-length.js';
7
- import { parseDeltaSpec, normalizeRequirementName, extractRequirementsSection } from '../parsers/requirement-blocks.js';
9
+ import { parseDeltaSpec, normalizeRequirementName, findFoldedMatch, extractRequirementsSection, postRenameRemovalHint, } from '../parsers/requirement-blocks.js';
8
10
  import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust, analyzeScenarios, scanCodeFences, findMissingScenarios, findDroppedBullets, withoutScenarios, normalizeDocument, } from '../parsers/requirement-text.js';
9
11
  import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
10
12
  import { findPurposePlaceholderIssue } from './purpose-placeholder.js';
11
- import { FileSystemUtils, extractNameFromPath, isMissingPathError } from '../../utils/file-system.js';
12
- import { findAllMarkdownFiles } from '../../utils/spec-files.js';
13
- import { findSpecUpdates, buildUpdatedSpec, extractDeltaPurpose } from '../specs-apply.js';
13
+ import { FileSystemUtils, extractNameFromPath, isMissingPathError, isAbsentDirectoryError } from '../../utils/file-system.js';
14
+ import { isUnicodeKebabName, portableNameFault, UNICODE_KEBAB_NAME_DESCRIPTION } from '../id.js';
15
+ import { findAllMarkdownFiles, findRootSpecDelta, specLayoutFault } from '../../utils/spec-files.js';
16
+ import { findSpecUpdates, buildUpdatedSpec, extractDeltaPurpose, resolveOnDiskSpecName, MissingTargetSpecError, MissingTargetRequirementError, RequirementNearMissError, } from '../specs-apply.js';
17
+ import { findHeaderMismatches, findSharedRequirements, listDeltaTouches, listSisterChangeDeltas, readChangeOrderKey, } from './sister-changes.js';
14
18
  /**
15
19
  * The one place the `## REMOVED Scenarios` grammar is spelled out in a
16
20
  * message. Both the "omits scenario(s)" error and the empty-section error
@@ -18,24 +22,80 @@ import { findSpecUpdates, buildUpdatedSpec, extractDeltaPurpose } from '../specs
18
22
  * instead of being sent to the template.
19
23
  */
20
24
  const REMOVED_SCENARIOS_GRAMMAR = 'Each entry is three bullets: "- Requirement: `<name>`" / "- Scenario: `<name>`" / "- Reason: <why>".';
25
+ /**
26
+ * The entry names under `tospec/specs/` as spelled on disk, or undefined when
27
+ * they cannot be known (no `mainSpecsDir`, or a directory that exists but cannot
28
+ * be read): then new capabilities cannot be told from existing ones, and
29
+ * guessing "new" would hold an existing capability to the naming rule.
30
+ */
31
+ async function listMainSpecNames(mainSpecsDir) {
32
+ if (mainSpecsDir === undefined)
33
+ return undefined;
34
+ try {
35
+ return await fs.readdir(mainSpecsDir);
36
+ }
37
+ catch (error) {
38
+ return isAbsentDirectoryError(error) ? [] : undefined;
39
+ }
40
+ }
41
+ /**
42
+ * The capability folder's name checked against the main specs it will merge
43
+ * into. Matched against the readdir spelling, never through the file system:
44
+ * asking whether `tospec/specs/AUTH/spec.md` exists answers yes on Windows and
45
+ * default macOS and no on Linux, so the same delta merged into `auth` on one
46
+ * and archived a second capability `AUTH` on the other. An existing
47
+ * capability keeps the name it has — the rule binds only the name a change is
48
+ * about to create.
49
+ */
50
+ function capabilityNameIssues(entryPath, mainSpecNames) {
51
+ const capability = entryPath.split('/')[0];
52
+ if (mainSpecNames.includes(capability))
53
+ return [];
54
+ const folded = capability.toLowerCase();
55
+ const clash = mainSpecNames.find((name) => name.toLowerCase() === folded);
56
+ if (clash !== undefined) {
57
+ return [
58
+ {
59
+ level: 'ERROR',
60
+ path: entryPath,
61
+ message: `Capability folder "${capability}" differs only in case from the existing main spec tospec/specs/${clash}/. A case-insensitive file system (Windows, default macOS) merges it into ${clash}, a case-sensitive one archives it as a second capability, so the result depends on the OS. Rename the folder to specs/${clash}/.`,
62
+ },
63
+ ];
64
+ }
65
+ const fault = portableNameFault(capability);
66
+ if (fault !== undefined) {
67
+ return [
68
+ {
69
+ level: 'ERROR',
70
+ path: entryPath,
71
+ message: `Capability folder "${capability}" ${fault}. Archive creates tospec/specs/${capability}/ from this name, and a repo holding it cannot be checked out on Windows. Rename the folder.`,
72
+ },
73
+ ];
74
+ }
75
+ // WARNING, not ERROR: the name works on every OS, and a project may have
76
+ // its own convention. It is raised now because archive turns the folder name
77
+ // into the main spec's name, and renaming a capability later is a change of
78
+ // its own.
79
+ if (!isUnicodeKebabName(capability)) {
80
+ return [
81
+ {
82
+ level: 'WARNING',
83
+ path: entryPath,
84
+ message: `Capability folder "${capability}" ${UNICODE_KEBAB_NAME_DESCRIPTION}. Archive turns it into the name of a new main spec, tospec/specs/${capability}/, so rename the folder now if it should follow the convention.`,
85
+ },
86
+ ];
87
+ }
88
+ return [];
89
+ }
90
+ /** The `specs/spec.md` ERROR. */
91
+ const ROOT_SPEC_DELTA_MESSAGE = 'Delta spec found at specs/spec.md. Delta specs must live in a capability folder (e.g. specs/<capability>/spec.md) — a file at the specs/ root is ignored when the change is applied or archived.';
21
92
  /**
22
93
  * Merge notices the archive dry run forwards as validation warnings: a REMOVED
23
94
  * block naming something the target spec does not have, so archiving deletes
24
95
  * nothing. Matched by code, not message text, so rewording a notice cannot
25
96
  * switch the check off.
26
97
  */
27
- const REMOVED_NOOP_NOTICE_CODES = new Set([
28
- 'archive_removed_requirement_absent',
29
- 'archive_removed_ignored_new_spec',
30
- ]);
31
- /**
32
- * Merge failures the structural pass already reports under its own wording:
33
- * duplicate or cross-section names (`validation failed - ...`), a section that
34
- * parsed to nothing, and a MODIFIED that drops a scenario. Matched by the
35
- * fixed prefix each message opens with; the rest of a message carries names
36
- * that vary per delta.
37
- */
38
- const STRUCTURAL_MERGE_FAILURE = /( validation failed - |^Delta parsing found no operations|current spec contains scenario\(s\) not present)/;
98
+ const REMOVED_NOOP_NOTICE_CODES = new Set(['archive_removed_requirement_absent']);
39
99
  /**
40
100
  * A name the template shipped and nobody replaced: `[name]`, `[old name]`,
41
101
  * `[scenario name]`. Bracketed end to end, which is how every placeholder in
@@ -50,6 +110,108 @@ const STRUCTURAL_MERGE_FAILURE = /( validation failed - |^Delta parsing found no
50
110
  function isTemplatePlaceholder(name) {
51
111
  return /^\[.*\]$/.test(name.trim());
52
112
  }
113
+ /**
114
+ * Names the earlier spelling when a duplicate only folds onto it: without it,
115
+ * "Duplicate requirement" over two visibly different headers reads as a bug.
116
+ */
117
+ function foldedDuplicateNote(earlier, name) {
118
+ return earlier === name ? '' : ` (differs only in case or spacing from "${earlier}")`;
119
+ }
120
+ /**
121
+ * The in-progress changes whose ADDED delta creates `capability`, or, given
122
+ * `requirement`, that requirement in it. Matched exactly, as the merge
123
+ * matches: a folded match named a change whose archive still left the delta
124
+ * refused, and `blockedBy` promised the opposite. That case is
125
+ * `findHeaderMismatches`'s.
126
+ */
127
+ function addingChanges(sisters, capability, requirement) {
128
+ const exact = requirement === undefined ? undefined : normalizeRequirementName(requirement);
129
+ return sisters
130
+ .filter((s) => s.capability === capability &&
131
+ (exact === undefined
132
+ ? s.plan.added.length > 0
133
+ : s.plan.added.some((block) => normalizeRequirementName(block.name) === exact)))
134
+ .map((s) => s.change);
135
+ }
136
+ /**
137
+ * The sentence naming the change(s) that add what the delta needs, or
138
+ * undefined when none does. Without it the refusal reads as a delta written
139
+ * against the wrong capability or requirement, when the delta is right and
140
+ * only the archive order is.
141
+ */
142
+ function addingChangeHint(adders, what) {
143
+ if (adders.length === 0)
144
+ return undefined;
145
+ if (adders.length === 1)
146
+ return `Archive '${adders[0]}' first: it adds this ${what}.`;
147
+ return `Archive ${adders.map((name) => `'${name}'`).join(', ')} first: each adds this ${what}.`;
148
+ }
149
+ /**
150
+ * The requirement names of `capability`'s main spec: [] when it has none,
151
+ * undefined when it exists but cannot be read. The header-mismatch check
152
+ * needs them and stays silent on undefined, since the scenario-loss check
153
+ * already reports an unreadable main spec.
154
+ */
155
+ async function readMainRequirementNames(mainSpecsDir, capability) {
156
+ try {
157
+ const content = await fs.readFile(path.join(mainSpecsDir, capability, 'spec.md'), 'utf-8');
158
+ return extractRequirementsSection(content).bodyBlocks.map((block) => normalizeRequirementName(block.name));
159
+ }
160
+ catch (error) {
161
+ return isMissingPathError(error) ? [] : undefined;
162
+ }
163
+ }
164
+ /**
165
+ * The edit that writes `suggestedHeader` into the delta. A RENAMED names its
166
+ * requirement on the `- FROM:` bullet, and "change the header" sent the
167
+ * author to a `### Requirement:` line the entry does not have.
168
+ */
169
+ export function headerCorrectionClause(operation, suggestedHeader) {
170
+ const target = operation === 'RENAMED' ? 'the FROM line' : 'the header';
171
+ return `change ${target} to "### Requirement: ${suggestedHeader}"`;
172
+ }
173
+ /** The `requirement_header_mismatch` ERROR's message. */
174
+ function headerMismatchMessage({ change, own, suggestedHeader }) {
175
+ const at = own.line === undefined ? '' : ` (line ${own.line})`;
176
+ return `${own.operation} "${own.requirement}"${at} differs only in case or spacing from "${suggestedHeader}", which in-progress change '${change}' adds. Archive matches requirement names exactly, so archiving '${change}' first does not resolve it: ${headerCorrectionClause(own.operation, suggestedHeader)}.`;
177
+ }
178
+ /** True only for a main spec that is genuinely absent, the case archive refuses a MODIFIED, REMOVED or RENAMED on. */
179
+ async function isMissingMainSpec(mainSpecsDir, capability) {
180
+ try {
181
+ await fs.access(path.join(mainSpecsDir, capability, 'spec.md'));
182
+ return false;
183
+ }
184
+ catch (error) {
185
+ return isMissingPathError(error);
186
+ }
187
+ }
188
+ /**
189
+ * The older change first: both sides read the same two keys, so each tells
190
+ * its author the same order. Equal or unreadable keys give no order at all.
191
+ */
192
+ function compareChangeAge(own, sister) {
193
+ if (own === undefined || sister === undefined || own === sister)
194
+ return 'unknown';
195
+ return own < sister ? 'this-first' : 'sister-first';
196
+ }
197
+ /**
198
+ * The advice closing a sister-change WARNING. Each change's validate writes
199
+ * it from its own side, so the two must say one thing: advising "archive the
200
+ * other one first" on both, as this did, left an agent no order to follow.
201
+ * A sister ADDED fixes the order by itself, since a rewrite needs the
202
+ * requirement to exist; two rewrites are ordered by creation, the older first.
203
+ */
204
+ function sisterArchiveAdvice(sister, operation, order) {
205
+ const syncNote = 'sync may also rewrite the requirement during it.';
206
+ if (order === 'sister-first') {
207
+ const why = operation === 'ADDED' ? '' : ` (it was created before this change)`;
208
+ return `Archive '${sister}' first${why}, then rewrite this delta against the main spec that archive produces: ${syncNote}`;
209
+ }
210
+ if (order === 'this-first') {
211
+ return `This change was created before '${sister}', so archive this change first; '${sister}' must then rewrite its delta against the main spec that archive produces: ${syncNote}`;
212
+ }
213
+ return `Which of the two was created first cannot be told, so decide which archives first, then rewrite the other one's delta against the main spec that archive produces: ${syncNote}`;
214
+ }
53
215
  /**
54
216
  * One `path` notation across the report. Zod hands back a segment array that
55
217
  * `join('.')` renders as `requirements.0.scenarios`, while every hand-written
@@ -98,10 +260,35 @@ export class Validator {
98
260
  catch (error) {
99
261
  const baseMessage = error instanceof Error ? error.message : 'Unknown error';
100
262
  const enriched = this.enrichTopLevelError(specName, baseMessage);
101
- issues.push({ level: 'ERROR', path: 'file', message: enriched });
263
+ const line = error instanceof SpecHeadingLevelError ? { line: error.line } : {};
264
+ issues.push({ level: 'ERROR', path: 'file', ...line, message: enriched });
102
265
  }
103
266
  return this.createReport(issues);
104
267
  }
268
+ /**
269
+ * Whether archive would refuse to write `rebuilt`, and whose fault that is.
270
+ * Null when it would write it.
271
+ *
272
+ * The verdict counts ERRORs only, whatever this validator's strictness:
273
+ * archive judges the rebuilt spec non-strictly, and this answers for archive.
274
+ *
275
+ * A main spec that already fails is the cause, even if the delta adds its own
276
+ * faults: those the change's validation reports anyway, while the main
277
+ * spec's are reported nowhere a change author looks — and its issue paths,
278
+ * unlike the rebuilt spec's, are the ones `tospec validate <spec>` prints.
279
+ */
280
+ async diagnoseRebuiltSpec(specName, rebuilt, original) {
281
+ const rebuiltReport = await this.validateSpecContent(specName, rebuilt);
282
+ if (!rebuiltReport.issues.some((i) => i.level === 'ERROR'))
283
+ return null;
284
+ if (original !== undefined) {
285
+ const mainReport = await this.validateSpecContent(specName, original.content);
286
+ if (mainReport.issues.some((i) => i.level === 'ERROR')) {
287
+ return { source: 'main', specPath: original.path, issues: mainReport.issues };
288
+ }
289
+ }
290
+ return { source: 'delta', issues: rebuiltReport.issues };
291
+ }
105
292
  /**
106
293
  * Validate delta-formatted spec files under a change directory: at least one
107
294
  * delta overall, ADDED/MODIFIED requirements carrying SHALL/MUST and a
@@ -115,9 +302,33 @@ export class Validator {
115
302
  * `archivePreflight` is opt-out because the dry run rebuilds every updated
116
303
  * spec. For `tospec validate` that is the point; for `archive` it is
117
304
  * duplication, since the real merge follows moments later.
305
+ *
306
+ * `requireDelta: false` is for a change whose schema demands no delta. Every
307
+ * other check still runs, because archive merges each
308
+ * specs/<capability>/spec.md whatever the schema says. Dropped: the "at least
309
+ * one delta" ERROR, and the specs/spec.md ERROR for a file with no delta
310
+ * header, which such a schema leaves as notes, as at the rest of the root.
311
+ *
312
+ * `sisterChanges: false` drops the check against other in-progress changes'
313
+ * deltas. Archive turns it off: it runs its own check, in the direction the
314
+ * archive decides (this change goes first), where this one would advise
315
+ * archiving the other change first.
118
316
  */
119
317
  async validateChangeDeltaSpecs(changeDir, mainSpecsDir, options = {}) {
120
318
  const archivePreflight = options.archivePreflight ?? true;
319
+ const requireDelta = options.requireDelta ?? true;
320
+ const checkSisterChanges = options.sisterChanges ?? true;
321
+ let sisters;
322
+ // Per change dir, read once per run however many requirements two changes share.
323
+ const orderKeys = new Map();
324
+ const orderKeyOf = (dir) => {
325
+ let key = orderKeys.get(dir);
326
+ if (key === undefined) {
327
+ key = readChangeOrderKey(dir);
328
+ orderKeys.set(dir, key);
329
+ }
330
+ return key;
331
+ };
121
332
  const issues = [];
122
333
  const specsDir = path.join(changeDir, 'specs');
123
334
  let totalDeltas = 0;
@@ -127,20 +338,15 @@ export class Validator {
127
338
  try {
128
339
  // The walk below is recursive in order to *reject* anything outside
129
340
  // specs/<capability>/spec.md: the layout is fixed at exactly one directory
130
- // level (tospec/decisions/20260730_014309-delta-spec-layout-one-level.md).
341
+ // level.
131
342
  // findSpecUpdates never went recursive, so a misplaced file that validates
132
343
  // clean is a file that archives into nothing.
133
344
  const rootSpecPath = path.join(specsDir, 'spec.md');
134
345
  // Only a regular file counts — a *directory* named spec.md is a capability
135
- // folder like any other.
136
- const rootSpecStat = await fs.stat(rootSpecPath).catch(() => null);
137
- if (rootSpecStat?.isFile() === true) {
346
+ // folder like any other. Picked by the rule the archive backstop reads.
347
+ if ((await findRootSpecDelta(specsDir, { requireDelta })) !== undefined) {
138
348
  hasMisplacedSpec = true;
139
- issues.push({
140
- level: 'ERROR',
141
- path: 'spec.md',
142
- message: 'Delta spec found at specs/spec.md. Delta specs must live in a capability folder (e.g. specs/<capability>/spec.md) — a file at the specs/ root is ignored when the change is applied or archived.',
143
- });
349
+ issues.push({ level: 'ERROR', path: 'spec.md', message: ROOT_SPEC_DELTA_MESSAGE });
144
350
  }
145
351
  // Report each misplaced file once and drop it from the delta pass below —
146
352
  // validating it as a real delta would contradict the error that just said
@@ -151,12 +357,14 @@ export class Validator {
151
357
  continue; // already reported above
152
358
  const entryPath = FileSystemUtils.toPosixPath(path.relative(specsDir, specFile));
153
359
  const segments = entryPath.split('/');
154
- // A stray .md directly at specs/ is not this guard's concern: archive's
155
- // orphan backstop treats it as clutter rather than a lost delta, and the
156
- // two must keep agreeing.
360
+ // A root .md that carries a delta header is reported by
361
+ // rootDeltaFileIssues instead, from the one list archive warns about.
157
362
  if (segments.length === 1)
158
363
  continue;
159
- if (segments.length > 2) {
364
+ // Judged by specLayoutFault, the rule apply's apply_specs_misplaced
365
+ // warning reads, so apply cannot call ready what this refuses.
366
+ const fault = specLayoutFault(entryPath);
367
+ if (fault === 'nested') {
160
368
  hasMisplacedSpec = true;
161
369
  // The fix is mechanical, so name the flattened path outright:
162
370
  // specs/platform/session/spec.md -> specs/platform-session/spec.md.
@@ -171,7 +379,7 @@ export class Validator {
171
379
  // fast-glob (which computes artifact status) skips dot-directories while
172
380
  // the merge path's readdir does not, so a dot capability would archive
173
381
  // without ever showing done.
174
- if (segments[0].startsWith('.')) {
382
+ if (fault === 'dot-folder') {
175
383
  hasMisplacedSpec = true;
176
384
  issues.push({
177
385
  level: 'ERROR',
@@ -183,7 +391,7 @@ export class Validator {
183
391
  // Right folder, wrong basename. findSpecFiles filters on
184
392
  // basename === 'spec.md', so the merge path silently drops the file;
185
393
  // findAllMarkdownFiles is what makes it visible here at all.
186
- if (segments[1] !== 'spec.md') {
394
+ if (fault === 'misnamed') {
187
395
  hasMisplacedSpec = true;
188
396
  issues.push({
189
397
  level: 'ERROR',
@@ -194,8 +402,12 @@ export class Validator {
194
402
  }
195
403
  specFiles.push(specFile);
196
404
  }
405
+ const mainSpecNames = await listMainSpecNames(mainSpecsDir);
197
406
  for (const specFile of specFiles) {
198
407
  const entryPath = FileSystemUtils.toPosixPath(path.relative(specsDir, specFile));
408
+ if (mainSpecNames !== undefined) {
409
+ issues.push(...capabilityNameIssues(entryPath, mainSpecNames));
410
+ }
199
411
  let content;
200
412
  try {
201
413
  content = await fs.readFile(specFile, 'utf-8');
@@ -230,8 +442,9 @@ export class Validator {
230
442
  // archive creates, so the main-spec Purpose rules must run before the
231
443
  // copy — otherwise the change archives and only then fails
232
444
  // `validate --all --strict`, from a source now under changes/archive/.
233
- // New capabilities only: an existing one owns its Purpose.
234
- issues.push(...(await this.collectDeltaPurposeIssues(content, entryPath, mainSpecsDir)));
445
+ // An existing capability owns its Purpose, so there only the drop is
446
+ // reported.
447
+ issues.push(...(await this.collectDeltaPurposeIssues(content, plan, entryPath, mainSpecsDir)));
235
448
  // Two identical delta headers are an editing accident, and combining
236
449
  // them would guess which one the author meant.
237
450
  for (const duplicate of plan.duplicateSections) {
@@ -242,6 +455,29 @@ export class Validator {
242
455
  message: `Delta section "${duplicate.title}" appears more than once (lines ${duplicate.lines.join(', ')}). Merge them into a single section — two headers with the same name are ambiguous about which requirements belong to which.`,
243
456
  });
244
457
  }
458
+ // WARNING, not ERROR: a requirement outside every delta section is
459
+ // usually a misplaced one, but the scan cannot tell it from prose that
460
+ // happens to quote a header, and archive merges nothing from it either way.
461
+ for (const orphan of plan.orphanRequirements) {
462
+ const where = orphan.section === null ? 'before the first "##" section' : `under "## ${orphan.section}", which is not a delta section`;
463
+ issues.push({
464
+ level: 'WARNING',
465
+ path: entryPath,
466
+ line: orphan.line,
467
+ message: `Requirement "${orphan.name}" (line ${orphan.line}) sits ${where}, so it is neither validated nor merged on archive. Move it under "## ADDED Requirements" or "## MODIFIED Requirements", or fix the section header's spelling.`,
468
+ });
469
+ }
470
+ // Only near misses of a delta title: archive also leaves `## Notes`
471
+ // unmerged, but that is a heading the author meant. WARNING for the same
472
+ // reason as above: the title may sit close to a delta title on purpose.
473
+ for (const section of plan.misspelledSections) {
474
+ issues.push({
475
+ level: 'WARNING',
476
+ path: entryPath,
477
+ line: section.line,
478
+ message: `Section "${section.title}" (line ${section.line}) is not a delta section, so nothing under it is validated or merged on archive. Rename it to "${section.suggestion}".`,
479
+ });
480
+ }
245
481
  // A present section that yields no entry is a grammar miss, not an
246
482
  // empty section: the author wrote something there. Left unreported, the
247
483
  // MODIFIED block keeps failing the scenario-loss check with no sign that
@@ -300,8 +536,13 @@ export class Validator {
300
536
  });
301
537
  continue;
302
538
  }
303
- if (seen.has(key)) {
304
- issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate requirement in ${section}: "${block.name}"` });
539
+ const earlier = findFoldedMatch(seen, key);
540
+ if (earlier !== undefined) {
541
+ issues.push({
542
+ level: 'ERROR',
543
+ path: entryPath,
544
+ message: `Duplicate requirement in ${section}: "${block.name}"${foldedDuplicateNote(earlier, key)}`,
545
+ });
305
546
  }
306
547
  else {
307
548
  seen.add(key);
@@ -314,7 +555,6 @@ export class Validator {
314
555
  // WARNING, not ERROR: the keyword check is English-only, so at
315
556
  // ERROR it blocked requirements written in another language.
316
557
  // `--strict` still refuses it.
317
- // tospec/decisions/20260814_134116-shall-must-missing-is-a-warning.md
318
558
  issues.push({
319
559
  level: 'WARNING',
320
560
  path: entryPath,
@@ -330,7 +570,6 @@ export class Validator {
330
570
  // never fire for the one delta shape where it is always true. The
331
571
  // declaration removes nothing by itself (the MODIFIED omission is the
332
572
  // mechanism), so such a change archived with the scenario intact.
333
- // tospec/decisions/20260918_230803-removed-scenarios-is-the-declared-path.md
334
573
  // Dropped from the plan once reported: a placeholder entry would
335
574
  // otherwise also fail the "matched nothing" check, naming one mistake
336
575
  // twice.
@@ -355,7 +594,7 @@ export class Validator {
355
594
  }
356
595
  // entryPath is always "<capability>/spec.md" here — misplaced and
357
596
  // dot-prefixed files were reported and dropped above.
358
- issues.push(...(await this.collectDroppedScenarioIssues(mainSpecsDir, entryPath.split('/')[0], plan.modified, entryPath, renamedToFrom, removedScenarios)));
597
+ issues.push(...(await this.collectDroppedScenarioIssues(mainSpecsDir, entryPath.split('/')[0], plan.modified, entryPath, renamedToFrom, removedScenarios, path.basename(changeDir))));
359
598
  }
360
599
  // The template requires Reason and Migration on every REMOVED entry.
361
600
  for (const entry of plan.removedEntries) {
@@ -370,8 +609,13 @@ export class Validator {
370
609
  });
371
610
  continue;
372
611
  }
373
- if (removedNames.has(key)) {
374
- issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate requirement in REMOVED: "${entry.name}"` });
612
+ const earlierRemoved = findFoldedMatch(removedNames, key);
613
+ if (earlierRemoved !== undefined) {
614
+ issues.push({
615
+ level: 'ERROR',
616
+ path: entryPath,
617
+ message: `Duplicate requirement in REMOVED: "${entry.name}"${foldedDuplicateNote(earlierRemoved, key)}`,
618
+ });
375
619
  }
376
620
  else {
377
621
  removedNames.add(key);
@@ -420,14 +664,24 @@ export class Validator {
420
664
  });
421
665
  continue;
422
666
  }
423
- if (renamedFrom.has(fromKey)) {
424
- issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate FROM in RENAMED: "${from}"` });
667
+ const earlierFrom = findFoldedMatch(renamedFrom, fromKey);
668
+ if (earlierFrom !== undefined) {
669
+ issues.push({
670
+ level: 'ERROR',
671
+ path: entryPath,
672
+ message: `Duplicate FROM in RENAMED: "${from}"${foldedDuplicateNote(earlierFrom, fromKey)}`,
673
+ });
425
674
  }
426
675
  else {
427
676
  renamedFrom.add(fromKey);
428
677
  }
429
- if (renamedTo.has(toKey)) {
430
- issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate TO in RENAMED: "${to}"` });
678
+ const earlierTo = findFoldedMatch(renamedTo, toKey);
679
+ if (earlierTo !== undefined) {
680
+ issues.push({
681
+ level: 'ERROR',
682
+ path: entryPath,
683
+ message: `Duplicate TO in RENAMED: "${to}"${foldedDuplicateNote(earlierTo, toKey)}`,
684
+ });
431
685
  }
432
686
  else {
433
687
  renamedTo.add(toKey);
@@ -452,10 +706,75 @@ export class Validator {
452
706
  if (modifiedNames.has(fromKey)) {
453
707
  issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED references old name from RENAMED. Use new header for "${to}"` });
454
708
  }
455
- if (addedNames.has(toKey)) {
709
+ // Folded, as the merge checks it: a TO that differs from an ADDED name
710
+ // only in case or spacing would land two requirements no reader can
711
+ // tell apart.
712
+ if (findFoldedMatch(addedNames, toKey) !== undefined) {
456
713
  issues.push({ level: 'ERROR', path: entryPath, message: `RENAMED TO collides with ADDED for "${to}"` });
457
714
  }
458
715
  }
716
+ // A MODIFIED replaces the whole main-spec block with the text it was
717
+ // written against, so two changes rewriting one requirement lose the
718
+ // edit of whichever archives first. When only the prose differs, the
719
+ // scenario and bullet checks above see nothing. Comparing the prose to
720
+ // the main spec would flag every deliberate rewrite, and a recorded
721
+ // base would be state tospec does not keep; the other change's delta
722
+ // is the evidence that is on disk. Read once per run, and only when
723
+ // this delta rewrites something.
724
+ const rewrites = listDeltaTouches(plan, ['MODIFIED', 'REMOVED', 'RENAMED']);
725
+ if (mainSpecsDir !== undefined && checkSisterChanges && rewrites.length > 0) {
726
+ const changesDir = path.join(path.dirname(mainSpecsDir), 'changes');
727
+ sisters ??= await listSisterChangeDeltas(changesDir, path.basename(changeDir));
728
+ const capability = entryPath.split('/')[0];
729
+ // With no main spec, the archive-blocker WARNING below already names
730
+ // every change that ADDs this capability, so a sister ADDED here
731
+ // would report one cause twice. Only when that blocker is raised:
732
+ // without the preflight, this is the only hint.
733
+ const blockerNamesAdders = archivePreflight &&
734
+ (plan.modified.length > 0 || plan.removed.length > 0 || plan.renamed.length > 0) &&
735
+ (await isMissingMainSpec(mainSpecsDir, capability));
736
+ const sisterOperations = blockerNamesAdders
737
+ ? ['MODIFIED', 'REMOVED', 'RENAMED']
738
+ : ['ADDED', 'MODIFIED', 'REMOVED', 'RENAMED'];
739
+ const mainNames = await readMainRequirementNames(mainSpecsDir, capability);
740
+ const mismatches = mainNames === undefined ? [] : findHeaderMismatches(capability, rewrites, sisters, mainNames);
741
+ for (const mismatch of mismatches) {
742
+ // No `blockedBy`: no archive order makes this delta apply, so
743
+ // naming the sister would tell an agent to wait on a fix that
744
+ // never comes.
745
+ issues.push({
746
+ level: 'ERROR',
747
+ path: entryPath,
748
+ ...(mismatch.own.line === undefined ? {} : { line: mismatch.own.line }),
749
+ code: REQUIREMENT_HEADER_MISMATCH_CODE,
750
+ suggestedHeader: mismatch.suggestedHeader,
751
+ message: headerMismatchMessage(mismatch),
752
+ });
753
+ }
754
+ for (const { change, own, sister } of findSharedRequirements(capability, rewrites, sisters, sisterOperations)) {
755
+ // The pair the mismatch ERROR names. Its overlap advice ("archive
756
+ // the sister first") holds only once the header is fixed, and the
757
+ // re-run after that fix reports it, with its `blockedBy`.
758
+ if (sister.operation === 'ADDED' && mismatches.some((m) => m.change === change && m.own === own))
759
+ continue;
760
+ const at = own.line === undefined ? '' : ` (line ${own.line})`;
761
+ const order = sister.operation === 'ADDED'
762
+ ? 'sister-first'
763
+ : compareChangeAge(await orderKeyOf(changeDir), await orderKeyOf(path.join(changesDir, change)));
764
+ // `blockedBy` only on the side told to wait. The older side is
765
+ // told to archive first and the unordered pair to decide: neither
766
+ // waits on anyone, and naming the sister there would tell an
767
+ // agent to hold this change back behind it.
768
+ issues.push({
769
+ level: 'WARNING',
770
+ path: entryPath,
771
+ ...(own.line === undefined ? {} : { line: own.line }),
772
+ code: SISTER_CHANGE_OVERLAP_CODE,
773
+ ...(order === 'sister-first' ? { blockedBy: [change] } : {}),
774
+ message: `${own.operation} "${own.requirement}"${at}: in-progress change '${change}' also acts on this requirement (${sister.operation}), and the change archived second overwrites the first one's edit with the text it was written against. ${sisterArchiveAdvice(change, sister.operation, order)}`,
775
+ });
776
+ }
777
+ }
459
778
  }
460
779
  }
461
780
  catch (error) {
@@ -483,12 +802,22 @@ export class Validator {
483
802
  // the main spec's scenarios. None asks whether the main spec can supply the
484
803
  // target the delta acts on — the merge's own preconditions.
485
804
  if (mainSpecsDir !== undefined && archivePreflight) {
486
- issues.push(...(await this.findArchiveBlockers(changeDir, mainSpecsDir)));
805
+ // A header mismatch is not a structural fault, so it does not vouch for
806
+ // a rebuilt-spec failure as already reported.
807
+ const structuralErrorPaths = new Set(issues.filter((i) => i.level === 'ERROR' && i.code !== REQUIREMENT_HEADER_MISMATCH_CODE).map((i) => i.path));
808
+ const findings = await this.findArchiveBlockers(changeDir, mainSpecsDir, structuralErrorPaths);
809
+ // Coded here, not in the dry run: apply reads the same findings and
810
+ // reports them under its own status code.
811
+ issues.push(...findings.map((finding) => finding.refuses ? { ...finding.issue, code: ARCHIVE_REFUSAL_CODE } : finding.issue));
487
812
  }
488
813
  // A misplaced-spec error already names the file and the fix; "No deltas
489
814
  // found" on top would contradict it.
490
- if (totalDeltas === 0 && !hasMisplacedSpec) {
491
- issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });
815
+ if (requireDelta && totalDeltas === 0 && !hasMisplacedSpec) {
816
+ issues.push({
817
+ level: 'ERROR',
818
+ path: 'file',
819
+ message: `${VALIDATION_MESSAGES.CHANGE_NO_DELTAS}. ${noDeltasGuide(path.basename(changeDir))}`,
820
+ });
492
821
  }
493
822
  // The ceiling `ChangeSchema.deltas.max()` declares. WARNING because the rule
494
823
  // is advice about change size, not a correctness claim; `--strict` is what
@@ -583,7 +912,6 @@ export class Validator {
583
912
  // WARNING, not ERROR: the keyword check is English-only, so at ERROR it
584
913
  // blocked requirements written in another language. `--strict` still
585
914
  // refuses it.
586
- // tospec/decisions/20260814_134116-shall-must-missing-is-a-warning.md
587
915
  if (!requirementText || !containsShallOrMust(requirementText)) {
588
916
  issues.push({
589
917
  level: 'WARNING',
@@ -663,18 +991,33 @@ export class Validator {
663
991
  /**
664
992
  * The main spec's two Purpose rules, applied to a delta that is about to
665
993
  * create that main spec. Placeholder before brevity, as on the spec path.
994
+ * For an existing capability, only that a delta Purpose will be dropped.
666
995
  *
667
996
  * `mainSpecsDir` absent means new capabilities cannot be told from existing
668
997
  * ones, so nothing is reported.
669
998
  */
670
- async collectDeltaPurposeIssues(content, entryPath, mainSpecsDir) {
999
+ async collectDeltaPurposeIssues(content, plan, entryPath, mainSpecsDir) {
671
1000
  if (mainSpecsDir === undefined)
672
1001
  return [];
673
1002
  const purpose = extractDeltaPurpose(content);
674
1003
  const capability = entryPath.split('/')[0];
1004
+ const mainSpecPath = path.join(mainSpecsDir, capability, 'spec.md');
675
1005
  try {
676
- await fs.access(path.join(mainSpecsDir, capability, 'spec.md'));
677
- return []; // Capability exists: its own spec owns the Purpose.
1006
+ await fs.access(mainSpecPath);
1007
+ // Capability exists: its own spec owns the Purpose, and archive drops the
1008
+ // delta's with `archive_delta_purpose_ignored`. Said here too, worded as
1009
+ // that notice is, because archive is too late: the author who wrote it
1010
+ // meant to change the Purpose, and by then the change is archived.
1011
+ if (!purpose)
1012
+ return [];
1013
+ const onDisk = await resolveOnDiskSpecName(mainSpecPath, capability);
1014
+ return [
1015
+ {
1016
+ level: 'WARNING',
1017
+ path: entryPath,
1018
+ message: `Delta "## Purpose" will be ignored on archive; ${onDisk} already has a main spec and owns its Purpose. Edit tospec/specs/${onDisk}/spec.md directly if it needs changing, and remove the Purpose from this delta.`,
1019
+ },
1020
+ ];
678
1021
  }
679
1022
  catch (error) {
680
1023
  // Only a genuinely absent spec means "new capability"; guessing "new"
@@ -682,6 +1025,13 @@ export class Validator {
682
1025
  if (!isMissingPathError(error))
683
1026
  return [];
684
1027
  }
1028
+ // Archive refuses a MODIFIED, REMOVED or RENAMED on a capability with no
1029
+ // main spec before it reads the Purpose, and the archive-blocker WARNING
1030
+ // says so. Advice on the Purpose here sent authors to add one, which turns
1031
+ // into "will be ignored on archive" once the change that adds the
1032
+ // capability archives first — the usual way out of this state.
1033
+ if (plan.modified.length > 0 || plan.removed.length > 0 || plan.renamed.length > 0)
1034
+ return [];
685
1035
  // WARNING, not ERROR: the schema calls the Purpose mandatory, but archive
686
1036
  // still completes with a placeholder, so this is advice on where the text
687
1037
  // belongs rather than a merge precondition. Reported here because archive
@@ -729,7 +1079,9 @@ export class Validator {
729
1079
  * author means to drop, so omitting it from the MODIFIED block stops being
730
1080
  * the accident this check exists to catch.
731
1081
  */
732
- declaredRemovals) {
1082
+ declaredRemovals,
1083
+ /** Named in the `show --diff` hint, so the hint runs as written. */
1084
+ changeName) {
733
1085
  let content;
734
1086
  try {
735
1087
  content = await fs.readFile(path.join(mainSpecsDir, capability, 'spec.md'), 'utf-8');
@@ -838,19 +1190,24 @@ export class Validator {
838
1190
  message: `MODIFIED "${block.name}"${target} drops bullet(s) the current spec still has: ${dropped
839
1191
  .map((line) => `"${line}"`)
840
1192
  .join(', ')}. Confirm this is an intentional rewrite and not a block written against an older ` +
841
- `copy of the requirement — run tospec show <change> --diff to see the full comparison.`,
1193
+ `copy of the requirement — run tospec show "${changeName}" --diff to see the full comparison.`,
842
1194
  });
843
1195
  }
844
1196
  }
845
1197
  // A declaration that matched nothing: the author believes a scenario was
846
1198
  // removed, the spec still has it, and every command reports success. ERROR
847
1199
  // because there is no reading of it that is correct.
1200
+ const renamed = [...renamedToFrom].map(([to, from]) => ({ from, to }));
848
1201
  for (const entry of unmatchedRemovals) {
1202
+ const renameHint = postRenameRemovalHint(entry, renamed);
849
1203
  issues.push({
850
1204
  level: 'ERROR',
851
1205
  path: entryPath,
852
1206
  line: entry.startLine,
853
- message: `REMOVED Scenarios declares "${entry.scenario}" under requirement "${entry.requirement}", but no MODIFIED block for that requirement drops that scenario. Check both names against tospec/specs/${capability}/spec.md — a declaration that matches nothing removes nothing.`,
1207
+ message: `REMOVED Scenarios declares "${entry.scenario}" under requirement "${entry.requirement}", but no MODIFIED block for that requirement drops that scenario. ` +
1208
+ (renameHint === undefined
1209
+ ? `Check both names against tospec/specs/${capability}/spec.md — a declaration that matches nothing removes nothing.`
1210
+ : `${renameHint}.`),
854
1211
  });
855
1212
  }
856
1213
  return issues;
@@ -867,13 +1224,13 @@ export class Validator {
867
1224
  * is missing is also what a change modifying a sibling's unarchived
868
1225
  * requirement looks like. INFO was wrong because `--strict` ignores it, so the
869
1226
  * pre-archive gate waved through changes archive would refuse.
870
- * tospec/decisions/20260916_154000-archive-dry-run-findings-are-warnings.md
871
1227
  */
872
- async findArchiveBlockers(changeDir, mainSpecsDir) {
1228
+ async findArchiveBlockers(changeDir, mainSpecsDir, structuralErrorPaths) {
873
1229
  // Only reaches the generated skeleton's placeholder Purpose, which this dry
874
1230
  // run discards with the rest of the rebuilt content.
875
1231
  const changeName = path.basename(changeDir);
876
- const issues = [];
1232
+ const findings = [];
1233
+ let sisters;
877
1234
  let updates;
878
1235
  try {
879
1236
  updates = await findSpecUpdates(changeDir, mainSpecsDir);
@@ -883,9 +1240,12 @@ export class Validator {
883
1240
  // the structural pass walks the same tree and its findings are actionable.
884
1241
  return [
885
1242
  {
886
- level: 'INFO',
887
- path: 'specs',
888
- message: `Could not check the merge preconditions: ${error instanceof Error ? error.message : String(error)}`,
1243
+ refuses: false,
1244
+ issue: {
1245
+ level: 'INFO',
1246
+ path: 'specs',
1247
+ message: `Could not check the merge preconditions: ${error instanceof Error ? error.message : String(error)}`,
1248
+ },
889
1249
  },
890
1250
  ];
891
1251
  }
@@ -895,7 +1255,14 @@ export class Validator {
895
1255
  const capability = path.basename(path.dirname(update.source));
896
1256
  const entryPath = FileSystemUtils.toPosixPath(`${capability}/spec.md`);
897
1257
  try {
898
- const built = await buildUpdatedSpec(update, changeName, { silent: true });
1258
+ // The structural pass above already reports the merge's structural
1259
+ // failures in its own, more precise wording, so the merge defers them
1260
+ // and goes on to the preconditions checked nowhere else (a MODIFIED or
1261
+ // RENAMED target the main spec lacks, an ADDED that collides). Throwing
1262
+ // on the first used to hide those until it was fixed, one round trip
1263
+ // per finding; filtering its message afterwards could not bring back
1264
+ // the checks the throw had skipped.
1265
+ const built = await buildUpdatedSpec(update, changeName, { silent: true, deferStructural: true });
899
1266
  // The merge would succeed but delete nothing, which is not what the
900
1267
  // delta appears to say. A mistyped REMOVED header is the likeliest
901
1268
  // cause and the hardest to catch, since nothing visibly happens.
@@ -905,12 +1272,26 @@ export class Validator {
905
1272
  for (const notice of built.notices) {
906
1273
  if (!REMOVED_NOOP_NOTICE_CODES.has(notice.code))
907
1274
  continue;
908
- issues.push({
909
- level: 'WARNING',
910
- path: entryPath,
911
- message: `Archive would accept this delta but remove nothing: ${notice.message}`,
1275
+ findings.push({
1276
+ refuses: false,
1277
+ issue: {
1278
+ level: 'WARNING',
1279
+ path: entryPath,
1280
+ message: `Archive would accept this delta but remove nothing: ${notice.message}`,
1281
+ },
912
1282
  });
913
1283
  }
1284
+ // Archive validates the rebuilt spec before writing it, so a main spec
1285
+ // that is already broken refuses every delta aimed at it — while the
1286
+ // checks above, which read only the delta, call the change valid.
1287
+ // A retired capability is deleted, not validated. A deferred merge is
1288
+ // partial, so judging it would report the structural failures again
1289
+ // under positional paths.
1290
+ if (!built.retired && built.structuralFailures.length === 0) {
1291
+ const specName = path.basename(path.dirname(update.target));
1292
+ const blockers = await this.findRebuiltSpecBlockers(specName, entryPath, built, structuralErrorPaths);
1293
+ findings.push(...blockers.map((issue) => ({ refuses: true, issue })));
1294
+ }
914
1295
  }
915
1296
  catch (error) {
916
1297
  // Only the thrown preconditions, which carry no errno. A filesystem
@@ -918,24 +1299,124 @@ export class Validator {
918
1299
  // `validate --all` a transient EMFILE would read as a collision.
919
1300
  if (error?.code !== undefined)
920
1301
  continue;
921
- const message = error instanceof Error ? error.message : String(error);
922
- // The structural pass above already reported these in its own, more
923
- // precise wording; repeating them in the merge's would name the same
924
- // mistake twice. Filtered by *kind* rather than by path: the merge's
925
- // other preconditions (a MODIFIED or RENAMED target the main spec does
926
- // not have, an ADDED that collides) are checked nowhere else, so a delta
927
- // with one unrelated grammar error used to hide them until that error
928
- // was fixed — one round trip per finding.
929
- if (STRUCTURAL_MERGE_FAILURE.test(message))
930
- continue;
931
- issues.push({
932
- level: 'WARNING',
933
- path: entryPath,
934
- message: `Archive would refuse this delta: ${message}`,
1302
+ let message = error instanceof Error ? error.message : String(error);
1303
+ // The missing capability, or the requirement a MODIFIED or RENAMED
1304
+ // names, may be one a sister change is still adding; then the fix is
1305
+ // archiving that change, not editing this delta. Read once per run and
1306
+ // only for these refusals, the ones it can explain. `blockedBy` comes
1307
+ // from the list the hint is written from, so the field and the
1308
+ // sentence cannot name different changes.
1309
+ let blockedBy = [];
1310
+ let suggestedHeader;
1311
+ let headerCorrection;
1312
+ if (error instanceof RequirementNearMissError) {
1313
+ // A typo against the main spec, whose name is settled, so the field
1314
+ // carries it: the agent fixes this WARNING itself, and reading the
1315
+ // name out of the merge's sentence would break on its rewording.
1316
+ suggestedHeader = error.existing;
1317
+ headerCorrection = { operation: error.operation, source: 'main-spec' };
1318
+ }
1319
+ else if (error instanceof MissingTargetSpecError || error instanceof MissingTargetRequirementError) {
1320
+ sisters ??= await listSisterChangeDeltas(path.join(path.dirname(mainSpecsDir), 'changes'), changeName);
1321
+ const requirement = error instanceof MissingTargetRequirementError ? error.requirement : undefined;
1322
+ // A header the sister ADDs under another spelling is a second cause
1323
+ // no archive order settles, so the order is not the whole cause and
1324
+ // neither the hint nor `blockedBy` may say it is. The mismatch ERROR
1325
+ // reports it; `suggestedHeader` rides here so apply can name the fix.
1326
+ const mismatch = (await this.findDeltaHeaderMismatches(update.source, mainSpecsDir, capability, sisters, error))[0];
1327
+ if (mismatch !== undefined) {
1328
+ suggestedHeader = mismatch.suggestedHeader;
1329
+ headerCorrection = { operation: mismatch.own.operation, source: 'sister-change' };
1330
+ }
1331
+ else {
1332
+ blockedBy = addingChanges(sisters, capability, requirement);
1333
+ const hint = addingChangeHint(blockedBy, requirement === undefined ? 'capability' : 'requirement');
1334
+ if (hint !== undefined)
1335
+ message = appendSentence(message, hint);
1336
+ }
1337
+ }
1338
+ findings.push({
1339
+ refuses: true,
1340
+ issue: {
1341
+ level: 'WARNING',
1342
+ path: entryPath,
1343
+ message: `Archive would refuse this delta: ${message}`,
1344
+ ...(blockedBy.length > 0 ? { blockedBy } : {}),
1345
+ ...(suggestedHeader === undefined ? {} : { suggestedHeader }),
1346
+ },
1347
+ ...(headerCorrection === undefined ? {} : { headerCorrection }),
935
1348
  });
936
1349
  }
937
1350
  }
938
- return issues;
1351
+ return findings;
1352
+ }
1353
+ /**
1354
+ * The header mismatches behind a missing-target refusal: the one requirement
1355
+ * a MODIFIED or RENAMED names, or, for a capability with no main spec, every
1356
+ * rewrite in the delta, since the merge stops before reading any of them.
1357
+ * Through `findHeaderMismatches`, so the refusal and the sister scan's
1358
+ * ERROR cannot disagree about which headers mismatch.
1359
+ */
1360
+ async findDeltaHeaderMismatches(deltaSource, mainSpecsDir, capability, sisters, error) {
1361
+ const mainNames = await readMainRequirementNames(mainSpecsDir, capability);
1362
+ if (mainNames === undefined)
1363
+ return [];
1364
+ let touches;
1365
+ if (error instanceof MissingTargetRequirementError) {
1366
+ touches = [{ operation: error.operation, requirement: error.requirement }];
1367
+ }
1368
+ else {
1369
+ try {
1370
+ touches = listDeltaTouches(parseDeltaSpec(await fs.readFile(deltaSource, 'utf-8')), ['MODIFIED', 'REMOVED', 'RENAMED']);
1371
+ }
1372
+ catch {
1373
+ return [];
1374
+ }
1375
+ }
1376
+ return findHeaderMismatches(capability, touches, sisters, mainNames);
1377
+ }
1378
+ /**
1379
+ * What archive's merge would refuse in this change, from the dry run above,
1380
+ * for `instructions apply` to report before the work starts: it said ready
1381
+ * for a delta validate already called unarchivable, so the agent learned it
1382
+ * after implementing. One dry run serves both, so they cannot disagree.
1383
+ *
1384
+ * Without the structural pass's paths: apply reports none of its findings,
1385
+ * so a delta fault that resurfaces in the rebuilt spec is the only word apply
1386
+ * gets of it. Suppressed there as already reported, apply would say nothing.
1387
+ */
1388
+ async findArchiveRefusals(changeDir, mainSpecsDir) {
1389
+ const findings = await this.findArchiveBlockers(changeDir, mainSpecsDir, new Set());
1390
+ return findings
1391
+ .filter((finding) => finding.refuses)
1392
+ .map(({ issue, headerCorrection }) => (headerCorrection === undefined ? { issue } : { issue, headerCorrection }));
1393
+ }
1394
+ /**
1395
+ * The rebuilt spec's validation failures, as archive would meet them. WARNING
1396
+ * for the reason `findArchiveBlockers` gives.
1397
+ */
1398
+ async findRebuiltSpecBlockers(specName, entryPath, built, structuralErrorPaths) {
1399
+ const failure = await this.diagnoseRebuiltSpec(specName, built.rebuilt, built.original);
1400
+ if (failure === null)
1401
+ return [];
1402
+ // A delta fault the structural pass already reported for this file
1403
+ // resurfaces in the rebuilt spec as the same mistake under a positional
1404
+ // path (ADDED without a scenario becomes `requirements[3].scenarios`).
1405
+ // Repeating it names one mistake twice; the structural ERROR already fails
1406
+ // the change. Only a fault that pass cannot see is reported here.
1407
+ if (failure.source === 'delta' && structuralErrorPaths.has(entryPath))
1408
+ return [];
1409
+ // The finding goes last because some messages end in a multi-line example.
1410
+ const lead = failure.source === 'main'
1411
+ ? `Archive would refuse this delta: the main spec ${failure.specPath} already fails validation, so fix it first (the delta is not the cause). In ${failure.specPath}, `
1412
+ : 'Archive would refuse this delta: the rebuilt spec fails validation. ';
1413
+ return failure.issues
1414
+ .filter((i) => i.level === 'ERROR')
1415
+ .map((issue) => ({
1416
+ level: 'WARNING',
1417
+ path: entryPath,
1418
+ message: `${lead}${issue.path}: ${issue.message}`,
1419
+ }));
939
1420
  }
940
1421
  /**
941
1422
  * Scenario-quality rules for a delta requirement block: only `#### Scenario:`