@seanmars/tospec 0.19.0-beta.1 → 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 (428) hide show
  1. package/CHANGELOG.md +530 -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/rules/tospec/decision.md +3 -0
  6. package/assets/rules/tospec/single-source-of-truth.md +19 -0
  7. package/dist/cli/index.d.ts.map +1 -1
  8. package/dist/cli/index.js +254 -83
  9. package/dist/cli/index.js.map +1 -1
  10. package/dist/commands/config.d.ts +9 -17
  11. package/dist/commands/config.d.ts.map +1 -1
  12. package/dist/commands/config.js +374 -98
  13. package/dist/commands/config.js.map +1 -1
  14. package/dist/commands/dashboard.d.ts +64 -81
  15. package/dist/commands/dashboard.d.ts.map +1 -1
  16. package/dist/commands/dashboard.js +346 -230
  17. package/dist/commands/dashboard.js.map +1 -1
  18. package/dist/commands/decision.d.ts +41 -19
  19. package/dist/commands/decision.d.ts.map +1 -1
  20. package/dist/commands/decision.js +400 -70
  21. package/dist/commands/decision.js.map +1 -1
  22. package/dist/commands/metrics.d.ts +28 -51
  23. package/dist/commands/metrics.d.ts.map +1 -1
  24. package/dist/commands/metrics.js +69 -85
  25. package/dist/commands/metrics.js.map +1 -1
  26. package/dist/commands/shared-output.d.ts +18 -21
  27. package/dist/commands/shared-output.d.ts.map +1 -1
  28. package/dist/commands/shared-output.js +49 -23
  29. package/dist/commands/shared-output.js.map +1 -1
  30. package/dist/commands/show.d.ts +7 -0
  31. package/dist/commands/show.d.ts.map +1 -1
  32. package/dist/commands/show.js +39 -8
  33. package/dist/commands/show.js.map +1 -1
  34. package/dist/commands/validate.d.ts +47 -30
  35. package/dist/commands/validate.d.ts.map +1 -1
  36. package/dist/commands/validate.js +282 -108
  37. package/dist/commands/validate.js.map +1 -1
  38. package/dist/commands/workflow/index.d.ts +6 -10
  39. package/dist/commands/workflow/index.d.ts.map +1 -1
  40. package/dist/commands/workflow/index.js +6 -10
  41. package/dist/commands/workflow/index.js.map +1 -1
  42. package/dist/commands/workflow/instructions.d.ts +21 -8
  43. package/dist/commands/workflow/instructions.d.ts.map +1 -1
  44. package/dist/commands/workflow/instructions.js +254 -95
  45. package/dist/commands/workflow/instructions.js.map +1 -1
  46. package/dist/commands/workflow/new-change.d.ts +4 -5
  47. package/dist/commands/workflow/new-change.d.ts.map +1 -1
  48. package/dist/commands/workflow/new-change.js +90 -25
  49. package/dist/commands/workflow/new-change.js.map +1 -1
  50. package/dist/commands/workflow/schemas.d.ts +3 -5
  51. package/dist/commands/workflow/schemas.d.ts.map +1 -1
  52. package/dist/commands/workflow/schemas.js +37 -11
  53. package/dist/commands/workflow/schemas.js.map +1 -1
  54. package/dist/commands/workflow/shared.d.ts +48 -21
  55. package/dist/commands/workflow/shared.d.ts.map +1 -1
  56. package/dist/commands/workflow/shared.js +36 -33
  57. package/dist/commands/workflow/shared.js.map +1 -1
  58. package/dist/commands/workflow/status.d.ts +10 -6
  59. package/dist/commands/workflow/status.d.ts.map +1 -1
  60. package/dist/commands/workflow/status.js +84 -42
  61. package/dist/commands/workflow/status.js.map +1 -1
  62. package/dist/commands/workflow/templates.d.ts +10 -3
  63. package/dist/commands/workflow/templates.d.ts.map +1 -1
  64. package/dist/commands/workflow/templates.js +39 -40
  65. package/dist/commands/workflow/templates.js.map +1 -1
  66. package/dist/core/archive.d.ts +26 -21
  67. package/dist/core/archive.d.ts.map +1 -1
  68. package/dist/core/archive.js +386 -205
  69. package/dist/core/archive.js.map +1 -1
  70. package/dist/core/artifact-graph/graph.d.ts +29 -36
  71. package/dist/core/artifact-graph/graph.d.ts.map +1 -1
  72. package/dist/core/artifact-graph/graph.js +50 -58
  73. package/dist/core/artifact-graph/graph.js.map +1 -1
  74. package/dist/core/artifact-graph/index.d.ts +1 -1
  75. package/dist/core/artifact-graph/index.d.ts.map +1 -1
  76. package/dist/core/artifact-graph/index.js +1 -1
  77. package/dist/core/artifact-graph/index.js.map +1 -1
  78. package/dist/core/artifact-graph/instruction-loader.d.ts +105 -100
  79. package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
  80. package/dist/core/artifact-graph/instruction-loader.js +176 -110
  81. package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
  82. package/dist/core/artifact-graph/outputs.d.ts +11 -14
  83. package/dist/core/artifact-graph/outputs.d.ts.map +1 -1
  84. package/dist/core/artifact-graph/outputs.js +47 -29
  85. package/dist/core/artifact-graph/outputs.js.map +1 -1
  86. package/dist/core/artifact-graph/resolver.d.ts +44 -63
  87. package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
  88. package/dist/core/artifact-graph/resolver.js +85 -86
  89. package/dist/core/artifact-graph/resolver.js.map +1 -1
  90. package/dist/core/artifact-graph/schema.d.ts +0 -6
  91. package/dist/core/artifact-graph/schema.d.ts.map +1 -1
  92. package/dist/core/artifact-graph/schema.js +7 -32
  93. package/dist/core/artifact-graph/schema.js.map +1 -1
  94. package/dist/core/artifact-graph/state.d.ts +1 -8
  95. package/dist/core/artifact-graph/state.d.ts.map +1 -1
  96. package/dist/core/artifact-graph/state.js +2 -17
  97. package/dist/core/artifact-graph/state.js.map +1 -1
  98. package/dist/core/artifact-graph/stub-detection.d.ts +12 -0
  99. package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -0
  100. package/dist/core/artifact-graph/stub-detection.js +39 -0
  101. package/dist/core/artifact-graph/stub-detection.js.map +1 -0
  102. package/dist/core/artifact-graph/types.d.ts +4 -0
  103. package/dist/core/artifact-graph/types.d.ts.map +1 -1
  104. package/dist/core/artifact-graph/types.js +30 -10
  105. package/dist/core/artifact-graph/types.js.map +1 -1
  106. package/dist/core/available-tools.d.ts +3 -12
  107. package/dist/core/available-tools.d.ts.map +1 -1
  108. package/dist/core/available-tools.js +4 -13
  109. package/dist/core/available-tools.js.map +1 -1
  110. package/dist/core/change-metadata/schema.d.ts +1 -1
  111. package/dist/core/change-metadata/schema.d.ts.map +1 -1
  112. package/dist/core/change-metadata/schema.js +10 -7
  113. package/dist/core/change-metadata/schema.js.map +1 -1
  114. package/dist/core/change-presenter.d.ts +23 -18
  115. package/dist/core/change-presenter.d.ts.map +1 -1
  116. package/dist/core/change-presenter.js +102 -43
  117. package/dist/core/change-presenter.js.map +1 -1
  118. package/dist/core/change-status-policy.d.ts +8 -1
  119. package/dist/core/change-status-policy.d.ts.map +1 -1
  120. package/dist/core/change-status-policy.js +25 -1
  121. package/dist/core/change-status-policy.js.map +1 -1
  122. package/dist/core/codex-metrics.d.ts +25 -45
  123. package/dist/core/codex-metrics.d.ts.map +1 -1
  124. package/dist/core/codex-metrics.js +44 -88
  125. package/dist/core/codex-metrics.js.map +1 -1
  126. package/dist/core/codex-residue.d.ts +22 -0
  127. package/dist/core/codex-residue.d.ts.map +1 -0
  128. package/dist/core/codex-residue.js +61 -0
  129. package/dist/core/codex-residue.js.map +1 -0
  130. package/dist/core/command-generation/adapters/claude.d.ts +2 -9
  131. package/dist/core/command-generation/adapters/claude.d.ts.map +1 -1
  132. package/dist/core/command-generation/adapters/claude.js +2 -12
  133. package/dist/core/command-generation/adapters/claude.js.map +1 -1
  134. package/dist/core/command-generation/adapters/index.d.ts +1 -9
  135. package/dist/core/command-generation/adapters/index.d.ts.map +1 -1
  136. package/dist/core/command-generation/adapters/index.js +1 -9
  137. package/dist/core/command-generation/adapters/index.js.map +1 -1
  138. package/dist/core/command-generation/generator.d.ts +0 -17
  139. package/dist/core/command-generation/generator.d.ts.map +1 -1
  140. package/dist/core/command-generation/generator.js +0 -17
  141. package/dist/core/command-generation/generator.js.map +1 -1
  142. package/dist/core/command-generation/index.d.ts +2 -5
  143. package/dist/core/command-generation/index.d.ts.map +1 -1
  144. package/dist/core/command-generation/index.js +0 -9
  145. package/dist/core/command-generation/index.js.map +1 -1
  146. package/dist/core/command-generation/types.d.ts +10 -36
  147. package/dist/core/command-generation/types.d.ts.map +1 -1
  148. package/dist/core/command-generation/types.js +0 -6
  149. package/dist/core/command-generation/types.js.map +1 -1
  150. package/dist/core/command-generation/yaml.d.ts +3 -18
  151. package/dist/core/command-generation/yaml.d.ts.map +1 -1
  152. package/dist/core/command-generation/yaml.js +5 -23
  153. package/dist/core/command-generation/yaml.js.map +1 -1
  154. package/dist/core/config-prompts.d.ts +2 -4
  155. package/dist/core/config-prompts.d.ts.map +1 -1
  156. package/dist/core/config-prompts.js +2 -7
  157. package/dist/core/config-prompts.js.map +1 -1
  158. package/dist/core/config-schema.d.ts +8 -53
  159. package/dist/core/config-schema.d.ts.map +1 -1
  160. package/dist/core/config-schema.js +49 -62
  161. package/dist/core/config-schema.js.map +1 -1
  162. package/dist/core/config.d.ts +56 -0
  163. package/dist/core/config.d.ts.map +1 -1
  164. package/dist/core/config.js +73 -2
  165. package/dist/core/config.js.map +1 -1
  166. package/dist/core/converters/json-converter.d.ts.map +1 -1
  167. package/dist/core/dashboard-activity.d.ts +7 -9
  168. package/dist/core/dashboard-activity.d.ts.map +1 -1
  169. package/dist/core/dashboard-activity.js +26 -24
  170. package/dist/core/dashboard-activity.js.map +1 -1
  171. package/dist/core/dashboard-data.d.ts +40 -22
  172. package/dist/core/dashboard-data.d.ts.map +1 -1
  173. package/dist/core/dashboard-data.js +62 -70
  174. package/dist/core/dashboard-data.js.map +1 -1
  175. package/dist/core/global-config.d.ts +24 -53
  176. package/dist/core/global-config.d.ts.map +1 -1
  177. package/dist/core/global-config.js +38 -67
  178. package/dist/core/global-config.js.map +1 -1
  179. package/dist/core/init.d.ts +29 -11
  180. package/dist/core/init.d.ts.map +1 -1
  181. package/dist/core/init.js +232 -164
  182. package/dist/core/init.js.map +1 -1
  183. package/dist/core/list.d.ts +1 -1
  184. package/dist/core/list.d.ts.map +1 -1
  185. package/dist/core/list.js +121 -28
  186. package/dist/core/list.js.map +1 -1
  187. package/dist/core/local-server.d.ts +60 -50
  188. package/dist/core/local-server.d.ts.map +1 -1
  189. package/dist/core/local-server.js +94 -66
  190. package/dist/core/local-server.js.map +1 -1
  191. package/dist/core/markdown-render.d.ts +25 -0
  192. package/dist/core/markdown-render.d.ts.map +1 -0
  193. package/dist/core/markdown-render.js +94 -0
  194. package/dist/core/markdown-render.js.map +1 -0
  195. package/dist/core/migrate.d.ts +32 -15
  196. package/dist/core/migrate.d.ts.map +1 -1
  197. package/dist/core/migrate.js +220 -108
  198. package/dist/core/migrate.js.map +1 -1
  199. package/dist/core/parsers/change-parser.d.ts +7 -10
  200. package/dist/core/parsers/change-parser.d.ts.map +1 -1
  201. package/dist/core/parsers/change-parser.js +48 -56
  202. package/dist/core/parsers/change-parser.js.map +1 -1
  203. package/dist/core/parsers/markdown-parser.d.ts +8 -9
  204. package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
  205. package/dist/core/parsers/markdown-parser.js +31 -22
  206. package/dist/core/parsers/markdown-parser.js.map +1 -1
  207. package/dist/core/parsers/requirement-blocks.d.ts +43 -15
  208. package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
  209. package/dist/core/parsers/requirement-blocks.js +169 -54
  210. package/dist/core/parsers/requirement-blocks.js.map +1 -1
  211. package/dist/core/parsers/requirement-text.d.ts +73 -79
  212. package/dist/core/parsers/requirement-text.d.ts.map +1 -1
  213. package/dist/core/parsers/requirement-text.js +137 -79
  214. package/dist/core/parsers/requirement-text.js.map +1 -1
  215. package/dist/core/parsers/spec-structure.d.ts.map +1 -1
  216. package/dist/core/parsers/spec-structure.js +10 -6
  217. package/dist/core/parsers/spec-structure.js.map +1 -1
  218. package/dist/core/planning-home.js.map +1 -1
  219. package/dist/core/profiles.d.ts +3 -10
  220. package/dist/core/profiles.d.ts.map +1 -1
  221. package/dist/core/profiles.js +5 -12
  222. package/dist/core/profiles.js.map +1 -1
  223. package/dist/core/project-config.d.ts +43 -44
  224. package/dist/core/project-config.d.ts.map +1 -1
  225. package/dist/core/project-config.js +107 -82
  226. package/dist/core/project-config.js.map +1 -1
  227. package/dist/core/project-layout.d.ts +10 -18
  228. package/dist/core/project-layout.d.ts.map +1 -1
  229. package/dist/core/project-layout.js +16 -26
  230. package/dist/core/project-layout.js.map +1 -1
  231. package/dist/core/root-selection.d.ts +11 -7
  232. package/dist/core/root-selection.d.ts.map +1 -1
  233. package/dist/core/root-selection.js +7 -8
  234. package/dist/core/root-selection.js.map +1 -1
  235. package/dist/core/rules.d.ts +10 -0
  236. package/dist/core/rules.d.ts.map +1 -0
  237. package/dist/core/rules.js +43 -0
  238. package/dist/core/rules.js.map +1 -0
  239. package/dist/core/schema-names.d.ts +16 -0
  240. package/dist/core/schema-names.d.ts.map +1 -0
  241. package/dist/core/schema-names.js +16 -0
  242. package/dist/core/schema-names.js.map +1 -0
  243. package/dist/core/schemas/base.schema.d.ts +3 -0
  244. package/dist/core/schemas/base.schema.d.ts.map +1 -1
  245. package/dist/core/schemas/base.schema.js +22 -6
  246. package/dist/core/schemas/base.schema.js.map +1 -1
  247. package/dist/core/schemas/change.schema.d.ts +16 -0
  248. package/dist/core/schemas/change.schema.d.ts.map +1 -1
  249. package/dist/core/schemas/change.schema.js +41 -10
  250. package/dist/core/schemas/change.schema.js.map +1 -1
  251. package/dist/core/schemas/spec.schema.d.ts +2 -0
  252. package/dist/core/schemas/spec.schema.d.ts.map +1 -1
  253. package/dist/core/shared/index.d.ts +3 -8
  254. package/dist/core/shared/index.d.ts.map +1 -1
  255. package/dist/core/shared/index.js +3 -8
  256. package/dist/core/shared/index.js.map +1 -1
  257. package/dist/core/shared/rules-generation.d.ts +27 -8
  258. package/dist/core/shared/rules-generation.d.ts.map +1 -1
  259. package/dist/core/shared/rules-generation.js +151 -16
  260. package/dist/core/shared/rules-generation.js.map +1 -1
  261. package/dist/core/shared/skill-generation.d.ts +38 -53
  262. package/dist/core/shared/skill-generation.d.ts.map +1 -1
  263. package/dist/core/shared/skill-generation.js +82 -51
  264. package/dist/core/shared/skill-generation.js.map +1 -1
  265. package/dist/core/shared/tool-detection.d.ts +40 -62
  266. package/dist/core/shared/tool-detection.d.ts.map +1 -1
  267. package/dist/core/shared/tool-detection.js +88 -80
  268. package/dist/core/shared/tool-detection.js.map +1 -1
  269. package/dist/core/skill-metrics.d.ts +36 -63
  270. package/dist/core/skill-metrics.d.ts.map +1 -1
  271. package/dist/core/skill-metrics.js +34 -73
  272. package/dist/core/skill-metrics.js.map +1 -1
  273. package/dist/core/spec-presenter.d.ts.map +1 -1
  274. package/dist/core/spec-presenter.js +6 -6
  275. package/dist/core/spec-presenter.js.map +1 -1
  276. package/dist/core/specs-apply.d.ts +22 -23
  277. package/dist/core/specs-apply.d.ts.map +1 -1
  278. package/dist/core/specs-apply.js +166 -193
  279. package/dist/core/specs-apply.js.map +1 -1
  280. package/dist/core/templates/fragments/interview.d.ts +2 -6
  281. package/dist/core/templates/fragments/interview.d.ts.map +1 -1
  282. package/dist/core/templates/fragments/interview.js +2 -6
  283. package/dist/core/templates/fragments/interview.js.map +1 -1
  284. package/dist/core/templates/fragments/next-step.d.ts +4 -8
  285. package/dist/core/templates/fragments/next-step.d.ts.map +1 -1
  286. package/dist/core/templates/fragments/next-step.js +4 -8
  287. package/dist/core/templates/fragments/next-step.js.map +1 -1
  288. package/dist/core/templates/fragments/verify.d.ts +9 -12
  289. package/dist/core/templates/fragments/verify.d.ts.map +1 -1
  290. package/dist/core/templates/fragments/verify.js +9 -12
  291. package/dist/core/templates/fragments/verify.js.map +1 -1
  292. package/dist/core/templates/index.d.ts +0 -6
  293. package/dist/core/templates/index.d.ts.map +1 -1
  294. package/dist/core/templates/index.js +0 -7
  295. package/dist/core/templates/index.js.map +1 -1
  296. package/dist/core/templates/skill-templates.d.ts +1 -5
  297. package/dist/core/templates/skill-templates.d.ts.map +1 -1
  298. package/dist/core/templates/skill-templates.js +0 -5
  299. package/dist/core/templates/skill-templates.js.map +1 -1
  300. package/dist/core/templates/types.d.ts +3 -7
  301. package/dist/core/templates/types.d.ts.map +1 -1
  302. package/dist/core/templates/types.js +0 -3
  303. package/dist/core/templates/types.js.map +1 -1
  304. package/dist/core/templates/workflows/apply.d.ts +3 -9
  305. package/dist/core/templates/workflows/apply.d.ts.map +1 -1
  306. package/dist/core/templates/workflows/apply.js +9 -12
  307. package/dist/core/templates/workflows/apply.js.map +1 -1
  308. package/dist/core/templates/workflows/archive.d.ts +0 -6
  309. package/dist/core/templates/workflows/archive.d.ts.map +1 -1
  310. package/dist/core/templates/workflows/archive.js +7 -5
  311. package/dist/core/templates/workflows/archive.js.map +1 -1
  312. package/dist/core/templates/workflows/decision.js +4 -4
  313. package/dist/core/templates/workflows/decision.js.map +1 -1
  314. package/dist/core/templates/workflows/explore.js +1 -1
  315. package/dist/core/templates/workflows/grill.d.ts.map +1 -1
  316. package/dist/core/templates/workflows/grill.js +0 -2
  317. package/dist/core/templates/workflows/grill.js.map +1 -1
  318. package/dist/core/templates/workflows/issue.d.ts +0 -6
  319. package/dist/core/templates/workflows/issue.d.ts.map +1 -1
  320. package/dist/core/templates/workflows/issue.js +3 -0
  321. package/dist/core/templates/workflows/issue.js.map +1 -1
  322. package/dist/core/templates/workflows/propose.d.ts +0 -6
  323. package/dist/core/templates/workflows/propose.d.ts.map +1 -1
  324. package/dist/core/templates/workflows/propose.js +0 -1
  325. package/dist/core/templates/workflows/propose.js.map +1 -1
  326. package/dist/core/templates/workflows/sync.d.ts +2 -8
  327. package/dist/core/templates/workflows/sync.d.ts.map +1 -1
  328. package/dist/core/templates/workflows/sync.js +2 -2
  329. package/dist/core/templates/workflows/sync.js.map +1 -1
  330. package/dist/core/templates/workflows/update.d.ts +0 -6
  331. package/dist/core/templates/workflows/update.d.ts.map +1 -1
  332. package/dist/core/templates/workflows/update.js.map +1 -1
  333. package/dist/core/update.d.ts +26 -21
  334. package/dist/core/update.d.ts.map +1 -1
  335. package/dist/core/update.js +165 -116
  336. package/dist/core/update.js.map +1 -1
  337. package/dist/core/user-state-migration.d.ts +13 -15
  338. package/dist/core/user-state-migration.d.ts.map +1 -1
  339. package/dist/core/user-state-migration.js +16 -20
  340. package/dist/core/user-state-migration.js.map +1 -1
  341. package/dist/core/validation/constants.d.ts +19 -25
  342. package/dist/core/validation/constants.d.ts.map +1 -1
  343. package/dist/core/validation/constants.js +25 -20
  344. package/dist/core/validation/constants.js.map +1 -1
  345. package/dist/core/validation/prose-length.d.ts +15 -0
  346. package/dist/core/validation/prose-length.d.ts.map +1 -0
  347. package/dist/core/validation/prose-length.js +29 -0
  348. package/dist/core/validation/prose-length.js.map +1 -0
  349. package/dist/core/validation/purpose-placeholder.d.ts +9 -16
  350. package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
  351. package/dist/core/validation/purpose-placeholder.js +30 -44
  352. package/dist/core/validation/purpose-placeholder.js.map +1 -1
  353. package/dist/core/validation/section-validator.d.ts +4 -4
  354. package/dist/core/validation/section-validator.d.ts.map +1 -1
  355. package/dist/core/validation/section-validator.js +43 -7
  356. package/dist/core/validation/section-validator.js.map +1 -1
  357. package/dist/core/validation/task-numbering.d.ts +6 -3
  358. package/dist/core/validation/task-numbering.d.ts.map +1 -1
  359. package/dist/core/validation/task-numbering.js +23 -11
  360. package/dist/core/validation/task-numbering.js.map +1 -1
  361. package/dist/core/validation/types.d.ts +18 -0
  362. package/dist/core/validation/types.d.ts.map +1 -1
  363. package/dist/core/validation/types.js +12 -1
  364. package/dist/core/validation/types.js.map +1 -1
  365. package/dist/core/validation/validator.d.ts +50 -51
  366. package/dist/core/validation/validator.d.ts.map +1 -1
  367. package/dist/core/validation/validator.js +478 -269
  368. package/dist/core/validation/validator.js.map +1 -1
  369. package/dist/prompts/searchable-multi-select.d.ts +3 -8
  370. package/dist/prompts/searchable-multi-select.d.ts.map +1 -1
  371. package/dist/prompts/searchable-multi-select.js +16 -39
  372. package/dist/prompts/searchable-multi-select.js.map +1 -1
  373. package/dist/utils/change-metadata.d.ts +11 -50
  374. package/dist/utils/change-metadata.d.ts.map +1 -1
  375. package/dist/utils/change-metadata.js +48 -67
  376. package/dist/utils/change-metadata.js.map +1 -1
  377. package/dist/utils/change-utils.d.ts +31 -54
  378. package/dist/utils/change-utils.d.ts.map +1 -1
  379. package/dist/utils/change-utils.js +143 -100
  380. package/dist/utils/change-utils.js.map +1 -1
  381. package/dist/utils/file-lock.d.ts +39 -0
  382. package/dist/utils/file-lock.d.ts.map +1 -0
  383. package/dist/utils/file-lock.js +149 -0
  384. package/dist/utils/file-lock.js.map +1 -0
  385. package/dist/utils/file-system.d.ts +12 -32
  386. package/dist/utils/file-system.d.ts.map +1 -1
  387. package/dist/utils/file-system.js +16 -40
  388. package/dist/utils/file-system.js.map +1 -1
  389. package/dist/utils/frontmatter.d.ts +7 -11
  390. package/dist/utils/frontmatter.d.ts.map +1 -1
  391. package/dist/utils/frontmatter.js +11 -11
  392. package/dist/utils/frontmatter.js.map +1 -1
  393. package/dist/utils/interactive.d.ts +4 -9
  394. package/dist/utils/interactive.d.ts.map +1 -1
  395. package/dist/utils/interactive.js +2 -4
  396. package/dist/utils/interactive.js.map +1 -1
  397. package/dist/utils/item-discovery.d.ts +15 -10
  398. package/dist/utils/item-discovery.d.ts.map +1 -1
  399. package/dist/utils/item-discovery.js +42 -47
  400. package/dist/utils/item-discovery.js.map +1 -1
  401. package/dist/utils/link.d.ts +13 -4
  402. package/dist/utils/link.d.ts.map +1 -1
  403. package/dist/utils/link.js +13 -4
  404. package/dist/utils/link.js.map +1 -1
  405. package/dist/utils/match.js.map +1 -1
  406. package/dist/utils/requirement-diff.d.ts +13 -23
  407. package/dist/utils/requirement-diff.d.ts.map +1 -1
  408. package/dist/utils/requirement-diff.js +13 -23
  409. package/dist/utils/requirement-diff.js.map +1 -1
  410. package/dist/utils/spec-files.d.ts +10 -11
  411. package/dist/utils/spec-files.d.ts.map +1 -1
  412. package/dist/utils/spec-files.js +31 -22
  413. package/dist/utils/spec-files.js.map +1 -1
  414. package/dist/utils/task-progress.d.ts +11 -9
  415. package/dist/utils/task-progress.d.ts.map +1 -1
  416. package/dist/utils/task-progress.js +53 -32
  417. package/dist/utils/task-progress.js.map +1 -1
  418. package/dist/utils/timestamp.d.ts +5 -8
  419. package/dist/utils/timestamp.d.ts.map +1 -1
  420. package/dist/utils/timestamp.js +5 -8
  421. package/dist/utils/timestamp.js.map +1 -1
  422. package/package.json +9 -10
  423. package/schemas/decision/templates/decision.md +3 -1
  424. package/schemas/decision/templates/index.md +2 -2
  425. package/schemas/issue/schema.yaml +11 -2
  426. package/schemas/issue/templates/spec.md +37 -3
  427. package/schemas/sdd/schema.yaml +24 -1
  428. package/schemas/sdd/templates/spec.md +37 -3
@@ -2,44 +2,65 @@ import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
3
  import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
4
4
  import { Validator } from './validation/validator.js';
5
+ import { issueSymbol } from './validation/types.js';
5
6
  import { validateChangeArtifacts } from '../commands/validate.js';
6
7
  import { isRootSelectionError, resolveRootForCommand, toRootOutput, } from './root-selection.js';
7
8
  import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, retireSpec, } from './specs-apply.js';
9
+ import { loadChangeContext, formatChangeStatus } from './artifact-graph/index.js';
10
+ import { buildCodeFenceMask, normalizeDocument, stripBom } from './parsers/requirement-text.js';
8
11
  import { formatTimestamp } from '../utils/timestamp.js';
9
12
  import { findSpecFiles } from '../utils/spec-files.js';
10
13
  import { FileSystemUtils, isMissingPathError } from '../utils/file-system.js';
11
14
  import { isKebabId, KEBAB_ID_DESCRIPTION } from './id.js';
12
- import { findTicketPath, listActiveChangeDirs } from '../utils/item-discovery.js';
15
+ import { findTicketPaths, listActiveChangeDirs } from '../utils/item-discovery.js';
13
16
  import { METADATA_FILENAME, readChangeMetadata } from '../utils/change-metadata.js';
14
17
  import { emitSuccess, emitFailureStatus } from '../commands/shared-output.js';
15
- /**
16
- * Archive directory names are always CLI-generated from this — agents must
17
- * never assemble the timestamp themselves. Alias of the shared
18
- * `formatTimestamp` (ticket filenames use the same helper).
19
- */
18
+ import { withFileLock } from '../utils/file-lock.js';
19
+ /** Alias of `formatTimestamp`: agents must never assemble archive stamps by hand. */
20
20
  export const formatArchiveStamp = formatTimestamp;
21
+ /** `Conclusion: PASS` / `Conclusion: FAIL`, with an optional `(reason)` note. */
22
+ const SYNC_CONCLUSION_LINE = /^Conclusion:\s*(PASS|FAIL)(?:\s*\([^)]*\))?\s*$/;
21
23
  /**
22
- * Extracts the first `Conclusion: PASS|FAIL` conclusion line from a
23
- * sync-report.md body, per the format `tospec-sync`'s instruction
24
- * produces. Returns null when no conclusion line is found (unparseable report).
24
+ * Returns null when no conclusion is found, when the line is an unfilled
25
+ * template (`Conclusion: PASS | FAIL`), or when two conclusions disagree —
26
+ * each must leave the `--require-sync` gate shut.
27
+ *
28
+ * Fenced lines are masked because the `tospec-sync` skill documents this format
29
+ * *inside* a fence: a report quoting the template carries a `Conclusion: PASS`
30
+ * that was never a verdict.
25
31
  */
26
32
  export function parseSyncReportConclusion(content) {
27
- const conclusionLine = content
28
- .split(/\r?\n/)
29
- .map((line) => line.trim())
30
- .find((line) => line.startsWith('Conclusion:'));
31
- if (!conclusionLine)
32
- return null;
33
- // Reject an unfilled template such as `Conclusion: PASS | FAIL`; only an
34
- // unambiguous conclusion (optionally followed by a parenthetical note) is
35
- // machine-authoritative enough to open the archive gate.
36
- const match = conclusionLine.match(/^Conclusion:\s*(PASS|FAIL)(?:\s*\([^)]*\))?\s*$/);
37
- return match ? match[1] : null;
33
+ const lines = normalizeDocument(content).split('\n');
34
+ const fenced = buildCodeFenceMask(lines);
35
+ let verdict = null;
36
+ for (let i = 0; i < lines.length; i++) {
37
+ if (fenced[i])
38
+ continue;
39
+ const line = lines[i].trim();
40
+ if (!line.startsWith('Conclusion:'))
41
+ continue;
42
+ const match = line.match(SYNC_CONCLUSION_LINE);
43
+ // Not "keep looking": scanning past an unparseable conclusion would let a
44
+ // later, well-formed line answer for a verdict this code cannot read.
45
+ if (!match)
46
+ return null;
47
+ const found = match[1];
48
+ if (verdict === null)
49
+ verdict = found;
50
+ else if (verdict !== found)
51
+ return null; // two conclusions, no way to pick
52
+ }
53
+ return verdict;
54
+ }
55
+ /** Whether any unfenced line opens with `Conclusion:`, parseable or not. */
56
+ function hasConclusionLine(content) {
57
+ const lines = normalizeDocument(content).split('\n');
58
+ const fenced = buildCodeFenceMask(lines);
59
+ return lines.some((line, i) => !fenced[i] && line.trim().startsWith('Conclusion:'));
38
60
  }
39
61
  /**
40
- * JSON mode is non-interactive: any point where the human flow would prompt or
41
- * print prose instead throws this error, which becomes a machine-readable
42
- * status entry with a non-zero exit code.
62
+ * JSON mode is non-interactive: wherever the human flow would prompt or print
63
+ * prose, this is thrown instead and becomes a machine-readable status entry.
43
64
  */
44
65
  class ArchiveBlockedError extends Error {
45
66
  diagnostic;
@@ -68,10 +89,9 @@ function toArchiveDiagnostic(error) {
68
89
  };
69
90
  }
70
91
  /**
71
- * Move a directory from src to dest. On Windows, fs.rename() often fails with
72
- * EPERM when the directory is non-empty or another process has it open (IDE,
73
- * file watcher, antivirus). Fall back to copy-then-remove when rename fails
74
- * with EPERM or EXDEV. Exported for migrate's two-phase promotion.
92
+ * On Windows fs.rename() often fails with EPERM when the directory is non-empty
93
+ * or another process has it open (IDE, watcher, antivirus), so EPERM/EXDEV fall
94
+ * back to copy-then-remove. Exported for migrate's two-phase promotion.
75
95
  */
76
96
  export async function moveDirectory(src, dest) {
77
97
  try {
@@ -80,9 +100,8 @@ export async function moveDirectory(src, dest) {
80
100
  catch (err) {
81
101
  const code = err?.code;
82
102
  if (code === 'EPERM' || code === 'EXDEV') {
83
- // Preserve relative symlink text. Without verbatimSymlinks, Node may
84
- // rewrite links to absolute paths under `src`; removing `src` below then
85
- // leaves those copied links dangling (notably on Windows).
103
+ // verbatimSymlinks: otherwise Node rewrites links to absolute paths under
104
+ // `src`, and removing `src` below leaves the copies dangling.
86
105
  await fs.cp(src, dest, { recursive: true, verbatimSymlinks: true });
87
106
  await fs.rm(src, { recursive: true, force: true });
88
107
  }
@@ -92,16 +111,8 @@ export async function moveDirectory(src, dest) {
92
111
  }
93
112
  }
94
113
  /**
95
- * After a ticket moves into tickets/archive/, its `ref` still points at the
96
- * change's pre-archive location. Rewrite the `ref:` frontmatter line to the
97
- * archived change path (project-root-relative), keeping the referenced filename
98
- * (proposal.md or task.md). No-op if the line is absent or has no path.
99
- */
100
- /**
101
- * Appends a `## Related Decisions` section to the archived ticket linking the ADRs the
102
- * change's metadata records (`decisions`), so the permanent archive record can
103
- * be traced back to the decisions that drove it. Best-effort: unreadable
104
- * metadata or no decisions → no section, never blocks the archive.
114
+ * Links the ADRs in the change's metadata from the archived ticket. Best-effort:
115
+ * unreadable metadata or no decisions → no section, never blocks the archive.
105
116
  */
106
117
  async function appendDecisionLinks(ticketFile, archivedChangeDir, projectRoot) {
107
118
  let decisions;
@@ -115,32 +126,34 @@ async function appendDecisionLinks(ticketFile, archivedChangeDir, projectRoot) {
115
126
  return;
116
127
  // Ticket lives in tospec/tickets/archive/, decisions in tospec/decisions/.
117
128
  const lines = decisions.map((d) => `- [${d}](../../decisions/${d})`);
118
- // Append with the ticket's own line endings. `appendFile` never reads the
119
- // file, so a hardcoded '\n' left a CRLF ticket mixed: CRLF above the heading
120
- // and LF below it. Reading first is the only way to know, and a read failure
121
- // here is as harmless as the metadata failure above — skip the section rather
122
- // than block the archive.
123
- let existing;
129
+ // `appendFile` never reads the file, so a hardcoded '\n' left a CRLF ticket
130
+ // mixed. A read failure falls back to '\n' rather than returning: `appendFile`
131
+ // needs no read permission, and bailing would drop the section.
132
+ let existing = '';
124
133
  try {
125
134
  existing = await fs.readFile(ticketFile, 'utf-8');
126
135
  }
127
- catch {
128
- return;
129
- }
136
+ catch { }
130
137
  const eol = existing.includes('\r\n') ? '\r\n' : '\n';
131
138
  const block = ['', '## Related Decisions', '', ...lines, ''].join(eol);
132
139
  await fs.appendFile(ticketFile, block);
133
140
  }
134
141
  /**
135
- * Line endings survive here without special handling, deliberately: the pattern
136
- * excludes line terminators (`[^/\r\n]+`, no `s` flag), so `replace` only ever
137
- * rewrites within a line and whatever terminated it is untouched. Do not
142
+ * Repoints an archived ticket's `ref:` frontmatter at the change's new path,
143
+ * keeping the referenced filename. No-op when the line is absent.
144
+ *
145
+ * Line endings survive untouched because the pattern excludes line terminators
146
+ * (`[^/\r\n]+`, no `s` flag), so `replace` only rewrites within a line. Do not
138
147
  * "simplify" this by splitting and rejoining the file.
139
148
  */
140
149
  async function retargetTicketRef(ticketFile, archiveName) {
141
- const content = await fs.readFile(ticketFile, 'utf-8');
150
+ const raw = await fs.readFile(ticketFile, 'utf-8');
151
+ // Compared against `raw`, not the stripped text, so a ticket whose only
152
+ // difference is a BOM still gets written back without one: this rewrite is
153
+ // where tospec re-emits the file, and what it emits is UTF-8 without a BOM.
154
+ const content = stripBom(raw);
142
155
  const updated = content.replace(/^(ref:[ \t]*).*\/([^/\r\n]+)[ \t]*$/m, `$1tospec/changes/archive/${archiveName}/$2`);
143
- if (updated !== content) {
156
+ if (updated !== raw) {
144
157
  await fs.writeFile(ticketFile, updated);
145
158
  }
146
159
  }
@@ -160,47 +173,70 @@ export async function moveFile(src, dest) {
160
173
  }
161
174
  }
162
175
  }
176
+ /**
177
+ * Returned by the locked merge when a human-mode failure path already printed
178
+ * and set the exit code. Distinct from `null` so the caller cannot forget to
179
+ * handle it.
180
+ */
181
+ const writeTotalsSentinel = Symbol('archive-merge-aborted');
163
182
  export class ArchiveCommand {
164
183
  async execute(changeName, options = {}) {
165
184
  const json = !!options.json;
166
- // Same root resolution adapter as every other command (report 4.3).
167
185
  const root = await resolveRootForCommand(options, {
168
186
  json,
169
- failurePayload: { archive: null },
187
+ // An implicit root means no ancestor holds `tospec/`, so archiving can
188
+ // only fail — and with a vaguer message than the shared no_tospec_root
189
+ // diagnostic.
190
+ allowImplicitRoot: false,
191
+ failurePayload: { archive: null, root: null },
170
192
  });
171
193
  if (!root) {
172
194
  return;
173
195
  }
174
196
  if (json) {
197
+ // A gate overridden by --yes did not fail, so exit 0 stands and its
198
+ // warning rides on `status` — prose on stdout would corrupt the JSON.
199
+ const jsonWarnings = [];
175
200
  try {
176
- const result = await this.run(changeName, options, root, true);
201
+ const result = await this.run(changeName, options, root, true, jsonWarnings);
177
202
  if (!result) {
178
203
  return;
179
204
  }
180
- emitSuccess({ archive: result }, toRootOutput(root));
205
+ emitSuccess({ archive: result, ...(jsonWarnings.length ? { status: jsonWarnings } : {}) }, toRootOutput(root));
181
206
  }
182
207
  catch (error) {
183
- this.printJsonFailure(root, toArchiveDiagnostic(error));
208
+ // Gates overridden before the failure still happened, and a run that
209
+ // skipped validation *and* then failed is when the reader needs to know.
210
+ this.printJsonFailure(root, toArchiveDiagnostic(error), jsonWarnings);
184
211
  }
185
212
  return;
186
213
  }
187
- await this.run(changeName, options, root, false);
214
+ await this.run(changeName, options, root, false, []);
188
215
  }
189
- printJsonFailure(root, diagnostic) {
190
- emitFailureStatus({ archive: null, ...(root ? { root: toRootOutput(root) } : {}) }, diagnostic);
216
+ printJsonFailure(root, diagnostic, jsonWarnings = []) {
217
+ // emitFailureStatus appends the error to any `status` already in the
218
+ // payload, so the warnings keep their order and precede the failure.
219
+ emitFailureStatus({
220
+ archive: null,
221
+ ...(root ? { root: toRootOutput(root) } : {}),
222
+ ...(jsonWarnings.length ? { status: jsonWarnings } : {}),
223
+ }, diagnostic);
191
224
  }
192
225
  /**
193
- * One archive gate. JSON mode is non-interactive, so a blocked path throws;
194
- * human mode prompts; `--yes` downgrades either to a warning. Returns false
195
- * when the user declines, and the caller returns null.
196
- *
197
- * The gates used to be written out longhand at each site and had to stay in
198
- * step by hand for the "every blocked path throws in JSON mode" invariant.
226
+ * One archive gate, so the "every blocked path throws in JSON mode" invariant
227
+ * lives in one place. JSON mode throws; human mode prompts; `--yes` downgrades
228
+ * either to a warning. False means the user declined.
199
229
  */
200
230
  async gate(opts) {
201
231
  if (opts.yes) {
202
- if (!opts.json)
232
+ if (opts.json) {
233
+ // No `fix`: every gate's fix ends in "or rerun with --yes", which tells
234
+ // a reader who already passed `--yes` to do what they just did.
235
+ opts.warnings.push({ severity: 'warning', code: opts.code, message: opts.message });
236
+ }
237
+ else {
203
238
  console.error(opts.warning);
239
+ }
204
240
  return true;
205
241
  }
206
242
  if (opts.json) {
@@ -215,45 +251,78 @@ export class ArchiveCommand {
215
251
  return true;
216
252
  }
217
253
  /**
218
- * Shared archive flow. In human mode (json=false) prompts and prose match
219
- * the historical behavior and cancellations return null. In JSON mode no
220
- * prose reaches stdout and every blocked path throws.
254
+ * Required artifacts the change never produced, in schema order. Reads the
255
+ * same graph `tospec status` does, so the two cannot disagree about what
256
+ * "required" means; `skipped` and `optional` are legitimate absences.
257
+ *
258
+ * An unloadable graph returns nothing rather than throwing: this gate must not
259
+ * become a second way for archive to fail. Whatever broke the load is
260
+ * validation's to report.
261
+ */
262
+ findMissingRequiredArtifacts(projectRoot, changeName, changeDir) {
263
+ try {
264
+ // Schema left undefined: loadChangeContext reads it from the change's own
265
+ // metadata, as `status` does.
266
+ const status = formatChangeStatus(loadChangeContext(projectRoot, changeName, undefined, { changeDir }));
267
+ return status.artifacts
268
+ .filter((artifact) => !artifact.optional && artifact.status !== 'done' && artifact.status !== 'skipped')
269
+ // A stub's file is on disk holding the template, so reporting it like an
270
+ // absent one sends the reader looking for a file that is right there.
271
+ .map((artifact) => (artifact.status === 'stub' ? `${artifact.id} (stub)` : artifact.id));
272
+ }
273
+ catch {
274
+ return [];
275
+ }
276
+ }
277
+ /**
278
+ * Shared archive flow. Human mode prompts and returns null on cancellation;
279
+ * JSON mode keeps stdout clean and throws from every blocked path.
221
280
  */
222
- async run(changeName, options, root, json) {
281
+ async run(changeName, options, root, json, jsonWarnings) {
223
282
  const changesDir = root.changesDir;
224
283
  const archiveDir = root.archiveDir;
225
284
  const mainSpecsDir = root.specsDir;
226
- // Check if changes directory exists
227
285
  try {
228
286
  await fs.access(changesDir);
229
287
  }
230
- catch {
231
- throw new Error("No Tospec changes directory found. Run 'tospec init' first.");
288
+ catch (error) {
289
+ // Only an absent directory is "run init"; a directory that exists but
290
+ // cannot be entered (EACCES) is a different problem with a different fix.
291
+ if (!isMissingPathError(error))
292
+ throw error;
293
+ throw new ArchiveBlockedError('changes_dir_missing', 'No Tospec changes directory found.', "Run 'tospec init' first.");
232
294
  }
233
- // Get change name interactively if not provided
295
+ // Captured before the interactive branch overwrites `changeName`: the kebab
296
+ // guard below applies to the argument only, never to what the picker
297
+ // returned. An empty string is the absence of a name.
298
+ const nameFromArgument = typeof changeName === 'string' && changeName.length > 0;
234
299
  if (!changeName) {
235
300
  if (json) {
236
301
  throw new ArchiveBlockedError('archive_change_name_required', 'A change name is required: archive --json is non-interactive.', 'tospec archive <change-name> --json');
237
302
  }
303
+ // Without a terminal the picker cannot be answered: it would paint its
304
+ // menu into the pipe and then read EOF as "nothing chosen".
305
+ if (!process.stdin.isTTY) {
306
+ throw new ArchiveBlockedError('archive_change_name_required', 'A change name is required when stdin is not a terminal.', 'tospec archive <change-name>');
307
+ }
238
308
  const selectedChange = await this.selectChange(changesDir, root.path);
239
309
  if (!selectedChange) {
310
+ // The same outcome as Ctrl-C on the confirmation prompt, so the same
311
+ // exit code: an abort the user chose is not a successful run.
240
312
  console.error('No change selected. Aborting.');
313
+ process.exitCode = 130;
241
314
  return null;
242
315
  }
243
316
  changeName = selectedChange;
244
317
  }
245
- // Only validate a name that came from the argument. The interactive
246
- // selection path lists real directories, some of which predate this
247
- // constraint in existing projects — rejecting one the CLI just offered would
248
- // make an archivable change unarchivable.
249
- if (!isKebabId(changeName)) {
318
+ // The picker lists real directories, some predating this constraint;
319
+ // rejecting one the CLI just offered would make it unarchivable.
320
+ if (nameFromArgument && !isKebabId(changeName)) {
250
321
  throw new ArchiveBlockedError('archive_change_name_invalid', `Change name '${changeName}' is not a valid change id — it ${KEBAB_ID_DESCRIPTION}.`);
251
322
  }
252
323
  const changeDir = path.join(changesDir, changeName);
253
- // Verify change exists. `.catch` rather than a try/catch around the branch:
254
- // the old shape threw `Change not found` from inside its own try, so that
255
- // branch was dead code, and the bare catch also rewrote genuine IO failures
256
- // (EACCES, EIO) as "not found". Only a missing path may answer that.
324
+ // Only a missing path may answer "not found" — a bare catch here rewrote
325
+ // genuine IO failures (EACCES, EIO) as a nonexistent change.
257
326
  const stat = await fs.stat(changeDir).catch((error) => {
258
327
  if (isMissingPathError(error))
259
328
  return null;
@@ -265,14 +334,10 @@ export class ArchiveCommand {
265
334
  ? `Change '${changeName}' not found. Available changes: ${available.join(', ')}`
266
335
  : `Change '${changeName}' not found. No active changes exist in this root.`);
267
336
  }
268
- // Reserve the archive name before anything is written. This check needs only
269
- // the timestamp and the change name — no merge result — but it used to run
270
- // *after* the main specs had been written, so a collision left specs merged
271
- // with the change still in tospec/changes/: a retry re-applied the same
272
- // deltas, and REMOVED/RENAMED then took their "already synced" branch with
273
- // counts that no longer described reality. The stamp is computed once here
274
- // and reused for the move below, so the directory that was checked is the
275
- // directory that gets created.
337
+ // Reserve the archive name before anything is written. Checking after the
338
+ // merge left specs applied with the change still in tospec/changes/, so a
339
+ // retry re-applied the same deltas. The stamp is computed once and reused
340
+ // below, so the checked directory is the one made.
276
341
  const archiveName = `${formatArchiveStamp(new Date())}-${changeName}`;
277
342
  const archivePath = path.join(archiveDir, archiveName);
278
343
  const archiveTargetStat = await fs.stat(archivePath).catch((error) => {
@@ -283,40 +348,53 @@ export class ArchiveCommand {
283
348
  if (archiveTargetStat) {
284
349
  throw new ArchiveBlockedError('archive_target_exists', `Archive '${archiveName}' already exists.`);
285
350
  }
286
- // --require-sync gate: the machine-checkable half of the
287
- // tospec-sync workflow (the semantic half — does the report's content
288
- // actually reflect reality — is the sync agent's job, not ours).
351
+ // The machine-checkable half of tospec-sync; whether the report's content
352
+ // reflects reality is the sync agent's job, not ours.
289
353
  if (options.requireSync) {
290
354
  const syncReportPath = path.join(changeDir, 'sync-report.md');
291
355
  let syncReportContent;
292
356
  try {
293
357
  syncReportContent = await fs.readFile(syncReportPath, 'utf-8');
294
358
  }
295
- catch {
296
- throw new ArchiveBlockedError('SYNC_REPORT_MISSING', `sync-report.md not found for change '${changeName}'.`, 'Run tospec-sync first, or omit --require-sync.');
359
+ catch (error) {
360
+ // "Run tospec-sync" is the fix for an absent report only; an unreadable
361
+ // one (EACCES) would be re-written by sync and still not be readable.
362
+ if (!isMissingPathError(error))
363
+ throw error;
364
+ throw new ArchiveBlockedError('sync_report_missing', `sync-report.md not found for change '${changeName}'.`, 'Run tospec-sync first, or omit --require-sync.');
297
365
  }
298
366
  const conclusion = parseSyncReportConclusion(syncReportContent);
299
367
  if (conclusion !== 'PASS') {
300
- throw new ArchiveBlockedError('SYNC_REPORT_FAILED', `sync-report.md for change '${changeName}' does not report a PASS conclusion (found: ${conclusion ?? 'unparseable'}).`, 'Resolve the flagged Requirement(s) via tospec-apply, then re-run tospec-sync.');
368
+ // Three states, three fixes: FAIL means requirements to resolve; no
369
+ // `Conclusion:` line means the sync report was never finished; a line
370
+ // that is there but unreadable (an unfilled `PASS | FAIL` template, or
371
+ // two verdicts that disagree) means the report needs editing, not
372
+ // another apply pass.
373
+ const found = conclusion ?? (hasConclusionLine(syncReportContent) ? 'unparseable Conclusion line' : 'no Conclusion line');
374
+ throw new ArchiveBlockedError('sync_report_failed', `sync-report.md for change '${changeName}' does not report a PASS conclusion (found: ${found}).`, conclusion === 'FAIL'
375
+ ? 'Resolve the flagged Requirement(s) via tospec-apply, then re-run tospec-sync.'
376
+ : 'Finish the report with a single `Conclusion: PASS` or `Conclusion: FAIL` line outside any code fence, or re-run tospec-sync.');
301
377
  }
302
378
  }
303
379
  const skipValidation = options.validate === false || options.noValidate === true;
304
- // Validate specs and change before archiving — the exact same pipeline
305
- // `tospec validate` runs, so validate green ⟺ archive passes validation.
380
+ // The exact pipeline `tospec validate` runs, minus the archive preflight —
381
+ // that is a dry run of the merge performed below, which raises
382
+ // archive_spec_update_failed itself.
306
383
  if (!skipValidation) {
307
- const report = await validateChangeArtifacts(root.path, changeDir);
384
+ const report = await validateChangeArtifacts(root.path, changeDir, {
385
+ archivePreflight: false,
386
+ });
308
387
  if (!json && report.issues.length > 0) {
309
388
  console.error(`\nValidation issues for change '${changeName}':`);
310
389
  for (const issue of report.issues) {
311
- const symbol = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
390
+ const symbol = issueSymbol(issue.level);
312
391
  console.error(` ${symbol} ${issue.path}: ${issue.message}`);
313
392
  }
314
393
  }
315
394
  if (!report.valid) {
316
- // --skip-specs is honoured further down, long after this gate, so a
317
- // change with no deltas fails here no matter what the flag says. Point
318
- // at the marker that actually resolves it rather than at --no-validate,
319
- // which only buries the question.
395
+ // --skip-specs is honoured long after this gate, so a change with no
396
+ // deltas fails here regardless. Point at the marker that resolves it,
397
+ // not at --no-validate.
320
398
  const noDeltas = report.issues.some((i) => /at least one delta/i.test(i.message));
321
399
  const deltaFix = noDeltas
322
400
  ? ` If this change genuinely has no behavioral effect, declare it once by setting \`skip_specs: true\` in the change's ${METADATA_FILENAME} — do not invent a requirement to satisfy validation.`
@@ -337,10 +415,14 @@ export class ArchiveCommand {
337
415
  json,
338
416
  yes: !!options.yes,
339
417
  code: 'archive_confirmation_required',
340
- message: 'Skipping validation requires confirmation: rerun with --yes.',
418
+ // States the condition, not the remedy: `message` is reused verbatim
419
+ // when `--yes` overrides the gate. Remedies belong in `fix`, which an
420
+ // overridden gate drops.
421
+ message: `Validation is disabled for change '${changeName}' (--no-validate).`,
341
422
  fix: 'tospec archive <change-name> --json --no-validate --yes',
342
423
  prompt: '⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)',
343
424
  warning: '\n⚠️ WARNING: Skipping validation may archive invalid specs.',
425
+ warnings: jsonWarnings,
344
426
  });
345
427
  if (!proceed)
346
428
  return null;
@@ -350,9 +432,31 @@ export class ArchiveCommand {
350
432
  console.error(`Affected files: ${changeDir}`);
351
433
  }
352
434
  }
353
- // Show progress and check for incomplete tasks
354
- // Use the already-resolved root rather than re-deriving it from changesDir:
355
- // the '..','..' hop silently encodes the tospec/changes layout.
435
+ // Validation checks the content of files that exist, so it cannot catch a
436
+ // change archiving with no proposal and no design. Archive is the last
437
+ // recoverable moment. Overridable, because a schema artifact is a planning
438
+ // obligation rather than a correctness one.
439
+ const missingArtifacts = this.findMissingRequiredArtifacts(root.path, changeName, changeDir);
440
+ if (missingArtifacts.length > 0) {
441
+ const names = missingArtifacts.join(', ');
442
+ // "missing or incomplete": the list holds both absent files and unfilled
443
+ // templates, and calling the second "missing" sends readers hunting for a
444
+ // file already on disk.
445
+ const proceed = await this.gate({
446
+ json,
447
+ yes: !!options.yes,
448
+ code: 'archive_artifacts_incomplete',
449
+ message: `Change '${changeName}' is missing or has incomplete required artifact(s): ${names}.`,
450
+ fix: `Run tospec status --change ${changeName} for the next step, or rerun with --yes.`,
451
+ prompt: `Warning: missing or incomplete required artifact(s): ${names}. Continue?`,
452
+ warning: `Warning: missing or incomplete required artifact(s): ${names}. Continuing due to --yes flag.`,
453
+ warnings: jsonWarnings,
454
+ });
455
+ if (!proceed)
456
+ return null;
457
+ }
458
+ // Pass the resolved root rather than re-deriving it from changesDir: the
459
+ // '..','..' hop silently encodes the tospec/changes layout.
356
460
  const progress = await getTaskProgressForChange(changesDir, changeName, root.path);
357
461
  if (!json) {
358
462
  const status = formatTaskStatus(progress);
@@ -368,6 +472,7 @@ export class ArchiveCommand {
368
472
  fix: 'Create the schema-tracked tasks file with checkboxes, or rerun with --yes.',
369
473
  prompt: 'Warning: no tracked task checkboxes were found. Continue?',
370
474
  warning: 'Warning: no tracked task checkboxes were found. Continuing due to --yes flag.',
475
+ warnings: jsonWarnings,
371
476
  });
372
477
  if (!proceed)
373
478
  return null;
@@ -381,28 +486,42 @@ export class ArchiveCommand {
381
486
  fix: 'Complete the tasks or rerun with --yes.',
382
487
  prompt: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`,
383
488
  warning: `Warning: ${incompleteTasks} incomplete task(s) found. Continuing due to --yes flag.`,
489
+ warnings: jsonWarnings,
384
490
  });
385
491
  if (!proceed)
386
492
  return null;
387
493
  }
388
- // Handle spec updates unless skipSpecs flag is set
389
494
  let specsUpdated = false;
390
- let totals;
495
+ let totals = { added: 0, modified: 0, removed: 0, renamed: 0, retired: 0 };
391
496
  if (options.skipSpecs) {
392
- if (!json) {
497
+ // `--skip-specs` is for changes with no deltas, so pointing it at one that
498
+ // has them is user error — and archiving in silence made it unrecoverable.
499
+ // A warning, not a block, because the flag is an explicit instruction.
500
+ const discarded = await findSpecUpdates(changeDir, mainSpecsDir);
501
+ if (discarded.length > 0) {
502
+ // source is <changeDir>/specs/<capability>/spec.md.
503
+ const listed = discarded
504
+ .map((update) => path.basename(path.dirname(update.source)))
505
+ .join(', ');
506
+ const message = `--skip-specs discarded ${discarded.length} delta spec(s); these capabilities were not merged into the main specs: ${listed}.`;
507
+ if (json) {
508
+ jsonWarnings.push({ severity: 'warning', code: 'archive_deltas_discarded', message });
509
+ }
510
+ else {
511
+ console.log(`Warning: ${message}`);
512
+ }
513
+ }
514
+ else if (!json) {
393
515
  console.log('Skipping spec updates (--skip-specs flag provided).');
394
516
  }
395
517
  }
396
518
  else {
397
- // Find specs to update
398
519
  const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir);
399
520
  if (specUpdates.length === 0) {
400
- // findSpecUpdates only reads specs/<capability>/spec.md. Validation
401
- // rejects anything else, so reaching here with delta files present means
402
- // validation was skipped (--no-validate). Merging nothing and reporting
403
- // success would drop the requirements without a word — the exact failure
404
- // this guard exists to make impossible. Match on spec.md only: a stray
405
- // NOTES.md under specs/ is not a delta and must not block an archive.
521
+ // findSpecUpdates only reads specs/<capability>/spec.md, and validation
522
+ // rejects anything else — so delta files here mean --no-validate, and
523
+ // reporting success would drop their requirements silently. Matches
524
+ // spec.md only: a stray NOTES.md is not a delta.
406
525
  const orphans = await findSpecFiles(path.join(changeDir, 'specs'));
407
526
  if (orphans.length > 0) {
408
527
  const listed = orphans
@@ -435,96 +554,162 @@ export class ArchiveCommand {
435
554
  }
436
555
  }
437
556
  if (shouldUpdateSpecs) {
438
- // Prepare all updates first (validation pass, no writes)
439
- const prepared = [];
440
- try {
441
- for (const update of specUpdates) {
442
- const built = await buildUpdatedSpec(update, changeName, { silent: json });
443
- prepared.push({ update, rebuilt: built.rebuilt, crlf: built.crlf, retired: built.retired, counts: built.counts });
557
+ // Everything to the last write is one critical section: the merge
558
+ // reads each main spec, builds the new text in memory and only then
559
+ // writes, so two concurrent archives both read the pre-merge text and
560
+ // the second erases the first one's requirement.
561
+ //
562
+ // The lock starts here, not at the top of `run`: a lock held across
563
+ // the picker or the confirmation prompt above would block every other
564
+ // process until someone returns to the terminal.
565
+ const locked = await withFileLock(path.join(root.path, 'tospec', '.archive.lock'), async () => {
566
+ // Prepare every update before writing any of them.
567
+ const prepared = [];
568
+ // Held back until every spec is written. A notice describes what the
569
+ // merge did ("wrote a TBD placeholder"), so surfacing it from a run
570
+ // that then aborted with "No files were changed" contradicts itself.
571
+ const mergeNotices = [];
572
+ try {
573
+ for (const update of specUpdates) {
574
+ const built = await buildUpdatedSpec(update, changeName, { silent: true });
575
+ mergeNotices.push(...built.notices);
576
+ prepared.push({ update, rebuilt: built.rebuilt, crlf: built.crlf, retired: built.retired, counts: built.counts });
577
+ }
444
578
  }
445
- }
446
- catch (err) {
447
- if (json) {
448
- throw new ArchiveBlockedError('archive_spec_update_failed', String(err.message || err), 'Fix the change delta specs and rerun. No files were changed.');
579
+ catch (err) {
580
+ if (json) {
581
+ throw new ArchiveBlockedError('archive_spec_update_failed', String(err.message || err), 'Fix the change delta specs and rerun. No files were changed.');
582
+ }
583
+ console.error(String(err.message || err));
584
+ console.error('Aborted. No files were changed.');
585
+ process.exitCode = 1;
586
+ return writeTotalsSentinel;
449
587
  }
450
- console.error(String(err.message || err));
451
- console.error('Aborted. No files were changed.');
452
- process.exitCode = 1;
453
- return null;
454
- }
455
- // Validate every rebuilt spec before writing any of them, so a
456
- // late validation failure really does leave all targets unchanged.
457
- if (!skipValidation) {
458
- for (const p of prepared) {
459
- // A retired capability has no requirements left, so validating its
460
- // rebuilt text would always fail on "at least one requirement" -
461
- // the very deadlock retirement exists to break. It is deleted, not
462
- // written, so there is no content to hold to that rule.
463
- if (p.retired)
464
- continue;
465
- const specName = path.basename(path.dirname(p.update.target));
466
- const report = await new Validator().validateSpecContent(specName, p.rebuilt);
467
- if (!report.valid) {
468
- if (json) {
469
- throw new ArchiveBlockedError('archive_spec_validation_failed', `Rebuilt spec for '${specName}' failed validation. No files were changed.`, `Run ${`tospec validate ${specName}`} after fixing the change deltas.`);
470
- }
471
- console.error(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`);
472
- for (const issue of report.issues) {
473
- if (issue.level === 'ERROR')
474
- console.error(` ✗ ${issue.message}`);
475
- else if (issue.level === 'WARNING')
476
- console.error(` ⚠ ${issue.message}`);
588
+ // Validate every rebuilt spec before writing any of them, so a
589
+ // late validation failure really does leave all targets unchanged.
590
+ if (!skipValidation) {
591
+ for (const p of prepared) {
592
+ // A retired capability has no requirements left, so validating it
593
+ // would always fail on "at least one requirement". It is deleted,
594
+ // not written.
595
+ if (p.retired)
596
+ continue;
597
+ const specName = path.basename(path.dirname(p.update.target));
598
+ const report = await new Validator().validateSpecContent(specName, p.rebuilt);
599
+ if (!report.valid) {
600
+ if (json) {
601
+ throw new ArchiveBlockedError('archive_spec_validation_failed', `Rebuilt spec for '${specName}' failed validation. No files were changed.`, `Run ${`tospec validate ${specName}`} after fixing the change deltas.`);
602
+ }
603
+ console.error(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`);
604
+ for (const issue of report.issues) {
605
+ if (issue.level === 'ERROR')
606
+ console.error(` ✗ ${issue.message}`);
607
+ else if (issue.level === 'WARNING')
608
+ console.error(` ⚠ ${issue.message}`);
609
+ }
610
+ console.error('Aborted. No files were changed.');
611
+ process.exitCode = 1;
612
+ return writeTotalsSentinel;
477
613
  }
478
- console.error('Aborted. No files were changed.');
479
- process.exitCode = 1;
480
- return null;
481
614
  }
482
615
  }
483
- }
484
- // All validations passed; write files and display counts
485
- const writeTotals = { added: 0, modified: 0, removed: 0, renamed: 0, retired: 0 };
486
- for (const p of prepared) {
487
- if (p.retired) {
488
- await retireSpec(p.update, mainSpecsDir, { silent: json });
489
- writeTotals.retired += 1;
616
+ const writeTotals = { added: 0, modified: 0, removed: 0, renamed: 0, retired: 0 };
617
+ for (const p of prepared) {
618
+ if (p.retired) {
619
+ await retireSpec(p.update, mainSpecsDir, { silent: json });
620
+ writeTotals.retired += 1;
621
+ }
622
+ else {
623
+ await writeUpdatedSpec(p.update, p.rebuilt, p.counts, {
624
+ silent: json,
625
+ crlf: p.crlf,
626
+ });
627
+ }
628
+ writeTotals.added += p.counts.added;
629
+ writeTotals.modified += p.counts.modified;
630
+ writeTotals.removed += p.counts.removed;
631
+ writeTotals.renamed += p.counts.renamed;
490
632
  }
491
- else {
492
- await writeUpdatedSpec(p.update, p.rebuilt, p.counts, {
493
- silent: json,
494
- crlf: p.crlf,
495
- });
633
+ // Now true: the files the notices talk about exist on disk. In JSON
634
+ // mode a dangling REMOVED used to be an invisible no-op, hence the
635
+ // envelope entry rather than silence.
636
+ for (const n of mergeNotices) {
637
+ if (json) {
638
+ jsonWarnings.push({ severity: 'warning', code: n.code, message: n.message });
639
+ }
640
+ else {
641
+ console.log(`Warning: ${n.message}`);
642
+ }
496
643
  }
497
- writeTotals.added += p.counts.added;
498
- writeTotals.modified += p.counts.modified;
499
- writeTotals.removed += p.counts.removed;
500
- writeTotals.renamed += p.counts.renamed;
501
- }
644
+ if (!json) {
645
+ console.log(`Totals: + ${writeTotals.added}, ~ ${writeTotals.modified}, - ${writeTotals.removed}, → ${writeTotals.renamed}` +
646
+ (writeTotals.retired ? `, retired ${writeTotals.retired}` : ''));
647
+ console.log('Specs updated successfully.');
648
+ }
649
+ return { totals: writeTotals };
650
+ }, { operation: `archive ${changeName}` });
651
+ // The human-mode failure paths above already printed and set the exit code.
652
+ if (locked === writeTotalsSentinel)
653
+ return null;
502
654
  specsUpdated = true;
503
- totals = writeTotals;
504
- if (!json) {
505
- console.log(`Totals: + ${writeTotals.added}, ~ ${writeTotals.modified}, - ${writeTotals.removed}, → ${writeTotals.renamed}` +
506
- (writeTotals.retired ? `, retired ${writeTotals.retired}` : ''));
507
- console.log('Specs updated successfully.');
508
- }
655
+ totals = locked.totals;
509
656
  }
510
657
  }
511
658
  }
512
- // `archiveName` / `archivePath` were resolved and checked for collision
513
- // before any spec was written; see the reservation above.
659
+ // Name and collision were settled at the reservation above.
514
660
  await fs.mkdir(archiveDir, { recursive: true });
515
- // Move change to archive (uses copy+remove on EPERM/EXDEV, e.g. Windows)
516
661
  await moveDirectory(changeDir, archivePath);
517
- // The change's ticket lives in the global tospec/tickets/ index (outside the
518
- // change dir), so move it alongside into tickets/archive/ to keep the active
519
- // index limited to in-flight changes.
520
- const ticketPath = await findTicketPath(root.path, changeName);
662
+ // The ticket lives in the global tospec/tickets/ index, outside the change
663
+ // dir, so it moves alongside to keep the active index to in-flight changes.
664
+ const ticketPaths = await findTicketPaths(root.path, changeName);
665
+ // Newest wins: a second ticket means the change was recreated, so the later
666
+ // stamp describes the change being archived. The rest are reported, not
667
+ // cleaned up — archive must not silently remove a record.
668
+ const ticketPath = ticketPaths[ticketPaths.length - 1];
669
+ if (ticketPaths.length > 1) {
670
+ const stranded = ticketPaths
671
+ .slice(0, -1)
672
+ .map((file) => path.basename(file))
673
+ .join(', ');
674
+ const message = `${ticketPaths.length} active tickets name change '${changeName}'; archived ${path.basename(ticketPath)} and left the rest in tospec/tickets/: ${stranded}.`;
675
+ if (json) {
676
+ jsonWarnings.push({ severity: 'warning', code: 'ticket_duplicates_left', message });
677
+ }
678
+ else {
679
+ console.log(`Warning: ${message}`);
680
+ }
681
+ }
521
682
  if (ticketPath) {
522
683
  const ticketArchiveDir = path.join(root.path, 'tospec', 'tickets', 'archive');
523
684
  await fs.mkdir(ticketArchiveDir, { recursive: true });
524
685
  const archivedTicketPath = path.join(ticketArchiveDir, path.basename(ticketPath));
525
- await moveFile(ticketPath, archivedTicketPath);
526
- await retargetTicketRef(archivedTicketPath, archiveName);
527
- await appendDecisionLinks(archivedTicketPath, archivePath, root.path);
686
+ // `moveFile` overwrites, and two tickets share a filename when the same
687
+ // change name is created twice in one second with an archive between.
688
+ // Left in place and reported rather than renamed around: inventing a
689
+ // second filename would make the index disagree with its frontmatter.
690
+ const archivedTicketExists = await fs
691
+ .stat(archivedTicketPath)
692
+ .then(() => true)
693
+ .catch((error) => {
694
+ if (isMissingPathError(error))
695
+ return false;
696
+ throw error;
697
+ });
698
+ if (archivedTicketExists) {
699
+ const message = `tospec/tickets/archive/${path.basename(archivedTicketPath)} already exists, so this change's ticket ` +
700
+ `was left in tospec/tickets/ rather than overwriting it. Rename one of the two — they describe different changes.`;
701
+ if (json) {
702
+ jsonWarnings.push({ severity: 'warning', code: 'ticket_archive_collision', message });
703
+ }
704
+ else {
705
+ console.log(`Warning: ${message}`);
706
+ }
707
+ }
708
+ else {
709
+ await moveFile(ticketPath, archivedTicketPath);
710
+ await retargetTicketRef(archivedTicketPath, archiveName);
711
+ await appendDecisionLinks(archivedTicketPath, archivePath, root.path);
712
+ }
528
713
  }
529
714
  if (!json) {
530
715
  console.log(`Change '${changeName}' archived as '${archiveName}'.`);
@@ -534,7 +719,7 @@ export class ArchiveCommand {
534
719
  archivedAs: archiveName,
535
720
  path: archivePath,
536
721
  specsUpdated,
537
- ...(totals ? { totals } : {}),
722
+ totals,
538
723
  };
539
724
  }
540
725
  async selectChange(changesDir, projectRoot) {
@@ -544,7 +729,7 @@ export class ArchiveCommand {
544
729
  console.log('No active changes found.');
545
730
  return null;
546
731
  }
547
- // Build choices with progress inline to avoid duplicate lists
732
+ // Task progress is decoration; on any failure the plain names still work.
548
733
  let choices = changeDirs.map(name => ({ name, value: name }));
549
734
  try {
550
735
  const progressList = [];
@@ -559,19 +744,15 @@ export class ArchiveCommand {
559
744
  value: p.id
560
745
  }));
561
746
  }
562
- catch {
563
- // If anything fails, fall back to simple names
564
- choices = changeDirs.map(name => ({ name, value: name }));
565
- }
747
+ catch { }
566
748
  try {
567
- const answer = await select({
749
+ return await select({
568
750
  message: 'Select a change to archive',
569
751
  choices
570
752
  });
571
- return answer;
572
753
  }
573
- catch (error) {
574
- // User cancelled (Ctrl+C)
754
+ catch {
755
+ // Ctrl+C.
575
756
  return null;
576
757
  }
577
758
  }