@seanmars/tospec 0.19.0-beta.8 → 0.21.0

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