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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (431) hide show
  1. package/CHANGELOG.md +578 -0
  2. package/README.md +71 -77
  3. package/assets/dashboard/app.js +14 -3
  4. package/assets/dashboard/style.css +7 -0
  5. package/assets/metrics/app.js +22 -0
  6. package/assets/metrics/style.css +8 -0
  7. package/assets/rules/tospec/decision.md +3 -0
  8. package/assets/rules/tospec/single-source-of-truth.md +19 -0
  9. package/dist/cli/index.d.ts.map +1 -1
  10. package/dist/cli/index.js +266 -82
  11. package/dist/cli/index.js.map +1 -1
  12. package/dist/commands/config.d.ts +9 -17
  13. package/dist/commands/config.d.ts.map +1 -1
  14. package/dist/commands/config.js +374 -95
  15. package/dist/commands/config.js.map +1 -1
  16. package/dist/commands/dashboard.d.ts +64 -81
  17. package/dist/commands/dashboard.d.ts.map +1 -1
  18. package/dist/commands/dashboard.js +350 -218
  19. package/dist/commands/dashboard.js.map +1 -1
  20. package/dist/commands/decision.d.ts +41 -19
  21. package/dist/commands/decision.d.ts.map +1 -1
  22. package/dist/commands/decision.js +400 -70
  23. package/dist/commands/decision.js.map +1 -1
  24. package/dist/commands/metrics.d.ts +34 -48
  25. package/dist/commands/metrics.d.ts.map +1 -1
  26. package/dist/commands/metrics.js +74 -84
  27. package/dist/commands/metrics.js.map +1 -1
  28. package/dist/commands/shared-output.d.ts +24 -10
  29. package/dist/commands/shared-output.d.ts.map +1 -1
  30. package/dist/commands/shared-output.js +62 -11
  31. package/dist/commands/shared-output.js.map +1 -1
  32. package/dist/commands/show.d.ts +7 -0
  33. package/dist/commands/show.d.ts.map +1 -1
  34. package/dist/commands/show.js +39 -8
  35. package/dist/commands/show.js.map +1 -1
  36. package/dist/commands/validate.d.ts +47 -30
  37. package/dist/commands/validate.d.ts.map +1 -1
  38. package/dist/commands/validate.js +282 -108
  39. package/dist/commands/validate.js.map +1 -1
  40. package/dist/commands/workflow/index.d.ts +6 -10
  41. package/dist/commands/workflow/index.d.ts.map +1 -1
  42. package/dist/commands/workflow/index.js +6 -10
  43. package/dist/commands/workflow/index.js.map +1 -1
  44. package/dist/commands/workflow/instructions.d.ts +21 -8
  45. package/dist/commands/workflow/instructions.d.ts.map +1 -1
  46. package/dist/commands/workflow/instructions.js +254 -95
  47. package/dist/commands/workflow/instructions.js.map +1 -1
  48. package/dist/commands/workflow/new-change.d.ts +4 -5
  49. package/dist/commands/workflow/new-change.d.ts.map +1 -1
  50. package/dist/commands/workflow/new-change.js +90 -25
  51. package/dist/commands/workflow/new-change.js.map +1 -1
  52. package/dist/commands/workflow/schemas.d.ts +3 -5
  53. package/dist/commands/workflow/schemas.d.ts.map +1 -1
  54. package/dist/commands/workflow/schemas.js +37 -11
  55. package/dist/commands/workflow/schemas.js.map +1 -1
  56. package/dist/commands/workflow/shared.d.ts +48 -21
  57. package/dist/commands/workflow/shared.d.ts.map +1 -1
  58. package/dist/commands/workflow/shared.js +36 -33
  59. package/dist/commands/workflow/shared.js.map +1 -1
  60. package/dist/commands/workflow/status.d.ts +10 -6
  61. package/dist/commands/workflow/status.d.ts.map +1 -1
  62. package/dist/commands/workflow/status.js +84 -42
  63. package/dist/commands/workflow/status.js.map +1 -1
  64. package/dist/commands/workflow/templates.d.ts +10 -3
  65. package/dist/commands/workflow/templates.d.ts.map +1 -1
  66. package/dist/commands/workflow/templates.js +39 -40
  67. package/dist/commands/workflow/templates.js.map +1 -1
  68. package/dist/core/archive.d.ts +26 -21
  69. package/dist/core/archive.d.ts.map +1 -1
  70. package/dist/core/archive.js +415 -201
  71. package/dist/core/archive.js.map +1 -1
  72. package/dist/core/artifact-graph/graph.d.ts +29 -36
  73. package/dist/core/artifact-graph/graph.d.ts.map +1 -1
  74. package/dist/core/artifact-graph/graph.js +50 -58
  75. package/dist/core/artifact-graph/graph.js.map +1 -1
  76. package/dist/core/artifact-graph/index.d.ts +2 -2
  77. package/dist/core/artifact-graph/index.d.ts.map +1 -1
  78. package/dist/core/artifact-graph/index.js +2 -2
  79. package/dist/core/artifact-graph/index.js.map +1 -1
  80. package/dist/core/artifact-graph/instruction-loader.d.ts +105 -100
  81. package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
  82. package/dist/core/artifact-graph/instruction-loader.js +176 -110
  83. package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
  84. package/dist/core/artifact-graph/outputs.d.ts +13 -6
  85. package/dist/core/artifact-graph/outputs.d.ts.map +1 -1
  86. package/dist/core/artifact-graph/outputs.js +146 -8
  87. package/dist/core/artifact-graph/outputs.js.map +1 -1
  88. package/dist/core/artifact-graph/resolver.d.ts +44 -63
  89. package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
  90. package/dist/core/artifact-graph/resolver.js +85 -86
  91. package/dist/core/artifact-graph/resolver.js.map +1 -1
  92. package/dist/core/artifact-graph/schema.d.ts +0 -6
  93. package/dist/core/artifact-graph/schema.d.ts.map +1 -1
  94. package/dist/core/artifact-graph/schema.js +7 -32
  95. package/dist/core/artifact-graph/schema.js.map +1 -1
  96. package/dist/core/artifact-graph/state.d.ts +1 -8
  97. package/dist/core/artifact-graph/state.d.ts.map +1 -1
  98. package/dist/core/artifact-graph/state.js +2 -17
  99. package/dist/core/artifact-graph/state.js.map +1 -1
  100. package/dist/core/artifact-graph/stub-detection.d.ts +12 -0
  101. package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -0
  102. package/dist/core/artifact-graph/stub-detection.js +39 -0
  103. package/dist/core/artifact-graph/stub-detection.js.map +1 -0
  104. package/dist/core/artifact-graph/types.d.ts +4 -0
  105. package/dist/core/artifact-graph/types.d.ts.map +1 -1
  106. package/dist/core/artifact-graph/types.js +30 -10
  107. package/dist/core/artifact-graph/types.js.map +1 -1
  108. package/dist/core/available-tools.d.ts +3 -12
  109. package/dist/core/available-tools.d.ts.map +1 -1
  110. package/dist/core/available-tools.js +4 -13
  111. package/dist/core/available-tools.js.map +1 -1
  112. package/dist/core/change-metadata/schema.d.ts +1 -1
  113. package/dist/core/change-metadata/schema.d.ts.map +1 -1
  114. package/dist/core/change-metadata/schema.js +10 -7
  115. package/dist/core/change-metadata/schema.js.map +1 -1
  116. package/dist/core/change-presenter.d.ts +23 -18
  117. package/dist/core/change-presenter.d.ts.map +1 -1
  118. package/dist/core/change-presenter.js +102 -43
  119. package/dist/core/change-presenter.js.map +1 -1
  120. package/dist/core/change-status-policy.d.ts +8 -1
  121. package/dist/core/change-status-policy.d.ts.map +1 -1
  122. package/dist/core/change-status-policy.js +25 -1
  123. package/dist/core/change-status-policy.js.map +1 -1
  124. package/dist/core/codex-metrics.d.ts +25 -45
  125. package/dist/core/codex-metrics.d.ts.map +1 -1
  126. package/dist/core/codex-metrics.js +44 -88
  127. package/dist/core/codex-metrics.js.map +1 -1
  128. package/dist/core/codex-residue.d.ts +22 -0
  129. package/dist/core/codex-residue.d.ts.map +1 -0
  130. package/dist/core/codex-residue.js +61 -0
  131. package/dist/core/codex-residue.js.map +1 -0
  132. package/dist/core/command-generation/adapters/claude.d.ts +2 -9
  133. package/dist/core/command-generation/adapters/claude.d.ts.map +1 -1
  134. package/dist/core/command-generation/adapters/claude.js +2 -12
  135. package/dist/core/command-generation/adapters/claude.js.map +1 -1
  136. package/dist/core/command-generation/adapters/index.d.ts +1 -9
  137. package/dist/core/command-generation/adapters/index.d.ts.map +1 -1
  138. package/dist/core/command-generation/adapters/index.js +1 -9
  139. package/dist/core/command-generation/adapters/index.js.map +1 -1
  140. package/dist/core/command-generation/generator.d.ts +0 -17
  141. package/dist/core/command-generation/generator.d.ts.map +1 -1
  142. package/dist/core/command-generation/generator.js +0 -17
  143. package/dist/core/command-generation/generator.js.map +1 -1
  144. package/dist/core/command-generation/index.d.ts +2 -5
  145. package/dist/core/command-generation/index.d.ts.map +1 -1
  146. package/dist/core/command-generation/index.js +0 -9
  147. package/dist/core/command-generation/index.js.map +1 -1
  148. package/dist/core/command-generation/types.d.ts +10 -36
  149. package/dist/core/command-generation/types.d.ts.map +1 -1
  150. package/dist/core/command-generation/types.js +0 -6
  151. package/dist/core/command-generation/types.js.map +1 -1
  152. package/dist/core/command-generation/yaml.d.ts +3 -18
  153. package/dist/core/command-generation/yaml.d.ts.map +1 -1
  154. package/dist/core/command-generation/yaml.js +5 -23
  155. package/dist/core/command-generation/yaml.js.map +1 -1
  156. package/dist/core/config-prompts.d.ts +2 -4
  157. package/dist/core/config-prompts.d.ts.map +1 -1
  158. package/dist/core/config-prompts.js +2 -7
  159. package/dist/core/config-prompts.js.map +1 -1
  160. package/dist/core/config-schema.d.ts +8 -53
  161. package/dist/core/config-schema.d.ts.map +1 -1
  162. package/dist/core/config-schema.js +49 -62
  163. package/dist/core/config-schema.js.map +1 -1
  164. package/dist/core/config.d.ts +56 -0
  165. package/dist/core/config.d.ts.map +1 -1
  166. package/dist/core/config.js +73 -2
  167. package/dist/core/config.js.map +1 -1
  168. package/dist/core/converters/json-converter.d.ts.map +1 -1
  169. package/dist/core/dashboard-activity.d.ts +7 -9
  170. package/dist/core/dashboard-activity.d.ts.map +1 -1
  171. package/dist/core/dashboard-activity.js +26 -24
  172. package/dist/core/dashboard-activity.js.map +1 -1
  173. package/dist/core/dashboard-data.d.ts +40 -22
  174. package/dist/core/dashboard-data.d.ts.map +1 -1
  175. package/dist/core/dashboard-data.js +84 -68
  176. package/dist/core/dashboard-data.js.map +1 -1
  177. package/dist/core/global-config.d.ts +24 -53
  178. package/dist/core/global-config.d.ts.map +1 -1
  179. package/dist/core/global-config.js +43 -62
  180. package/dist/core/global-config.js.map +1 -1
  181. package/dist/core/init.d.ts +29 -11
  182. package/dist/core/init.d.ts.map +1 -1
  183. package/dist/core/init.js +232 -164
  184. package/dist/core/init.js.map +1 -1
  185. package/dist/core/list.d.ts +1 -1
  186. package/dist/core/list.d.ts.map +1 -1
  187. package/dist/core/list.js +121 -28
  188. package/dist/core/list.js.map +1 -1
  189. package/dist/core/local-server.d.ts +63 -39
  190. package/dist/core/local-server.d.ts.map +1 -1
  191. package/dist/core/local-server.js +99 -53
  192. package/dist/core/local-server.js.map +1 -1
  193. package/dist/core/markdown-render.d.ts +25 -0
  194. package/dist/core/markdown-render.d.ts.map +1 -0
  195. package/dist/core/markdown-render.js +94 -0
  196. package/dist/core/markdown-render.js.map +1 -0
  197. package/dist/core/migrate.d.ts +32 -15
  198. package/dist/core/migrate.d.ts.map +1 -1
  199. package/dist/core/migrate.js +220 -108
  200. package/dist/core/migrate.js.map +1 -1
  201. package/dist/core/parsers/change-parser.d.ts +7 -10
  202. package/dist/core/parsers/change-parser.d.ts.map +1 -1
  203. package/dist/core/parsers/change-parser.js +48 -56
  204. package/dist/core/parsers/change-parser.js.map +1 -1
  205. package/dist/core/parsers/markdown-parser.d.ts +8 -9
  206. package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
  207. package/dist/core/parsers/markdown-parser.js +31 -22
  208. package/dist/core/parsers/markdown-parser.js.map +1 -1
  209. package/dist/core/parsers/requirement-blocks.d.ts +53 -11
  210. package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
  211. package/dist/core/parsers/requirement-blocks.js +200 -60
  212. package/dist/core/parsers/requirement-blocks.js.map +1 -1
  213. package/dist/core/parsers/requirement-text.d.ts +73 -79
  214. package/dist/core/parsers/requirement-text.d.ts.map +1 -1
  215. package/dist/core/parsers/requirement-text.js +137 -79
  216. package/dist/core/parsers/requirement-text.js.map +1 -1
  217. package/dist/core/parsers/spec-structure.d.ts +1 -1
  218. package/dist/core/parsers/spec-structure.d.ts.map +1 -1
  219. package/dist/core/parsers/spec-structure.js +30 -3
  220. package/dist/core/parsers/spec-structure.js.map +1 -1
  221. package/dist/core/planning-home.js.map +1 -1
  222. package/dist/core/profiles.d.ts +3 -10
  223. package/dist/core/profiles.d.ts.map +1 -1
  224. package/dist/core/profiles.js +5 -12
  225. package/dist/core/profiles.js.map +1 -1
  226. package/dist/core/project-config.d.ts +43 -44
  227. package/dist/core/project-config.d.ts.map +1 -1
  228. package/dist/core/project-config.js +107 -82
  229. package/dist/core/project-config.js.map +1 -1
  230. package/dist/core/project-layout.d.ts +10 -18
  231. package/dist/core/project-layout.d.ts.map +1 -1
  232. package/dist/core/project-layout.js +16 -26
  233. package/dist/core/project-layout.js.map +1 -1
  234. package/dist/core/root-selection.d.ts +11 -7
  235. package/dist/core/root-selection.d.ts.map +1 -1
  236. package/dist/core/root-selection.js +7 -8
  237. package/dist/core/root-selection.js.map +1 -1
  238. package/dist/core/rules.d.ts +10 -0
  239. package/dist/core/rules.d.ts.map +1 -0
  240. package/dist/core/rules.js +43 -0
  241. package/dist/core/rules.js.map +1 -0
  242. package/dist/core/schema-names.d.ts +16 -0
  243. package/dist/core/schema-names.d.ts.map +1 -0
  244. package/dist/core/schema-names.js +16 -0
  245. package/dist/core/schema-names.js.map +1 -0
  246. package/dist/core/schemas/base.schema.d.ts +3 -0
  247. package/dist/core/schemas/base.schema.d.ts.map +1 -1
  248. package/dist/core/schemas/base.schema.js +22 -6
  249. package/dist/core/schemas/base.schema.js.map +1 -1
  250. package/dist/core/schemas/change.schema.d.ts +16 -0
  251. package/dist/core/schemas/change.schema.d.ts.map +1 -1
  252. package/dist/core/schemas/change.schema.js +41 -10
  253. package/dist/core/schemas/change.schema.js.map +1 -1
  254. package/dist/core/schemas/spec.schema.d.ts +2 -0
  255. package/dist/core/schemas/spec.schema.d.ts.map +1 -1
  256. package/dist/core/shared/index.d.ts +3 -8
  257. package/dist/core/shared/index.d.ts.map +1 -1
  258. package/dist/core/shared/index.js +3 -8
  259. package/dist/core/shared/index.js.map +1 -1
  260. package/dist/core/shared/rules-generation.d.ts +27 -8
  261. package/dist/core/shared/rules-generation.d.ts.map +1 -1
  262. package/dist/core/shared/rules-generation.js +151 -16
  263. package/dist/core/shared/rules-generation.js.map +1 -1
  264. package/dist/core/shared/skill-generation.d.ts +38 -53
  265. package/dist/core/shared/skill-generation.d.ts.map +1 -1
  266. package/dist/core/shared/skill-generation.js +82 -51
  267. package/dist/core/shared/skill-generation.js.map +1 -1
  268. package/dist/core/shared/tool-detection.d.ts +40 -62
  269. package/dist/core/shared/tool-detection.d.ts.map +1 -1
  270. package/dist/core/shared/tool-detection.js +88 -80
  271. package/dist/core/shared/tool-detection.js.map +1 -1
  272. package/dist/core/skill-metrics.d.ts +36 -63
  273. package/dist/core/skill-metrics.d.ts.map +1 -1
  274. package/dist/core/skill-metrics.js +34 -73
  275. package/dist/core/skill-metrics.js.map +1 -1
  276. package/dist/core/spec-presenter.d.ts.map +1 -1
  277. package/dist/core/spec-presenter.js +6 -6
  278. package/dist/core/spec-presenter.js.map +1 -1
  279. package/dist/core/specs-apply.d.ts +24 -23
  280. package/dist/core/specs-apply.d.ts.map +1 -1
  281. package/dist/core/specs-apply.js +188 -173
  282. package/dist/core/specs-apply.js.map +1 -1
  283. package/dist/core/templates/fragments/interview.d.ts +2 -6
  284. package/dist/core/templates/fragments/interview.d.ts.map +1 -1
  285. package/dist/core/templates/fragments/interview.js +2 -6
  286. package/dist/core/templates/fragments/interview.js.map +1 -1
  287. package/dist/core/templates/fragments/next-step.d.ts +4 -8
  288. package/dist/core/templates/fragments/next-step.d.ts.map +1 -1
  289. package/dist/core/templates/fragments/next-step.js +4 -8
  290. package/dist/core/templates/fragments/next-step.js.map +1 -1
  291. package/dist/core/templates/fragments/verify.d.ts +9 -12
  292. package/dist/core/templates/fragments/verify.d.ts.map +1 -1
  293. package/dist/core/templates/fragments/verify.js +9 -12
  294. package/dist/core/templates/fragments/verify.js.map +1 -1
  295. package/dist/core/templates/index.d.ts +0 -6
  296. package/dist/core/templates/index.d.ts.map +1 -1
  297. package/dist/core/templates/index.js +0 -7
  298. package/dist/core/templates/index.js.map +1 -1
  299. package/dist/core/templates/skill-templates.d.ts +1 -5
  300. package/dist/core/templates/skill-templates.d.ts.map +1 -1
  301. package/dist/core/templates/skill-templates.js +0 -5
  302. package/dist/core/templates/skill-templates.js.map +1 -1
  303. package/dist/core/templates/types.d.ts +3 -7
  304. package/dist/core/templates/types.d.ts.map +1 -1
  305. package/dist/core/templates/types.js +0 -3
  306. package/dist/core/templates/types.js.map +1 -1
  307. package/dist/core/templates/workflows/apply.d.ts +3 -9
  308. package/dist/core/templates/workflows/apply.d.ts.map +1 -1
  309. package/dist/core/templates/workflows/apply.js +9 -12
  310. package/dist/core/templates/workflows/apply.js.map +1 -1
  311. package/dist/core/templates/workflows/archive.d.ts +0 -6
  312. package/dist/core/templates/workflows/archive.d.ts.map +1 -1
  313. package/dist/core/templates/workflows/archive.js +7 -5
  314. package/dist/core/templates/workflows/archive.js.map +1 -1
  315. package/dist/core/templates/workflows/decision.js +4 -4
  316. package/dist/core/templates/workflows/decision.js.map +1 -1
  317. package/dist/core/templates/workflows/explore.js +1 -1
  318. package/dist/core/templates/workflows/grill.d.ts.map +1 -1
  319. package/dist/core/templates/workflows/grill.js +0 -2
  320. package/dist/core/templates/workflows/grill.js.map +1 -1
  321. package/dist/core/templates/workflows/issue.d.ts +0 -6
  322. package/dist/core/templates/workflows/issue.d.ts.map +1 -1
  323. package/dist/core/templates/workflows/issue.js +3 -0
  324. package/dist/core/templates/workflows/issue.js.map +1 -1
  325. package/dist/core/templates/workflows/propose.d.ts +0 -6
  326. package/dist/core/templates/workflows/propose.d.ts.map +1 -1
  327. package/dist/core/templates/workflows/propose.js +0 -1
  328. package/dist/core/templates/workflows/propose.js.map +1 -1
  329. package/dist/core/templates/workflows/sync.d.ts +2 -8
  330. package/dist/core/templates/workflows/sync.d.ts.map +1 -1
  331. package/dist/core/templates/workflows/sync.js +2 -2
  332. package/dist/core/templates/workflows/sync.js.map +1 -1
  333. package/dist/core/templates/workflows/update.d.ts +0 -6
  334. package/dist/core/templates/workflows/update.d.ts.map +1 -1
  335. package/dist/core/templates/workflows/update.js.map +1 -1
  336. package/dist/core/update.d.ts +26 -21
  337. package/dist/core/update.d.ts.map +1 -1
  338. package/dist/core/update.js +165 -116
  339. package/dist/core/update.js.map +1 -1
  340. package/dist/core/user-state-migration.d.ts +13 -15
  341. package/dist/core/user-state-migration.d.ts.map +1 -1
  342. package/dist/core/user-state-migration.js +16 -20
  343. package/dist/core/user-state-migration.js.map +1 -1
  344. package/dist/core/validation/constants.d.ts +19 -25
  345. package/dist/core/validation/constants.d.ts.map +1 -1
  346. package/dist/core/validation/constants.js +25 -20
  347. package/dist/core/validation/constants.js.map +1 -1
  348. package/dist/core/validation/prose-length.d.ts +15 -0
  349. package/dist/core/validation/prose-length.d.ts.map +1 -0
  350. package/dist/core/validation/prose-length.js +29 -0
  351. package/dist/core/validation/prose-length.js.map +1 -0
  352. package/dist/core/validation/purpose-placeholder.d.ts +9 -16
  353. package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
  354. package/dist/core/validation/purpose-placeholder.js +30 -44
  355. package/dist/core/validation/purpose-placeholder.js.map +1 -1
  356. package/dist/core/validation/section-validator.d.ts +4 -4
  357. package/dist/core/validation/section-validator.d.ts.map +1 -1
  358. package/dist/core/validation/section-validator.js +43 -7
  359. package/dist/core/validation/section-validator.js.map +1 -1
  360. package/dist/core/validation/task-numbering.d.ts +6 -3
  361. package/dist/core/validation/task-numbering.d.ts.map +1 -1
  362. package/dist/core/validation/task-numbering.js +23 -11
  363. package/dist/core/validation/task-numbering.js.map +1 -1
  364. package/dist/core/validation/types.d.ts +18 -0
  365. package/dist/core/validation/types.d.ts.map +1 -1
  366. package/dist/core/validation/types.js +12 -1
  367. package/dist/core/validation/types.js.map +1 -1
  368. package/dist/core/validation/validator.d.ts +50 -51
  369. package/dist/core/validation/validator.d.ts.map +1 -1
  370. package/dist/core/validation/validator.js +486 -263
  371. package/dist/core/validation/validator.js.map +1 -1
  372. package/dist/prompts/searchable-multi-select.d.ts +3 -8
  373. package/dist/prompts/searchable-multi-select.d.ts.map +1 -1
  374. package/dist/prompts/searchable-multi-select.js +16 -39
  375. package/dist/prompts/searchable-multi-select.js.map +1 -1
  376. package/dist/utils/change-metadata.d.ts +11 -50
  377. package/dist/utils/change-metadata.d.ts.map +1 -1
  378. package/dist/utils/change-metadata.js +48 -67
  379. package/dist/utils/change-metadata.js.map +1 -1
  380. package/dist/utils/change-utils.d.ts +31 -54
  381. package/dist/utils/change-utils.d.ts.map +1 -1
  382. package/dist/utils/change-utils.js +143 -100
  383. package/dist/utils/change-utils.js.map +1 -1
  384. package/dist/utils/file-lock.d.ts +39 -0
  385. package/dist/utils/file-lock.d.ts.map +1 -0
  386. package/dist/utils/file-lock.js +149 -0
  387. package/dist/utils/file-lock.js.map +1 -0
  388. package/dist/utils/file-system.d.ts +12 -32
  389. package/dist/utils/file-system.d.ts.map +1 -1
  390. package/dist/utils/file-system.js +16 -40
  391. package/dist/utils/file-system.js.map +1 -1
  392. package/dist/utils/frontmatter.d.ts +7 -11
  393. package/dist/utils/frontmatter.d.ts.map +1 -1
  394. package/dist/utils/frontmatter.js +11 -11
  395. package/dist/utils/frontmatter.js.map +1 -1
  396. package/dist/utils/interactive.d.ts +4 -9
  397. package/dist/utils/interactive.d.ts.map +1 -1
  398. package/dist/utils/interactive.js +2 -4
  399. package/dist/utils/interactive.js.map +1 -1
  400. package/dist/utils/item-discovery.d.ts +15 -10
  401. package/dist/utils/item-discovery.d.ts.map +1 -1
  402. package/dist/utils/item-discovery.js +42 -47
  403. package/dist/utils/item-discovery.js.map +1 -1
  404. package/dist/utils/link.d.ts +13 -4
  405. package/dist/utils/link.d.ts.map +1 -1
  406. package/dist/utils/link.js +13 -4
  407. package/dist/utils/link.js.map +1 -1
  408. package/dist/utils/match.js.map +1 -1
  409. package/dist/utils/requirement-diff.d.ts +13 -23
  410. package/dist/utils/requirement-diff.d.ts.map +1 -1
  411. package/dist/utils/requirement-diff.js +13 -23
  412. package/dist/utils/requirement-diff.js.map +1 -1
  413. package/dist/utils/spec-files.d.ts +10 -11
  414. package/dist/utils/spec-files.d.ts.map +1 -1
  415. package/dist/utils/spec-files.js +31 -22
  416. package/dist/utils/spec-files.js.map +1 -1
  417. package/dist/utils/task-progress.d.ts +11 -9
  418. package/dist/utils/task-progress.d.ts.map +1 -1
  419. package/dist/utils/task-progress.js +53 -32
  420. package/dist/utils/task-progress.js.map +1 -1
  421. package/dist/utils/timestamp.d.ts +5 -8
  422. package/dist/utils/timestamp.d.ts.map +1 -1
  423. package/dist/utils/timestamp.js +5 -8
  424. package/dist/utils/timestamp.js.map +1 -1
  425. package/package.json +9 -10
  426. package/schemas/decision/templates/decision.md +3 -1
  427. package/schemas/decision/templates/index.md +2 -2
  428. package/schemas/issue/schema.yaml +11 -2
  429. package/schemas/issue/templates/spec.md +37 -3
  430. package/schemas/sdd/schema.yaml +24 -1
  431. package/schemas/sdd/templates/spec.md +37 -3
package/CHANGELOG.md CHANGED
@@ -6,6 +6,571 @@
6
6
 
7
7
  tospec 是一套 spec-driven development CLI: 以 schema 定義文件結構與工作流程, 進度由檔案系統狀態推算, 開發方法則封裝於 Skill 之中, 使 AI 工具 (Claude Code / Codex) 得以循序完成需求釐清、規格撰寫、設計、任務拆解、實作到歸檔的完整流程.
8
8
 
9
+ ## [0.19.0-beta.13] - 2026-09-22
10
+
11
+ **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
12
+
13
+ 本版本收斂 `feature/sync-1.12.0` 分支的最後兩輪: 一次逐指令的手動掃描 (第七、八輪), 加上一份對整條分支的審查 (R1-R4、R6-R9、W1-W26、W28、W31). 兩輪的共同形狀不是「檢查缺席」, 而是**檢查存在, 卻到不了它為之而寫的那個案例**: 一道守門條件掛在錯的變數上 (`## REMOVED Scenarios` 的錯誤只在有 MODIFIED 時才跑), 一份常數的複本各自漂移 (structure guard 與 merge 用兩支不同的 requirement header regex), 或一個裸 catch 把 I/O 失敗折成「不存在」(EACCES 的 delta 被驗證為「沒有 delta」). 另一組是同一個問題在兩個指令得到相反答案: `list` 對沒宣告 schema 的 change 回 `null`, `status` 用專案預設跑它; `status --all --json` 把失敗只塞在該 change 自己的 entry, `list` 與 `validate --all` 卻放在 top-level `status`. 同時, 這個 repo 不再把自己的規劃產物與生成的 skill 目錄提交進 git: `tospec/`、`.agents/`、`.claude/` 整批移除, README 重寫.
14
+
15
+ ### 新增
16
+
17
+ - **`config` 的七個子命令全部支援 `--json`**: 原本只有 `list` 與 `reset` 講 `--json`, `path`、`get`、`set`、`unset`、`profile` 對 `--json` 回 commander 的 `unknown option` 並 exit 1, 於是驅動設定的 agent 對七分之五的子命令得去 parse 散文 — 而 `reset` 分支自己的註解早已假設「`list`、`get`、`set` 都講 `--json`」. 每個子命令現在都走標準封包, 拒絕時經 `emitFailureStatus` 把資料鍵置 null, 與其他指令的失敗形狀一致. 兩個取捨: `set` 在 profile 不是 custom 時對 `workflows` 的「會被忽略」提醒, 在 `--json` 下變成成功 payload 旁的 `config_workflows_ignored` status (值確實存了, 是警告不是失敗); `profile` 不帶 preset 會開互動選單, `--json` 永遠答不了, 所以直接失敗並把兩個 preset 的寫法放進 fix, 而不是掛在一個沒人看得到的 prompt 上.
18
+
19
+ - **`decision list` 不帶 `--reindex` 也回報斷鏈的帳本列**: 上一版讓 `--reindex` 補列與回報斷鏈, 但它先印「index.md is already in sync」再在下一行列出指向已刪除檔案的列 — 那句 all-clear 只代表「沒有 status 儲存格需要改寫」, 與它字面說的不同. 更糟的是不帶旗標的 `decision list` 什麼都不說: 它讀決策檔, 帳本裡指向已消失檔案的列對它是隱形的, 唯一發現帳本腐爛的方式是去拿一個名字關於「寫」的旗標. 掃描拆成唯讀的 `findDanglingIndexRows`, 兩條路徑都回報; JSON 裡 `danglingRows` 只在真的有斷鏈時出現, 因為和 `reindexedRows` / `addedRows` 一起恆常輸出會讓那兩個計數讀成「檢查過, 沒事」, 而根本沒有 reindex 被嘗試. 斷鏈列仍然永不刪除.
20
+
21
+ - **`instructions` 對被 `skip_specs` 宣告掉的 artifact 先警告**: `status` 把它報成 `skipped`, `instructions specs` 卻讀成一句普通的「寫它」, agent 照做就在 marker 旁邊產出 spec 檔, 正是 `validate` 拒絕的那組矛盾. 現在 `--json` 的 `status[]` 以 `artifact_skipped` 開頭, 文字模式以 `<warning>` 區塊點名該移除的 marker; 兩個模式都說, 因為 blocked-dependency 的警告已經是這樣, 而 agent 讀的是它的 skill 要求的那一種.
22
+
23
+ - **`instructions` 文字模式帶上 `<change_context>`**: JSON payload 一直有 `changeMetadata` (goal、decisions), 文字版卻丟掉了, 讀文字的 agent 看不到這個 change 背後的 ADR.
24
+
25
+ - **`validate` 對沒有 `## Purpose` 的新 capability delta 發 `DELTA_PURPOSE_MISSING`**: 原本沒有任何 finding, 第一則通知來自 archive, 而那時 delta 已經在 `changes/archive/` 底下, 主 spec 裡也已經寫進 TBD placeholder. WARNING 而非 ERROR, 因為 archive 仍然會完成.
26
+
27
+ - **`status --all --json` 的失敗 change 同時出現在 top-level `status`**: 原本只在該 change 自己的 entry 裡, 而 `list --json` 與 `validate --all --json` 把同類失敗放在 top-level. 依契約讀 partial envelope 的呼叫端在那裡什麼也找不到. 現在 batch 也在 top-level 發 `change_unreadable`, 以 change 目錄為 `target`, fix 文字與 `list` 相同.
28
+
29
+ - **`list --specs` 逐 spec 降級**: 任何讀取失敗都被吞掉, 該 spec 以 `requirementCount: 0` 列出, 沒有診斷, exit 0 — 這個數字與「解析乾淨但什麼都沒宣告」無法區分, 於是一份缺失、無法讀取或其實是目錄的 spec.md 被報成空的 capability, 正是那個 catch 區塊的註解想避免的靜默失敗. AGENTS.md 把 `list` 列在逐項降級的 batch 指令裡, 而同一個指令的 changes 那一半早就做到了, 契約於是取決於旗標. 每個 spec 現在帶 `status: "ok" | "unreadable"`, 失敗加一則 `spec_unreadable`, exit 1. 直接丟掉該 entry 是被否決的替代方案: 從列表消失的 spec 看起來像被刪除, 而那正是使用者面對一份讀不到的檔案時最不該得到的結論.
30
+
31
+ ### 變更
32
+
33
+ - **artifact 的平手順序改依 schema 宣告順序, 不再依字母 (breaking change)**: Kahn's algorithm 在多個 artifact 同時就緒時需要一個 tie-break, `getBuildOrder` 用的是 id 的 `.sort()`. sdd 宣告 `proposal -> specs -> design -> tasks`, specs 與 design 都只依賴 proposal, 於是 proposal.md 一落地兩者就平手, `design` 靠字母贏. 這直接到達 agent 面前: `status` 把 design 印在 specs 上面, `nextSteps` 說去跑 `tospec instructions design` — 讓 agent 先寫技術設計, 再寫它據以展開的 delta spec. 同時 `schemas` 與 `templates` 都以宣告順序輸出, 一個 CLI 對同一份 schema 給兩個答案. 註解裡的「sorted for determinism」點出真正的需求, 而字母只是達成它的一種方式: 宣告順序同樣跨平台穩定, 且帶有字母沒有的意圖. 套用在全部四個排序點, 讓「blocked by: specs, design」與上方列表一致. 依賴仍然優先, 這只決定平手. 把 sdd schema 重排到讓字母剛好一致被否決: 那是靠巧合修好一份 schema, 專案自訂 schema 照樣暴露.
34
+
35
+ - **會寫入的指令不再默默建立 tospec root (breaking change)**: `resolveTospecRoot` 在找不到祖先時退回目前目錄作隱含 root. 讀取指令與 `archive` 都已傳 `allowImplicitRoot: false`, 只有 `new change` 與 `decision new` 沒有, 於是在從未初始化的目錄執行任一個, 會悄悄種下 `tospec/config.yaml`、`changes/`、`tickets/`、`decisions/`, `--json` 呼叫端只看到成功封包. 兩者現在以讀取端同一個 `no_tospec_root` 拒絕, fix 指向 `tospec init`. 印一句「created a root at ...」被否決, 因為樹還是留下了, 而通知永遠到不了 JSON 呼叫端. `show` 與 `instructions` 同時補上, 原本在專案外它們回答的是「unknown item」/「no changes exist」.
36
+
37
+ - **`validate` 的 archive 預檢改為依失敗種類排除, 而非整檔跳過**: merge dry run 原本跳過任何已帶結構性 ERROR 的 delta 檔, 以免同一個錯用 merge 的措辭再報一次. 但 merge 自己的前提 — MODIFIED / RENAMED 的目標主 spec 沒有、ADDED 撞名 — 別處都不查, 於是一個不相關的文法錯誤把它們全藏起來, 修掉後下一輪冒出新的失敗: 每個 finding 一趟來回. 排除改為以 merge 訊息的固定前綴比對種類, 正是結構檢查自己會報的那一組, 其餘照轉. 讓 merge 收集所有失敗是替代方案, 但那會失去 archive 依賴的 abort-on-first 原子性. 隨之修正兩條規則: dropped-bullet WARNING 原本對任何改寫 bullet 的 MODIFIED 都觸發 (舊行本來就不在 delta 裡), `--strict` 下沒有任何誠實的 MODIFIED 過得了, 改為只在新 bullet 少於刪掉的數量時報告, 那才是回退編輯的形狀.
38
+
39
+ - **`## REMOVED Scenarios` 接受 header 形式, 空宣告是 ERROR**: 用 `### Requirement:` / `#### Scenario:` 標題寫 REMOVED Scenarios (其他每個 delta 區段都用的拼法) 解析出零筆, `validate` 於是一直對 MODIFIED 區塊報「omits scenario(s)」, 從不說宣告才是原因; bullet 文法只出現在 template 裡, 錯誤指向的地方離它很遠. 根因: entry regex 只接受可選的 `-` 前綴, 且沒有任何檢查問「這個存在的區段有沒有產出任何 entry」, 文法失誤與根本沒寫區段無法區分. 三段修正: regex 同時接受 `#{1,6}` 前綴 (作者意圖明確, 拒絕它只為了逼一次 bullet 改拼法沒有收穫); 存在卻產出零 entry 的區段是點名文法的 ERROR; 「omits scenario(s)」與 next-step hint 都帶上那三行 bullet, 任一則訊息單獨就足以修檔案.
40
+
41
+ - **`archive` 沒有終端機時拒絕開選單, 互動中放棄回 exit 130**: 沒有 change 名稱又沒有 TTY, 原本把選單畫進 pipe, 讀到 EOF 當「沒選」, 印「No change selected」後 exit 0 — 一次什麼都沒做卻回報成功的腳本執行. 現在以 `archive_change_name_required` 失敗; 互動中關掉選單回 130, 與確認提示上的 Ctrl-C 同碼.
42
+
43
+ - **`validate --type` 改用 commander choices**: 未知值原本穿過 `normalizeType` 變 undefined, 靜默退回自動偵測; 現在是 `invalid_argument`, 與 `show --type` 一致.
44
+
45
+ - **`update` 在版本未變時說 Refreshed**: 版本已是最新的一次執行仍會重新產生每份 skill (update 是修復損壞副本的路徑), 卻先印「All N tool(s) are version-current」再印「Updating ... / Updated: ...」, 同一份 transcript 裡兩句話互相矛盾. 同樣的寫入現在在沒有工具改版時以 Refreshing / Refreshed 宣告, Updating / Updated 保留給真的升版或 `--force`.
46
+
47
+ - **`DEFAULT_SCHEMA` 與 `DECISION_SCHEMA_NAME` 移到 `core/schema-names.ts`**: 打斷 `workflow/shared` 與 `decision` 之間的 ESM 循環.
48
+
49
+ - **repo 不再追蹤 `tospec/`、`.agents/`、`.claude/`**: 296 個檔案移除, 包括全部 ADR、changes、tickets 與生成的 skill / command 目錄. 隨之 README 重寫 (逐表對照 commander 定義, 補上 `show --diff`、`status --all`、`decision list --reindex`、`init --json`), `TODO.md` 移除. 影響一件既有慣例: 版本升級不再需要重跑 `tospec update` 更新 skill 檔的 `generatedBy` 標記, 因為那些檔案不在 repo 裡了.
50
+
51
+ ### 修正
52
+
53
+ - **驗證與 merge 的一批「檢查到不了它的案例」**:
54
+ - `## REMOVED Scenarios` 沒有搭配 MODIFIED 區塊時是靜默 no-op: 「宣告沒有對到任何東西」的 ERROR 躲在 `modified.length > 0` 後面, 而唯一它恆為真的形狀正是這道閘門永遠不開的那個. 閘門現在也對宣告開啟, `buildUpdatedSpec` 同樣拒絕未被消費的宣告, 所以 `--no-validate` 歸檔不了 validate 拒絕的東西.
55
+ - MODIFIED 整塊取代, 把區塊吸收進來的 `### Notes` 一起刪了; 現在像 REMOVED 早就做的那樣保留尾段.
56
+ - structure guard 用一支比 merge 更嚴的 `\s+` requirement header regex 複本, 於是 `###Requirement:` 與 `### Requirement:` 並存時通過 guard, 歸檔時掉一段 body. 現在只有一個 exported 常數; 寬鬆拼法保留, 因為既有 regression 釘住它.
57
+ - 讀不到的 delta (EACCES) 走到 `continue`, 被驗證為「不存在」; 現在是 ERROR, 外層 catch 只吞 missing-path.
58
+ - 沒有 `## Requirements` 的 spec 在 fallback 路徑重新吐出 BOM 與 CR.
59
+ - REMOVED / RENAMED 的行 regex 在可選 token 兩側有相鄰的 `\s*`, 在多空白的行上二次方回溯.
60
+ - 三個裸 catch (archive 的 changes 目錄、sync-report、migrate 的 exists()) 把 EACCES 報成 missing; `--require-sync` 現在區分「沒有 Conclusion 行」與「無法解析」.
61
+ - **主 spec 的 requirement 缺 scenario 被報兩次, 用兩種路徑記法**: `SpecSchema` 的 `scenarios.min(1)` 給一個 ERROR, `applySpecRules` 給一個帶格式提示的 WARNING, 同一句話兩個嚴重度, 讀者不知道該對哪個動手, 計數的呼叫端數成兩個. 提示是 warning 唯一多出來的東西, 移到 error 上 (與 `convertZodErrors` 對 `CHANGE_NO_DELTAS` 的做法相同), 重複分支移除; 保留 warning 丟掉 Zod 規則會讓沒有 scenario 的 spec 以 exit 0 通過. 兩份副本還把同一個位址拼成 `requirements.0.scenarios` 與 `requirements[0].scenarios`, `convertZodErrors` 現在經 `formatIssuePath` 統一為方括號.
62
+ - **`validate` 的 next-step hint 對準真正的 delta 失敗**: 每個 delta issue 都觸發同樣三條 bullet, 於是缺 Migration 欄位的 REMOVED entry、`--strict` 下的 SHALL/MUST 警告, 都被叫去補一個它已經有的 Scenario 區塊. hint 現在依訊息對應, MODIFIED-omits-scenario 自有一行; debug hint 不再點名早已是 no-op 的 `--deltas-only`. 同輪修正: `validate <change> --type spec` 不再讀一條使用者沒打的 spec 路徑並原樣印 ENOENT, 改說「Spec 'x' not found」並在同名 change 存在時指向 `--type change`; `validate <item> --all` 靜默驗證全部, 現在 stderr 說該 item 被忽略; 「or run without --strict」只在真的傳了 `--strict` 時出現; `.tospec.yaml` 指向不存在 schema 的 change 原本被報成「no delta found」(解析錯誤被吞, delta 檢查改用 sdd 預設跑), 現在先解析 schema, 只回那一個 ERROR; `--all` 的 Details 只列第一個失敗, 現在每個失敗一行 rerun 指令.
63
+ - **`show` / `validate` 對同時是 change 與 spec 的名字只建議一次**: `nearestMatches` 吃的是 `[...changes, ...specs]`, 兩邊都有的名字變成「Did you mean: x, x?」, 建議本身像打錯字.
64
+ - **`show --diff` 印出 REMOVED 會刪掉的主 spec 區塊**: bullet 形式的 REMOVED 只帶名字, `--diff` 只印標題行, 審查者看不到哪些 scenario 要消失. archive 刪的是主 spec 的區塊, 現在就印那個, 兩者都有時再接著印作者寫的 header 形式 (Reason / Migration); 主 spec 沒有的名字比照 MODIFIED 沒有對應時標出.
65
+ - **`list --json` 回報 change 實際解析到的 schema**: 原本照抄 `metadata.schema`, 沒宣告的 change 回 `null`, 而 `status` 用專案預設跑它, 兩個輸出對同一個 change 互相矛盾. 現在走與 `status` 相同的 `resolveSchemaForChange`.
66
+ - **`archive` 的 merge 通知延後到 spec 寫入之後**: `buildUpdatedSpec` 發出「寫進 TBD placeholder」之類的通知, archive 在準備每份 spec 時就收集, 而 merge 先準備全部再寫入, 於是在後面某份 spec 中止的執行, 會在「No files were changed」旁邊回報它寫了 placeholder 到一個不存在的檔案. 通知現在緩衝在 `mergeNotices`, 只在寫入迴圈後浮出; 中止的 merge 只報錯誤. `--skip-specs` 的說明文字同時修正: 它只跳過 merge, 驗證仍要求 delta, 沒有 delta 的 sdd change 仍需 `.tospec.yaml` 的 `skip_specs: true`, 而 specs 為選配的 schema (issue) 不需要.
67
+ - **`config unset` 修剪被清空的父節點**: `deleteNestedValue` 刪掉葉節點就停, `config unset foo.bar` 留下 `foo: {}`, `config list` 把它當成使用者設過的鍵. 現在自底向上刪空的父節點, 遇到還有其他鍵的那層停下.
68
+ - **`config set profile custom` 在沒有 workflows 清單時警告**: `config profile custom` 會拒絕, 但 `config set` 是原始寫入, 保持寬鬆; 沒有清單時下一次 `init` / `update` 裝零個 skill 且不說原因. 現在發 `config_profile_needs_workflows` 並點名 `config set workflows ...` 的寫法; `config get workflows` 對未設的鍵也改指同一個寫法.
69
+ - **dashboard 在沒有任何 commit 的 repo 上降級 `/api/activity`**: `git init` 過但從未 commit 的專案 (每個第一次用 dashboard 的人都在這個狀態) 讓 GET `/api/activity` 回 500 並附原始 `git log` 錯誤; 完全沒有 repo 的目錄卻早已乾淨地降級為 `{available: false}`, 兩個「沒東西可畫」的案例意見不合. 根因是 `gitUnavailableReason()` 只跑 `git rev-parse --is-inside-work-tree`, unborn HEAD 過得了. 現在再以 `git rev-parse --verify --quiet HEAD` 驗 HEAD, 失敗回 `reason: 'no commits yet'`. 把 `git log` 包進 try/catch 被否決: 那也會把壞掉的 repo 或錯的 pathspec 吞成「沒有活動」.
70
+ - **`POST /api/task` 先在真實路徑上重查圍籬**: symlink 過的 task 檔現在是 403, 而不是 canonicalization 意外產生的 409.
71
+ - **ticket 的 `ref:` 指向 schema 自己的第一個 artifact**: 原本由兩個寫死的 artifact id 推導 (有 `proposal` 就 proposal.md, 否則 task.md), 只對內建兩個 schema 正確. 專案自訂 schema 若第一個 artifact 兩者皆非 (例如生成 rfc.md 的 rfc workflow), 得到一條永遠不存在的 `task.md` 路徑; archive 的改寫只換前綴保留 basename, 於是死連結一路活進 `tospec/tickets/archive/`. 現在取 schema 第一個非 glob artifact 的 `generates`; sdd 與 issue 不變.
72
+ - **`.tospec.yaml` 與 `config.yaml` 的解析錯誤訊息去掉懸空的冒號**: yaml 函式庫的第一行以「:」結尾接一段 code frame, 只留第一行是對的 (frame 在表格列裡是雜訊), 但留下「...at line 2, column 1:」. 同時 `list` 與 `status` 對解析不了的 `tospec/config.yaml` 原本一聲不吭 — 每個 change 的 schema 來自它自己的 `.tospec.yaml`, 專案設定只在該欄位缺席時才被查, 印診斷的 reader 從沒跑過. 兩者現在每次執行呼叫一次 `printProjectConfigDiagnostics`, 走 stderr, 因為警告不是失敗的指令.
73
+ - **`update` 清掉退役名稱下的 skill 目錄**: 帶 `generatedBy` 但名稱不對應任何現行 workflow 的 `tospec-*` 目錄從未被修剪; `removeUnselectedSkillDirs` 現在一併掃, 且 `update` 對 canonical `.agents/skills` 用與 init 相同的 helper. 手寫目錄 (沒有 `generatedBy`) 不動, 因為沒有東西能還原它們. `.codex` 殘留的提示改走 `warn()`, 在 `--json` 下到得了 `status[]`; 舊規則檔的衝突以先前的檔名點名.
74
+ - **CLI 契約的零星修正**: `--port ''` 原本經 `Number('')` 變 0 通過範圍檢查, 綁到 OS 挑的連接埠, 現在比照 `--concurrency` 只認數字; `isRealTimestamp` 改以 UTC 往返, DST 跳過的那個小時被接受; `VALIDATE_FAILURE_PAYLOAD` 帶 `root: null`, `schemas` / `templates` 使用自己的常數; config 的七個子命令加入 JSON null-shape 表, `--scope` 的 preAction 中止先發封包再 throw; `decision new` / `list` 的人類模式失敗改走 `emitFailure`, 與其他指令同樣印 `Error:` 行; `new change` 的人類輸出不再混用平台分隔符與尾隨斜線 (`tospec\changes\x/`), 一律 POSIX 風格.
75
+ - **template 的選配 delta 區段移進 fenced 範例**: 它們原本放在 HTML 註解裡, 而 delta parser 遮蔽 fence 但不遮蔽 HTML 註解, 逐字複製 template 就驗證失敗. `AGENTS.md` 不再宣稱 command 檔帶 `generatedBy`; `how-to-publish.md` 對齊 package.json; 無用的 prepublish script 移除.
76
+
77
+ ### 其他
78
+
79
+ - **三份 ADR 寫下後隨目錄一併移除**: 本版三個決策各有 ADR — artifact 平手順序依宣告 (`20260921_222653`)、寫入指令不建 root (`20260921_233545`)、validate 預檢依失敗種類排除 (`20260922_110237`) — 都在 `tospec/decisions/` 移出 repo 之前提交, 因此現在的 tree 裡沒有它們. 取捨與被否決的替代方案完整保留在對應的 commit body (`a3a3c57`、`ce84cc9`、`f7409eb`), 上方各條目也已改寫收錄. 舊版條目裡的 `決策記錄:` 連結依「條目是歷史紀錄, 不改寫」的慣例原樣保留, 但它們指向的檔案自本版起不在 repo 裡, 需從 git 歷史 (`git show c3cad0a:tospec/decisions/<file>`) 取回.
80
+ - **測試**: `spawnSync` 加上 vitest 無法強制的 timeout; in-process CLI 案例之間呼叫 `clearResolvedRoot`; `captureJson` / `captureStreams` 收進 `test/support/capture.ts` 一份; CSS contract 覆蓋 `.badge-unreadable` 與 `.captured-at`; 第七、八輪的 regression 檔各自列出涵蓋的功能. 這些會 spawn 真實 CLI 的測試依賴 `dist/`, 未 build 的 tree 上會以 ESM resolve 錯誤失敗 10 個案例, 先 `pnpm build` 即可. 測試 1581 passed / 1 skipped (109 個檔案).
81
+
82
+ ## [0.19.0-beta.12] - 2026-09-21
83
+
84
+ **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
85
+
86
+ 本版本只動 skill 文案, 沒有任何行為變更. 起因是一次逐條比對 0.18.0 與 0.19.0-beta.11 的 skill 產出, 而比對的結論是內容本身沒有問題: 每一條事實宣稱都對得上程式碼 — archive 的 status code、`parseSyncReportConclusion` 的 code fence 遮蔽與牴觸判定、`instructions apply --json` 的欄位、`artifactPaths.<id>.existingOutputPaths` 全部無誤. 壞的是同一條規則被寫了兩遍到四遍. skill 文案是 prompt 而不是說明文件, 每個字在每一次觸發時都進 context window, 所以重複三遍不會讓 agent 更服從, 只會稀釋那些只講一次的指令. 真正的失效模式還在後面: 同一條規則有兩份副本, 日後的修改只會落在其中一份, skill 於是開始自我矛盾, 而沒有任何東西能告訴 agent 該聽哪一邊. 這一版裡有兩處正是這樣來的 — 0.19.0-beta.11 為 sync 補上 code fence 規則、為 archive 補上 artifacts-incomplete 分支時, 都沒有刪掉被它們取代的那段文字.
87
+
88
+ ### 變更
89
+
90
+ - **workflow skill 文案的重複段落逐一移除, 每條規則只留一份**: 十份 SKILL.md 合計由 10,793 字降為 10,531 字, `VERIFY.md` 由 775 字降為 735 字 (兩份副本各一), 而沒有任何一條 CLI flag、status code 或分支條件被刪掉 — 移除的全部是同一條規則的第二、第三、第四次陳述. 根因不在任何一份 template, 而在「guardrails 重述 steps」這個寫法本身: 它讓每條規則天生有兩個住所, 而散文沒有 import, 沒有任何機制會在其中一份被改動時提醒另一份, 這也正是 beta.11 那兩處新增文字得以與舊文字並存的原因. 選擇刪除而不是把重複保留為結尾強調 — 重述一整套程序的強調, 與「對同一件事的第二份 (且可能已過時的) 規格」, 從 agent 那一側看完全相同.
91
+ - `tospec-sync`: 「只能寫一行 `Conclusion:`」在 fenced template 的前後各講一次. 合併為一段, 保留 `parseSyncReportConclusion` 真正實作的兩件事 — fence 內的行被遮蔽, 以及兩行互相牴觸時視為無法解析.
92
+ - `tospec-archive`: 移除「為何 skip-sync 判斷排在第一順位」的取捨說明, 那是 ADR 的內容, 不該在每次執行時擋在 agent 面前 (決策記錄: `tospec/decisions/20260729_161020-sync-runs-automatically-in-archive.md`). 另外「Fix now」分支把 sync 的 step 4 整套重寫了一遍, 現改為指向擁有該流程的那一步.
93
+ - `tospec-apply`: 三條 guardrail 逐字重述 step 3、step 5 與開場段落, 另兩條各只保留其不重複的半句.
94
+ - `VERIFY.md`: 「never review the review」出現四次, 分散在 scope 段落、step 5、output 段落與 guardrail.
95
+ - `tospec-grill`、`tospec-explore`: 結尾段落重述自身開場. explore 獨有的那半句 (把決策檔名帶進 `--decisions`) 保留.
96
+ - `tospec-propose`: 「ticket 維持精簡索引」是 ticket 歸屬規則的第三次陳述.
97
+
98
+ ### 其他
99
+
100
+ - **未新增 ADR**: 本版沒有推翻或新立任何取捨, 只是把既有規則的重複副本收斂成一份, 因此 `tospec/decisions/` 自上一版之後沒有新增. `test/core/shared/skill-generation.test.ts` 在過程中攔下一次改寫 — 它斷言 `a clean axis is not re-run` 這句字面必須存在, 而該句在精簡時被換成同義表述, 已還原. `apply-template-snapshot.test.ts` 的 snapshot 為刻意更新: 它原本用來鎖住「對共用骨架的修改是純重排」, 而本次是散文變更, 不屬於該前提. 測試 1463 passed / 1 skipped (103 個檔案).
101
+
102
+ ## [0.19.0-beta.11] - 2026-09-21
103
+
104
+ **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
105
+
106
+ 本版本只修一件事, 而它修的正是上一版那個修正停下來的邊界. 0.19.0-beta.10 把 BOM 正規化收斂到每一個 markdown reader, 非 markdown 的那一側沒有跟上, 於是同一個 Windows 編輯器或 PowerShell `Out-File` 存出的同一份檔案照樣壞掉, 只是壞得離成因更遠. 這一側的三種失效方式都不是上一版那個「讀不到第一行」: `JSON.parse` 把 U+FEFF 當成語法錯誤而非空白, 而 yaml 解析器依 YAML 1.2 spec 接受開頭的 BOM, 所以在同一個編輯器裡對同一組設定做的同一次修改, 弄壞 `config.json` 而 `config.yaml` 毫無異狀; 一道以逐位元組比對為判準的閘門把未動過的 template 判成已編輯, 方向正好相反; 兩條 read-modify-write 路徑則把 BOM 又寫回一份 tospec 自己重新產出的檔案. 第四處是上一輪清點漏掉的那個 `^` 錨定 reader, 它證明了同一件事的另一半: 一個正規化器的價值取決於繞過它的 reader 還剩幾個, 而「已經修好了」與「已經修好了, 除了那幾個」從外面看完全一樣.
107
+
108
+ ### 修正
109
+
110
+ - **帶 BOM 的 `config.json` 被判為無效 JSON, 而 `config.yaml` 對同一次編輯毫無異狀**: BOM 是數種 Windows 編輯器與 PowerShell `Out-File` 的預設輸出, 所以它會在沒有任何人選擇它的情況下, 出現在使用者剛用 `tospec config edit` 打開的那份檔案上. `JSON.parse` 在讀到第一個大括號之前就對 U+FEFF 丟出語法錯誤, yaml 解析器則接受它, 於是兩種設定格式對同一次編輯給出相反的結論, 而這個差異與使用者做了什麼無關. 三條路徑各自壞在不同的地方:
111
+ - `readConfigFile` 對一份 JSON 完全合法的檔案印出 `Invalid JSON in config.json, using defaults` 並退回預設值 — 使用者設定的 profile 與 workflow 清單就這樣消失, 而訊息指控的是那份檔案本身.
112
+ - `config list` 的 `rawGlobalConfig` 連那則訊息都沒有: 它把解析失敗 catch 掉並視同「沒有任何明確設定」, 於是每一個設過的值都被標成 `(default)` — 唯一能用來查證設定的命令, 對一份設定完整的檔案回答「你什麼都沒設」.
113
+ - `config edit` 把檔案交給使用者的編輯器, 再拒絕存回來的結果, 為一次它自己邀請的編輯責怪使用者.
114
+
115
+ 沿用既有的 `stripBom`, 而不是包一層容錯的 JSON 解析或改用會處理 BOM 的解碼器: 上一次修這個問題時分岔出去的東西, 正是「第二個正規化器」; 而這一行剝除只動開頭, 保留 U+FEFF 出現在其他位置時作為內容的身分, 解碼器層的修法分不出這兩者.
116
+
117
+ - **BOM 讓 `isStubArtifactFile` 的閘門整個反轉**: 這個檢查逐位元組比對 artifact 與 template, 用來判定一份文件是不是原封不動的 stub, 而歸檔前的最後一道檢查靠它擋下「內容還是逐字 template」的 change. 它的失效方式與 markdown reader 相反: 不是讀不到內容, 而是位元組確實不同了 — 在編輯器裡打開那份未動過的 template 再存一次就多出一個 BOM, 檔案於是看起來像被寫過, 而那正是閘門存在的理由. 比對改為兩側都剝除 BOM 後進行; template 由套件出貨, 從來不帶 BOM, 所以差異只可能來自使用者那一側的編輯器.
118
+
119
+ - **兩條 read-modify-write 路徑把 BOM 帶回 tospec 自己產出的檔案**: reader 端的註解早已寫明 tospec 產出的每一份文件都是不帶 BOM 的 UTF-8, 但 `retargetTicketRef` (歸檔時改寫 ticket 的 `ref:`) 與 `writeTicketStub` 填入 placeholder Summary 的那一段, 都是讀進來、改一改、再寫回去, 於是原檔的 BOM 原封不動地穿了過去 — 規則寫在讀的那一端, 破壞它的是寫的那一端. 比對對象刻意留在原始文字而不是剝除後的副本: 這樣一份只差在 BOM 的 ticket 仍然會觸發重寫, 而重寫出來的就是不帶 BOM 的版本; 拿剝除後的副本去比會讓它被判定為「沒有變化」, 那個 BOM 便永遠留在檔案裡. 以一個產生出來的專案 (`init --tools all` 加兩個 change) 端到端查證: 掃描 46 個檔案, 沒有任何一個帶 BOM.
120
+
121
+ - **`show` 的標題回退在第一行的 H1 上失效**: `extractTitle` 先看 ticket/proposal frontmatter 的 `title`, 再回退到 H1. 前者經過 `frontmatterString`, 那條路徑自己會剝掉 BOM; 後者直接對原始文字做 `^#` 比對, 而 BOM 就卡在 `^` 與 `#` 之間. 於是一份沒有 frontmatter title、H1 寫在第一行的文件, 在 `show --json` 的 `title` 欄與人類輸出的標題上都退成 change 的目錄名稱. 回退存在的理由正是「沒有別的標題來源」, 所以它失效的時機, 恰好是沒有任何東西能補位的那一種檔案.
122
+
123
+ ### 其他
124
+
125
+ - **未新增 ADR**: 本版只有一個 commit, 修的是 0.19.0-beta.10 已記載的同一條約束 (「tospec 讀寫的檔案一律視 BOM 為不存在, 且永不寫回」) 沒有覆蓋到的 reader, 沒有推翻或新立任何取捨, 因此 `tospec/decisions/` 自上一版之後沒有新增. 測試 1463 passed / 1 skipped (103 個檔案).
126
+
127
+ ## [0.19.0-beta.10] - 2026-09-21
128
+
129
+ **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
130
+
131
+ 本版本收斂第六、七、八輪全指令面掃描. 前五輪的共同問法是「兩個本該一致的答案是不是已經分岔」; 這三輪換成一個更靠前的問題 — **開口回答的那一方, 到底讀不讀得到它正在談的那份檔案**. 這輪最密集的一組缺陷全是同一個形狀: `validate` 叫作者去 `.tospec.yaml` 設 `skip_specs`, 而它正好是唯一不讀那個檔案的路徑; `instructions` 算出了 blocked 卻只印在人類輸出分支, `--json` 的唯一消費者 agent 看不到; dashboard 把 metadata 解析失敗的 change 畫成「還沒有人開始」, 對同一個專案給出與 `list` 相反的結論; `migrate` 自帶一套 delta 掃描, 讀不到 tospec 自己的 template 規定的 bullet 形式; 十一個 markdown reader 繞過 `normalizeDocument`, 於是一個 BOM 讓第一行同時對每一條 `^` 錨定的規則都不存在. 這類缺陷沒有錯誤訊息可看 — 讀不到的那一方交出的是一個合法的空值, 而合法的空值與「本來就沒有」從呼叫端看完全相同. 另有一輪跨 `src/` 與 `test/` 的註解與程式碼精簡, 淨減約 3,300 行.
132
+
133
+ ### 新增
134
+
135
+ - **`rules:` 接受 `apply` 鍵, `instructions apply` 一併攜帶專案 `context`**: 每一份 artifact 的 instructions 一直都帶著專案的 `context` (技術堆疊、慣例、領域詞彙) 與該 artifact 的 `rules:`, 只有 `instructions apply` 兩者都不帶 — 而 apply 正是真的寫出程式碼的那個階段, `tospec-apply` skill 只發一次 `instructions apply --json` 並把它當成全部的交辦內容. 專案寫下的慣例於是剛好停在它本來要約束的地方. `rules:` 也無法定址這個階段: 它的合法鍵是所有 schema 的 artifact id 聯集, 而 `apply` 不是 artifact, 所以專案講得出 tasks.md 要怎麼寫, 講不出它要怎麼被實作. 刻意不沿用被追蹤 artifact 的 rules — `rules.tasks` 管的是寫出 tasks.md, 與實作它是兩件事, 把一個鍵的意義放寬會讓兩者再也無法分開設定. 決策記錄: `tospec/decisions/20260920_204148-apply-phase-carries-project-config.md`.
136
+
137
+ - **workflow 交互引用缺口在選定、安裝與檢視時回報**: 自訂 profile 安裝的是 workflow 的子集, 但那些 workflow 所帶的文案不是子集 — 每份 skill template 都以固定文字點名它的手足. 只裝 `propose,apply,archive` 會寫出一份說著「立刻執行 `tospec-sync` workflow, 不要先問」的 `tospec-archive`, 而那個 skill 根本沒裝; agent 於是要嘛找不到, 要嘛自行即興, 而 archive 即興掉的那一步是 delta spec 被永久併入 `tospec/specs/` 之前的最後一道檢查. 選定路徑上沒有任何東西知道 workflow 之間互相引用: `config set workflows` 只驗證每個 id 存在. `findWorkflowReferenceGaps` 改為掃描該選擇「會裝出來的文案」裡的 `tospec-<id>` 提及 — 掃文案而不是在 `WORKFLOW_DEFS` 裡宣告一份 `requires` 清單, 因為壞掉的就是文案本身, 它不可能像手維護的清單那樣與實際安裝內容分岔. 只回報不自動修正: 改寫文案會讓安裝出來的 skill 與正典 template 不同且沒有任何記載, 而自動補裝引用到的 workflow 等於推翻使用者剛做的選擇, 且引用圖夠密, 多半會收斂成「全部裝」, 也就是使用者剛拒絕掉的 `core` profile. 決策記錄: `tospec/decisions/20260920_204148-workflow-reference-gaps-are-reported.md`.
138
+
139
+ - **validation report 新增 `notes` 欄位**: 部分驗證訊息帶的是關於**規則本身**的背景 — 為何它只是 warning、它看不到什麼、不適用時該怎麼辦. SHALL/MUST 檢查把問題逼了出來: 它那段約 300 字元的說明在別處無從查起, 而逐項印出等於每條 requirement 印一次, 十二條 requirement 就是 3.6KB 一模一樣的散文, 把唯一有差別的部分 (是哪幾條) 埋掉, 且 `--json` 讀者按 requirement 數量付這個代價. `createReport` 現在把相異的 note 提升到 `ValidationReport.notes` 並從各 issue 上清掉, 批次執行再提升一層到整趟執行. 只印在第一次出現上被否決: 「第一次」是排序的偶然, 且在 `--json` 裡等於把一個規則層級的事實掛到某一個任意的 issue 上. 決策記錄: `tospec/decisions/20260920_204149-validation-notes-live-on-the-report.md`.
140
+
141
+ - **`tospec config reset --json`**: `config list`、`get`、`set` 都講 `--json`, 只有 `reset` 是個洞, 於是自動化流程讀得到也寫得到全域設定, 就是重設不了, 而且死在 commander 那句「unknown option '--json'」上, 完全看不出這個命令家族其餘部分是可腳本化的. `reset --all --yes --json` 現在輸出 `{ version, config }`. `--json` 而未帶 `--yes` 是拒絕而非視同同意: 印出提示會讓非 JSON 內容落到 stdout 而破壞單一文件原則, 而照做等於讓 `--json` 變成一個在破壞性命令上比較安靜的 `--yes`.
142
+
143
+ - **`tospec config profile custom`**: `config set workflows`、`config list` 與 `init --help` 三處都叫使用者去執行它, 而它不存在 — 啟用 workflow 清單的唯一記載途徑本身是個錯誤. 未設定清單時拒絕執行, 因為 `custom` 配上空 workflow 清單會讓下一次 `update` 刪光所有已安裝的 skill 目錄.
144
+
145
+ - **`decision list --reindex` 補齊缺列並回報斷鏈**: 上一版讓它回填 status 欄, 但帳本落後決策檔有三種方式, 它只處理第一種. 第二種是「決策檔根本沒有對應的列」— 現在補上, 因為就 `index.md` 而言, 只存在於檔案系統上的決策等於沒有紀錄, 而帳本才是人和 agent 真的會打開的檔案, 這也正是決策從別的分支合進來、或由手寫產生時的樣子. 第三種是「列指向的檔案不見了」— 具名回報但絕不刪除. 對 (2) 的沉默是比較糟的那一半: `--reindex` 對一本漏掉整份決策的帳本回答「已與決策檔同步」, 這種全面過關沒有人會想到要去查證. `reindexDecisionStatuses` 更名為 `reindexDecisionIndex` 並回傳結果物件; `reindexedRows` 維持原意 (被改寫的 status 儲存格), 另外兩種落差各自擁有欄位 (`addedRows`、`danglingRows`), 而不是折進一個已經另有所指的計數.
146
+
147
+ ### 變更
148
+
149
+ - **`instructions apply` 的任務編號從 `description` 移到 `id` (breaking change)**: 原本回傳 `{"id": "1.1", "description": "1.1 Write a failing test"}`. 兩個欄位存在的用意是讓呼叫端既能以編號定址任務、又能渲染它的文字, 而一個同時做這兩件事的呼叫端 — 也正是「兩個欄位都給」所邀請的用法 — 印出來是 `1.1 1.1 Write a failing test`. 現在 `id` 擁有編號, `description` 是其餘部分, 所以 `id + " " + description` 重現原本那一行, 兩個欄位互不重複. 檔案從未編號的任務保留序數 id 與原封不動的 description. 把 `description` 記載為「原始行」是被否決的替代方案 — 它解釋了重複渲染卻沒有阻止它. 決策記錄: `tospec/decisions/20260920_233800-apply-task-id-is-not-in-the-description.md`.
150
+
151
+ - **長度下限改為寬度感知**: 50 字元約是八個英文單字, 卻是約二十五個正體中文字, 所以一份把問題完整回答掉的 `Why` 會因為它是用中文寫的而被判定太短 — 發生在一個文件語言本來就是中文的專案裡. `proseLength` 把全形字元計為兩個單位, 現在所有下限都走它. 上限維持原始字元計數, 因為在上限那一側, 少算才是保守的一側. 與 SHALL/MUST 檢查不同 (那條是 warning 且已說明英文偏差), 這條檢查沒有任何為自己辯解的餘地. 決策記錄: `tospec/decisions/20260919_193441-width-aware-minimum-length.md`.
152
+
153
+ - **`Why` 的長度上下限與 10 個 delta 的上限開始實際生效 (breaking change)**: `ChangeSchema` 宣告了這兩條規則, 而唯一執行它們的是 `Validator.validateChange` — `src/` 裡沒有任何東西呼叫它, CLI 走的是 `validateChangeArtifacts`. 於是一個有 12 個 delta、`Why` 長達 2440 字元的 change 驗證乾淨且順利歸檔. 這比沒有規則更糟: 常數、訊息與 schema 三者都斷言了一個限制, 讀者因此相信它. delta 上限移到 `validateChangeDeltaSpecs` (delta 本來就在那裡被數), 維持 WARNING, 因為「考慮拆分」是關於 change 大小的建議而非正確性斷言, 而 `--strict` 正是把建議變成閘門的那個旗標. `Why` 的上下限移進 `schema.yaml`, 與一直都有作用的 `minSectionLength` 並列, 並新增 `maxSectionLength` — 這個架構自己的答案就是「section 規則在 YAML 裡逐 artifact 宣告」. `Validator.validateChange` 與 `applyChangeRules` 連同它們唯一的呼叫者 (兩個測試) 一併刪除; 第二個 change 驗證入口正是這個程式庫已經被咬過的那種分岔. 決策記錄: `tospec/decisions/20260920_000405-change-rules-live-on-one-path.md`.
154
+
155
+ - **`tospec list` 的 `✓ Complete` 欄改名為 `✓ Tasks done`**: 欄名讀起來像「這個 change 完成了」, 而它數的只有 checkbox. 同一次調整讓 `list --json` 逐 change 回報 `schema`, 並讓 `--type` 說出它因為「沒有 type」排除了幾個 change, 而不是默默丟掉它們 — 自訂 schema 建立的 change 兩種 type 都不帶, 於是被 `--type` 的每一個值排除, 一聲不響地從清單裡消失.
156
+
157
+ - **`config profile core` 不再把完整 workflow 清單寫進設定檔**: 那個動作親手製造出它自己的警告所回報的「清單被忽略」狀態, 於是每次 `config list` 都報一次.
158
+
159
+ - **`init --tools none --json` 的 `skills` 計數改為實際落地數**: 原本在磁碟上什麼都沒有的情況下宣稱 `skills: 10`; 現在反映真正進到工具目錄的數量, 與人類輸出一直以來的說法一致.
160
+
161
+ - **`show --deltas-only` / `--specs-only` 對 change 是無作用旗標, 說明文字如實說明**: change 的 `--json` payload 永遠是 `id`/`title`/`deltaCount`/`deltas`, 形狀固定, 沒有更寬的輸出可供收窄. 原本的「Show only deltas (JSON only, change)」暗示存在一個更完整的預設輸出, 讀者於是去找那份旗標承諾要裁掉的東西. 兩個旗標為相容性保留, 現在說明白.
162
+
163
+ ### 修正
164
+
165
+ - **UTF-8 BOM 讓十一個 reader 的第一行憑空消失**: BOM 是數種 Windows 編輯器與 PowerShell `Out-File` 的預設輸出, 所以它會在沒有任何人選擇它的情況下出現在這個 CLI 讀取的檔案上. `normalizeDocument` 一直都會剝掉它, 但有十一個 reader 自己手寫 `replace(/\r\n?/g,'\n')` 或 `split(/\r?\n/)` 而跳過了那一步. 那些 reader 裡的每一條 pattern 都是 `^` 錨定的, 所以 BOM 把第一行變成一個沒有任何規則吻合的東西 — 每一處都靜默失敗, 而且各失敗各的: 第一行的 `- [ ] 1.1 ...` 對 apply 的進度、`list`、dashboard 與歸檔完整性閘門同時不存在, 而那道閘門決定一個還有未完成工作的 change 能不能被歸檔; delta 作者寫的 `## Purpose` 被丟掉, archive 把 TBD placeholder 蓋進它建立的主 spec 且全程無警告; ticket 回報完全沒有 frontmatter, 因為 `splitFrontmatter` 測的是 `startsWith('---')`, 於是 `change`、`type`、`ref` 一起消失; 決策帳本把 BOM 寫回去, tospec 自己產出了帶 BOM 的檔案. `stripBom` 從 `normalizeDocument` 拆出來給那些必須保留原檔行尾 (因為它們會寫回去) 的 reader, 每一個 markdown reader 現在都走其中之一. 選擇導向既有 helper 而非在 70 個讀取點各加一次剝除: 定義早就存在且記載了這個確切的危害, 壞掉的是繞過它的那些 reader.
166
+
167
+ - **`tospec archive --require-sync` 把 code fence 裡的範例當成真正的結論**: 這道閘門的職責是擋下規格與程式碼尚未一致的 change, 而一份結論為 FAIL 的報告打得開它. 解析器取第一行以 `Conclusion:` 開頭的內容且不做 fence 遮罩, 於是檔案前面一段 ```` ```Conclusion: PASS``` ```` 範例替下方真正的判決回答了問題; 只含這種範例的報告也一樣過關. 而 `tospec-sync` skill 正是把這個格式記載在一個 ```markdown fence 裡, 所以引用 template、或貼一份通過報告的範例, 是產生那一行最平常的方式. 代價不只是閘門: dashboard 的任務凍結讀同一個解析器, 於是一個 FAIL 掉的 change 顯示 `phase: archiving`, `POST /api/task` 回 409「tasks are frozen」— sync 失敗了, 而使用者被鎖在「去勾掉那些能修好它的任務」之外. 改走 `buildCodeFenceMask`, 也就是這裡每一個 markdown reader 早就共用的那個遮罩器 — 這是唯一沒有共用的那一個. 另外三種情況改為拒絕而非猜測: 無法解析的 `Conclusion:` 行回傳 null 而不是讓後面一行合格的替它回答; 兩個互相矛盾的結論回傳 null, 因為靜默挑一個正是 FAIL 被歸檔的方式; 同樣內容重述一次仍然接受.
168
+
169
+ - **`TASK_PATTERN` 與 GFM 的 checkbox 文法在兩端都分岔**: `TASK_PATTERN` 是「什麼算是一個任務」的唯一定義 — 進度計數、`tospec list`、歸檔完整性閘門、dashboard 的任務清單與 `POST /api/task` 全部讀它. 它只接受 `-` 與 `*` 當項目符號, 所以一條 `+ [ ] 1.2 ...` 任務對這裡每一個 reader 同時隱形, 而 GitHub 正常渲染它: `tospec list` 說 `✓ Tasks done`, 歸檔閘門不發 `archive_tasks_incomplete`, dashboard 對著同一個檢視器在同一個檔案裡看得見的 checkbox 回 409「is not a task」. 這是 BOM 那個缺陷的重演 — 沒有人看得見的任務, 就是沒有人會踩到的閘門. 另一端它接受 `]` 後面沒有空白的標記, 而 GitHub 把 `- [ ]2.6 ...` 畫成一個沒有 checkbox 的普通項目, 於是它數進了一個作者從 dashboard 與瀏覽器都勾不掉的任務. 以 GitHub 的渲染器為契約, 因為它是同一份檔案的另一個檢視, 也是作者不執行 tospec 就能查證的唯一一個. 決策記錄: `tospec/decisions/20260920_204147-task-checkbox-grammar-follows-gfm.md`.
170
+
171
+ - **任務編號檢查不認 code fence**: 一份記載自身格式的 tasks.md 會把真正的 `- [ ] 9.1 ...` 放進 fence 裡. 進度計數、歸檔閘門與 dashboard 全都忽略那些行, 編號檢查沒有, 於是它對一個作者無法重新編號的範例發出警告, 而 `tospec validate --strict` 接著就因此拒絕整個 change. 成因是呼叫形狀而非缺了遮罩: `findTaskNumberingIssues` 逐行呼叫 `parseTasksFromContent(line)`, 而單獨交出去的一行永遠不可能被看成在 fence 裡, 因為遮罩是從周圍的行建起來的. 那裡的註解宣稱兩個 reader 一起對 fence 無感、讓其中一邊變得有感會使兩者步調不一 — 而計數那一邊學會遮罩 fence 的那一刻這句話就不再成立, 留下的正是它所警告的那種單邊分岔. 現在文件解析一次後按行定址, 且 `buildCodeFenceMask` 一併套用到標題上 — fence 裡的 `## 9. ...` 是被引用的範例, 讓它開啟一個編號群組會誤判底下每一個真任務.
172
+
173
+ - **`## REMOVED Requirements` 的項目形式解析不出來**: `show --json --deltas-only` 對一份寫法完全正確的移除回報 `deltaCount 0`. template、sdd schema 的 instruction 與 validator 三者寫 REMOVED 都是用名稱的項目清單, 而 `ChangeParser` 用只認 `### Requirement:` 標題的 `parseRequirements` 去讀那個區段 — 於是那個「specs instruction 叫 agent 用來檢視 delta 的命令」說這個 change 什麼都沒移除, 而 `--diff` 與 `archive` 兩邊都確實執行了移除. 根因是一套文法有兩個解析器. REMOVED 區段改走 `parseDeltaSpec().removedEntries`, 也就是驗證讀的同一套 delta 文法, 兩種形式通吃. 同一個文法解析器裡還有鏡像的缺陷: 一旦 REMOVED 區段用了 `### Requirement:` 標題, 它底下的 scenario 步驟就是頂層項目, 項目規則於是把每一條讀成又一個被移除的 requirement — 接著對一個叫做 `**WHEN** a caller ...` 的「requirement」索討 Reason 與 Migration. 現在第一個標題把區段切換成標題形式, 後續項目是內文而非條目.
174
+
175
+ - **`.tospec.yaml` 解析失敗時 `validate` 與 `archive` 一聲不響**: `readSkipSpecsMarker` 吞掉 YAML 解析失敗並回傳 `{ declared: false }`, 理由是「無法解析的 YAML 是 `readChangeMetadata` 該回報的錯, 不是我們的」. 這個假設對走 `readChangeMetadata` 的 `list` 與 `status` 成立, 對 `validate` 與 `archive` 不成立: `validateChangeArtifacts` 讀這個標記而從不呼叫 `readChangeMetadata`, 所以在唯一由這個標記決定判決的那條路上, 根本沒有人回報過那個失敗. 結果是一條死路 — 一個 `.tospec.yaml` 裡 `skip_specs: true` 多縮排一層的 change 被告知「Change must have at least one delta ... 請改在它的 .tospec.yaml 設 `skip_specs: true`」, 也就是它已經照做了的建議, 寫在那個工具讀不到也不肯提起的檔案裡; 要找出真因只能憑直覺去跑一次 `list`. 標記現在攜帶 `unreadableReason`, 而 `validate` 把它報成一個**取代**而非**附加**於 delta 要求的 ERROR. 取代才是重點: 檔案壞著的時候, 這個 change 到底有沒有宣告 `skip_specs` 是不可知的, 所以那句 delta 建議是在沒讀過它所談論的檔案的情況下給出的. 決策記錄: `tospec/decisions/20260920_215934-unparseable-metadata-waives-the-delta-requirement.md`.
176
+
177
+ - **change metadata 的 `schema` 欄位必填, 讓既有的回退鏈永遠走不到**: `resolveSchemaForChange` 一直都有一條回退鏈 (change 的 `.tospec.yaml` → `tospec/config.yaml` → `sdd`), 但 `ChangeMetadataSchema` 把 `schema` 設為必填, 於是檔案存在卻沒指定 schema 時 `readChangeMetadata` 直接丟例外 — 必填這件事本身就是讓那條鏈不可達的原因, 它只可能在完全沒有 metadata 檔的 change 上執行. 結果是一個 `validate` 稱為合法、`archive` 照樣歸檔, 而 `status`、`list` 與 `instructions` 回報為 unreadable 的 change. 「可歸檔卻不可檢視」是這個分歧比較糟的那一半 — 能解釋這個狀態的那些命令, 正是拒絕執行的那些. 而進到這個狀態的入口是 tospec 自己的建議: `validate` 叫沒有 delta 的 change 作者去 `.tospec.yaml` 設 `skip_specs: true`, 對一個還沒有 metadata 檔的 change 照做, 產生的正是一個沒有 `schema` 鍵的檔案. 對 schema 保持沉默現在等同於檔案不存在: 採用專案預設. 空檔或只有註解的檔案解析成 `{}` 亦同. 存在但不是 mapping 仍然失敗 — 那是毀損而非沉默. 同時 Zod 失敗改為一行並點名欄位、檔案與修法: `error.message` 是一個排版過的 JSON issue 陣列, 它原封不動地經由 `Error:`、`status[0].message` 與 `tospec list` 畫的對齊欄位 (它直接把版面撐壞) 抵達使用者, 而既沒有指出檔案也沒有說該改成什麼. 決策記錄: `tospec/decisions/20260920_204148-change-metadata-schema-is-optional.md`.
178
+
179
+ - **`tospec migrate` 讀不到項目形式的 REMOVED 與 RENAMED**: `normalizeMainSpec` 自帶一套 delta 掃描, 而那套掃描只認 `### Requirement:` 標題形式. REMOVED 或 RENAMED 條目只有在掃描正站在某個 requirement 區塊裡時才會生效, 因為區塊標題是觸發刪除或記錄的唯一時機 — 於是項目形式從不吻合, `currentName` 始終是 null, flush 從未執行. 而那正是 tospec 自己的 `schemas/sdd/templates/spec.md` 規定的形式, 也是 `tospec archive` 正確合併的那一種. 對一份那樣寫的來源 spec, migrate 把該移除的 requirement 寫進了遷移後的主 spec 並回報 `removedRequirements: []`, 也把 RENAMED 的 requirement 留在舊名底下並回報 `renamedKept: []` — 一份空的報告與一次乾淨的遷移無從分辨. migrate 現在改問這個專案其餘部分都在用的那個解析器. RENAMED 配對改為實際套用而非僅供回報: 下游沒有任何東西讀遷移報告, 所以「列出供人工檢視」就是改名被遺忘的地方. 教那套私有掃描認得項目形式是被否決的替代方案 — 它修掉兩個症狀卻留下產生它們的那個分岔, 下一次文法擴充在這裡又會缺一次, 而且一樣無聲. 決策記錄: `tospec/decisions/20260920_233732-migrate-uses-the-delta-parser.md`.
180
+
181
+ - **dashboard 把 metadata 解析失敗的 change 畫成「還沒開始」**: `collectOverview` 捕捉 `readChangeMetadata` 的失敗然後丟掉它, 留下一個 `schema: ""`、沒有 type、任務與 artifact 都是零的 change. 那與一個還沒有人動過的 change 逐位元組相同, 於是唯一需要人介入的狀態, 正是 dashboard 畫得最平常的那一個. CLI 早就有答案: `list` 依 `20260917_234821-batch-commands-degrade-per-item.md` 把失敗原地帶著走, 報成 `status: "unreadable"` 加一則 `change_unreadable` 診斷. dashboard 讀同一個專案卻得到相反的結論, 所以這是把既有政策補給缺了它的那個 reader, 而不是新政策. `/api/overview` 的 change 條目現在攜帶 `unreadable` 與解析錯誤, 前端把它畫成排在所有徽章之前的一枚 — 旁邊那些計數是從一份解析不出來的 metadata 算出來的, 所以它們是零而不是量測值. 選擇回報而非拋出、且該 change 仍然列出: 一個唯讀檢視不該為了一個壞檔案回 500, 而把它藏起來等於藏掉使用者唯一該修的東西.
182
+
183
+ - **`instructions` 的 blocked 狀態到不了 `--json`**: `isBlocked` 被算出來之後只有人類輸出分支在用, 由它印出點名缺少哪些相依的 `<warning>`. `--json` 分支只把 `configWarnings` 放進 `status[]`, 別無其他, 所以 agent — `--json` 的唯一消費者, 也是最需要「到此為止」訊號的一方 — 只能從 `dependencies[].done` 自行推論同一件事. 那個警告現在是 `status[]` 裡的一則 `artifact_blocked`, 排在任何 config 警告之前, 因為它是唯一回答「這份 artifact 到底該不該寫」的條目.
184
+
185
+ - **缺少的 template 不說它來自哪一層覆寫**: 專案層的 `tospec/schemas/<name>/schema.yaml` 會接管整個 schema 目錄, template 一併在內 — 沒有逐檔回退到套件內建那一份. 只覆寫單一 template 因此會弄壞每一個 template 沒被一起複製過來的 artifact, 而 `tospec instructions <artifact>` 的回答是一條光禿禿的路徑: `change_error`、沒有 `fix`, 是 CLI 上唯一一條不提供下一步的失敗路徑. 失敗發生的當下沒有任何東西說那條路徑來自一次覆寫, 而那正是整個解釋. `TemplateLoadError` 現在攜帶完整的 `CommandDiagnostic`; `asStatus` 只在錯誤帶有字串 `code` 時採用它的診斷, 這就是為什麼修法必須與 code 同行而非並排. 至於哪一層勝出, 現在直接來自挑出該目錄的那個 resolver — `templates.ts` 原本是拿路徑去跟專案目錄與使用者目錄比對來重新推導, 結果對一個從舊版使用者目錄解析出來的 schema 回報 `package`, 一個問題兩個答案, 而 resolver 那個才是真的.
186
+
187
+ - **`decision list --reindex` 把斷鏈列的 status 洗掉**: `refreshIndexStatuses` 從每一列的連結所指的檔案重讀 status, 而 `statusOfDecisionFile` 在檔案讀不到時回傳 `unknown`. 一列的決策檔不見了正是這種情況, 於是 `--reindex` 把它的 status 儲存格改寫成 `unknown` — 在同一次執行裡, 它一邊把那一列列進 `danglingRows` 說這是要去修的東西, 一邊銷毀了那個決策說過什麼的最後一份倖存紀錄. 它還會計數: 改寫後與原行不同, 所以那一列落進 `statusesRefreshed`, 一本只有一處真實落差加一個死連結的帳本回報 `reindexed 2`, 於是這個數字無法用來判斷到底有沒有東西飄移過. 守衛條件是「存在」而非「可讀」: 存在但讀不到的檔案仍然產出 `unknown`, 那是一句關於一個確實存在的檔案的真陳述; 檔案不見了則不產出任何東西, 儲存格保留人類最後寫在那裡的內容. 改成刪掉那一列從來不在考慮之列 — 帳本是歷史紀錄, 一次刷新沒有資格從裡面移除東西.
188
+
189
+ - **`--reindex` 補回來的列被附加到帳本結尾**: 那讓帳本的時序只是碰巧成立 — `decision new` 也是附加, 而決策通常是隨時間往前寫的. 一個補回來的列打破這個巧合, 因為它此刻到達, 日期卻是它當初真正被決定的時候. 從別的分支拉進來、或手寫的決策於是落在比它新了好幾年的紀錄下方, 讓 `index.md` 與會排序的 `decision list` 互相矛盾, 而 `index.md` 才是人和 agent 會打開的那個檔案, 一本順序亂掉的帳本讀起來就像一本不完整的帳本. 補回來的列現在插在第一個日期晚於它的列之前. 既有的列永不重排, 只在中間插入: 一本有人手工整理過的帳本保住那份整理, 而整表排序會默默改寫這個命令沒被要求碰的列, 包括它刻意回報而非搬動的那些斷鏈列. 比較採用時間戳儲存格的文字比較, 因為 `yyyy-MM-dd HH:mm:ss` 的字典序就是時序: 不做日期解析, 而一個被手改成別種形狀的儲存格以文字比較而不是丟例外.
190
+
191
+ - **`validate` 對一個什麼都移除不到的 REMOVED delta 保持沉默**: `findArchiveBlockers` 以 dry-run 跑一次 spec 合併並回報它會拒絕什麼, 卻把 builder 的非致命通知整個丟棄. 於是有一個 delta 操作完全沒有預覽: ADDED 撞上既有 requirement、MODIFIED 找不到目標、RENAMED 的來源不存在, 三者都會在 validate 時警告; 一個 REMOVED 點名了 spec 裡沒有的 requirement 則毫無聲響, 而 validate 稱這個 change 乾淨. 它偏偏是最需要那個警告的操作, 因為打錯的標題什麼都刪不掉 — 弄錯的可見結果就是「什麼都沒發生」, 所以事後沒有症狀可供察覺. dry-run 現在把兩則 REMOVED-matched-nothing 通知轉成 WARNING. WARNING 而非 ERROR, 因為 archive 確實會繼續, 且兩個 change 同時淘汰同一條 requirement 時, REMOVED 點名一個已經消失的東西是合法的; `--strict` 仍然拒絕. 以通知代碼而非全部轉發來劃界: 其餘通知都是關於 Purpose, 而這個 validator 按自己的規則評判它. 決策記錄: `tospec/decisions/20260920_215934-validate-forwards-only-removed-noop-notices.md`.
192
+
193
+ - **`new change` 丟掉 `--description`, 並把 archive 根目錄說成一個 change**: 交給一個不附 `templates/ticket.md` 的 schema 時, `--description` 被默默丟棄 — `writeTicketStub` 回傳 undefined, 呼叫端只在 ticket 存在時附上診斷, 而沒有別的東西存放一行摘要 (`.tospec.yaml` 沒有, change 目錄也沒有), 於是那段文字就是消失了, 沒有警告, `--json` payload 裡也沒有 `ticketPath` 可供推敲原因. 姊妹路徑從第四輪起就會警告 (重用的 ticket 已經帶著有人寫過的 Summary 時報 `ticket_summary_kept`), 所以同一個參數因為另一個理由消失, 看起來像自訂 schema 的怪癖而不是一個被丟掉的旗標; 現在報 `ticket_unsupported` 並點名哪個 artifact 可以承載這段摘要. 另外 `tospec new change archive` 回答「Change 'archive' already exists at .../tospec/changes/archive」— 對路徑為真, 對宣稱為假. `tospec/changes/archive` 是 archive 根目錄而不是一個 change, 那句訊息把讀者送去找一個從來不存在的 change, 最壞的情況是送他去刪掉那個裝著每一份已歸檔 change 的目錄. 這個名稱現在在存在性檢查之前就以保留字拒絕, 檢查放在 `createChange` 而非 `validateChangeName`, 因為後者與 decision topic 共用, 而在那裡 `archive` 是個好名字.
194
+
195
+ - **`instructions apply` 看不到 glob 後面的 tasks**: `apply.tracks` 以 `generates` 的值點名 artifact, 而那個值可能是 glob; `resolveTaskFiles` 為 `list`、dashboard 與歸檔閘門展開它, apply 卻對它做 `path.join` + `existsSync`. 一個 tasks 散在數個檔案的 schema 因此拿到 `state: blocked`、`progress: 0/0` 與「The *.md file is missing」, 而同一份 payload 自己的 `contextFiles.tasks` 正列著那兩個檔案. 現在改用同一個 resolver, 且 glob 按作者寫法印出而不是印成一個無意義的 `*.md`.
196
+
197
+ - **缺少章節的提示給出一條讀者打不開的 template 路徑**: 提示結尾的 `see template: <path>` 是用 `path.relative(projectRoot, schemaDir)` 組出來的, 那只有在 schema 真的位於專案內才是一條打得開的路徑. 對套件內建的那一份 (也就是預設, 因為 `tospec/schemas/` 覆寫才是例外) 它反而走出了專案: 對一個差兩層的 checkout 是 `../../seanmars/tospec/schemas/sdd/templates/design.md`, 對全域安裝則是一路穿過 `AppData/Roaming/npm/node_modules`. 兩者都沒說自己相對於什麼, 也都不是任何人會貼到任何地方的東西. `describeTemplatePath` 在 schema 確實在專案內時保留簡短的相對形式, 否則回退到絕對路徑 — 那也正是 `tospec templates --json` 已經在回答的形式, 於是兩者一致而不是把同一個檔案描述成兩種樣子.
198
+
199
+ - **`status` 自相矛盾**: 在「All artifacts complete!」上方印著「Progress: 1/2 artifacts complete」— 分母把一個沒有人寫的選用 artifact 算了進去, 判決沒有. 兩者現在都採用 `isComplete` 所用的定義.
200
+
201
+ - **`decision list --status` 在專案已有多份 ADR 時叫你去建一份**: 篩選條件一個都沒命中時給的是「還沒有任何決策」的建議.
202
+
203
+ - **專案 `rules:` 裡不存在的 artifact id 只到 stderr**: 讀 stdout 的 agent 因此看不見自己的設定壞掉了; 現在以 `status[]` 送達. 同批修正還包含 `readProjectConfig` 裡每一句「忽略它」的 `console.warn` — 也就是 `--json` 呼叫端唯一不讀的那個頻道: 一個 agent 拿到的是沒有 `status` 的乾淨成功信封, 而它的專案 context 因為超出 `MAX_CONTEXT_SIZE` 1KB 已被默默丟棄. 「這個專案沒有設定 context」與「你的 context 被丟掉了」是兩件不同的事, 而 stdout 對兩者回報的是前者.
204
+
205
+ - **大小寫只差的 capability 警告訊息指向不存在的路徑**: 見 0.19.0-beta.9; 本版另修正 `## REMOVED Scenarios` 已宣告的 scenario 仍被 bullet 層檢查點名的情況 — scenario 層的檢查尊重那份宣告, bullet 層的檢查接著點名同一條 scenario 的 WHEN/THEN 並要求作者確認他剛剛才宣告過的改寫. `withoutScenarios` 在比較前先把已宣告的 scenario 從當前側移除, 於是只有「保留下來的 scenario 少掉一條 bullet」才會被回報.
206
+
207
+ ### 其他
208
+
209
+ - **跨 `src/` 與 `test/` 的註解與程式碼精簡**: 98 個原始檔與 175 個檔案兩輪掃過, 淨減約 3,300 行. 註解密度向 AGENTS.md 所記的標準收斂 — 解釋**為什麼** (限制、被否決的替代方案) 而非做了什麼, 並把多段追溯式的敘述壓成一兩句; 同時抽出重複的分支邏輯 (例如 `list` 的 `changeStatus`). 無行為變更, 完整測試套件於前後皆通過.
210
+
211
+ - **`CLAUDE.md` 更名為 `AGENTS.md`**: 供 Claude Code 與 Codex 共讀同一份專案指引.
212
+
213
+ - **文件: batch 命令的部分成功 JSON 形狀**: agent JSON 契約原本只列了成功與失敗兩種形狀, 而 `20260917_234821-batch-commands-degrade-per-item.md` 此後引入了第三種 — `list`、`status --all`、`validate --all` 與 `schemas` 以「data key 有值 + 頂層 `status[]` + exit 1」回答一次部分讀取. 只讀這份文件的讀者會把這個組合判成契約違規 (資料在, 離開碼卻說失敗), 並把 `schemas` 的行為讀成 `schemas` 的 bug 而非四個命令共同遵循的規則; 重複的人類輸出也引出同樣的錯誤結論, 而那其實是該 ADR 刻意的拆分: 降級的那一列走 stdout 讓管線接走的表格得以倖存, 診斷走 stderr.
214
+
215
+ - **文件: 任務編號刻意不檢查斷號**: `findTaskNumberingIssues` 會標記編號與群組標題矛盾的任務、以及重複的 ID, 對斷號 (1.1 之後接 1.3) 卻一言不發. 冷讀之下這個不對稱看起來像一個原本周全的檢查漏掉了一項, 而假設斷號有被涵蓋的讀者會去信任一份從未為此被檢查過的檔案. 無行為變更, 缺的只是意圖: 這個模組捍衛的規則是「一個 ID 恰好解析到一個任務」, 因為 apply 是以「continue with 2.3」指稱工作的, 而重複與錯誤的群組前綴會破壞它, 斷號不會. 強制連號會逼作者重新編號, 而那正好製造出這個模組存在來預防的歧義 — 每一個已經對舊號碼做出的指稱 (在對話裡、在 commit 裡、在一次做到一半的 apply 執行裡) 都會指向別的地方. 決策記錄: `tospec/decisions/20260920_215934-task-numbering-ignores-gaps.md`.
216
+
217
+ - **第七輪與第八輪掃描的回歸測試**: `test/core/round7-regressions.test.ts` 與 `test/core/round8-regressions.test.ts`, 延續 `round5-regressions` 的作法 — 這些發現彼此之間唯一的關聯就是產生它們的那一輪掃描, 而把每個重現案例放在它的手足旁邊, 正是讓這組測試在第九輪問「有沒有哪個又回來了」時還讀得下去的原因. 每一個修正都配上一個「必須維持安靜」的案例, 因為這些全都是對「工具說什麼」的變更, 其失效模式是偽陽性.
218
+
219
+ ## [0.19.0-beta.9] - 2026-09-18
220
+
221
+ **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
222
+
223
+ 本版本是第五輪全指令面掃描的 18 項缺陷. 前四輪的共同形狀是「兩個本該一致的東西已經分岔」; 這一輪把問題換個問法 — 不問兩個答案是否一致, 而是問**同一個機制在這個程式庫裡有幾個使用點, 哪一個沒有接上**. 七項 P2 裡有四項是這種形狀: code fence 遮罩有四個 parser 在用, 任務計數沒有; 路徑約束在 dashboard 端點、change 名稱、delta 目錄深度都做了, schema 自己的 `generates` 沒有; 碰撞保護 change 目錄有, 同一個函式裡的 ticket 搬移沒有; 清理邏輯 `update` 有, `init` 沒有. 這類缺陷的共通點是修法早就寫好放在那裡, 發現成本遠高於修補成本. 另外三項 P2 是規則的**缺口**而非不一致: 主 spec 的 Purpose 規則在 delta 端不存在、MODIFIED 只比對 scenario 名稱不比對內容、spec 合併沒有並行保護. 其中兩項會遺失工作成果且事後無法偵測. 本輪另補上第四輪列出但未修的兩項.
224
+
225
+ ### 新增
226
+
227
+ - **delta 文法新增 `## REMOVED Scenarios` 區段**: scenario 原本沒有任何刪除或改名的合法路徑. 三條路全部封死 — `MODIFIED` 省略掉一條現存 scenario 是 ERROR (這道防漏檢查是對的, 平行開發同一條 requirement 正是意外刪除的來源), 同一份 delta 裡 `REMOVED` 加 `ADDED` 同一條 requirement 是另一個 ERROR, 而 `--no-validate --yes` 也過不了, 因為 `buildUpdatedSpec` 自己還有一層複查. `RENAMED Requirements` 只處理 requirement 名稱. 於是文法在 scenario 這一層是唯讀且只增的: 一個打錯字的 scenario 名稱、一條行為已經拿掉的 scenario, 永久留在 spec 裡, 剩下的做法只有手改主 spec — 而那正是專案規則明文禁止的事. 新區段本身不執行刪除: 刪除仍然由 `MODIFIED` 的省略完成, 宣告的作用是讓那個省略成為**有意的**, 防漏檢查因此扣掉它而不是拒絕; 改名就是宣告加上同一個 `MODIFIED` 區塊裡的新 scenario. validator 與合併端讀同一份宣告, 所以 `validate` 與 `archive` 不會再各說各話. 宣告卻沒對應到任何 drop 以 ERROR 擋下 — 這是新區段引入的失效模式 (作者以為刪掉了, spec 還在, 所有命令都回報成功), 而它沒有任何一種讀法是對的. 決策記錄: `tospec/decisions/20260918_230803-removed-scenarios-is-the-declared-path.md`.
228
+
229
+ - **`tospec decision list --reindex`**: 把 `index.md` 的 status 欄從各決策檔重新回填. 另有 `decision new` 寫入索引時一併刷新其餘各列 — 詳見下方「`decisions/index.md` 的 status 從不回填」.
230
+
231
+ - **`tospec templates --json` 的 `exists` 欄位**: 逐一回報 schema 宣告的 template 檔是否真的在磁碟上.
232
+
233
+ ### 變更
234
+
235
+ - **schema 的 `generates` 必須留在 change 目錄內 (breaking change)**: 宣告了 `..` 片段或絕對路徑的 schema 現在會在載入時被拒絕. 症狀是 `generates: ../../../../ESCAPED.md` 解析到磁碟根目錄, `status` 把它當成一般 artifact 列出, 而 `instructions` — 這個命令的全部職責就是告訴 agent 該寫到哪裡 — 把該路徑當成輸出目標交出去, 而且是在同一份 JSON 文件裡, 該文件的 `actionContext.allowedEditRoots` 正寫著編輯範圍限於專案目錄. 根因不是解析錯誤而是**從來沒有這道檢查**: schema 的解析順序把 `<project>/tospec/schemas/` 排在最前面, 也就是說 schema 是 repo 內容, clone 一個 repo 就把它的 schema 一起帶進來, 而這個程式庫對其他每一個外部輸入的路徑都做了約束 (dashboard 的兩個檔案端點在註解裡就寫明是 trust boundary, delta spec 的目錄深度限制在剛好一層並附 ADR, change 名稱擋掉 `..` 與 `/`), 唯獨 schema 自己的 `generates` 沒有. 檢查放在 Zod schema 而非解析時的路徑比對: schema 只在載入時驗證一次, 沒有 change 目錄可以對照, 而一個純相對且不上溯的路徑在任何 change 目錄底下都安全, 這是可以在載入時就判定的性質.
236
+
237
+ - **`list --json` 的失敗信封改用 `null` (breaking change)**: 以 `changes.length === 0` 判斷的讀取端需要調整. 其他每個命令的失敗信封都把 data key 設為 `null` (`items`、`archive`、`change`、`instruction`、`id`), 只有 `list` 回空陣列 — 而空陣列與「這個專案目前沒有 active change」這個 exit 0 的正常回應完全無法區分, 於是依 CLAUDE.md 所記載的契約用 `<dataKey> === null` 判斷失敗的呼叫端, 把失敗讀成了空專案.
238
+
239
+ - **`list --specs` 對不適用的旗標不再靜默**: `--specs --changes` 同時指定時直接報錯 (兩者選的是不同的清單, 兩者之間沒有先後可言), `--specs --type` 走 `show` 同一套 "Ignoring flags" 警告並寫到 stderr, 使 `--json` 的 stdout 仍然是單一文件. 原本兩種情況都是任選一個然後不出聲.
240
+
241
+ - **綁在 wildcard host 的 local server 顯示可連線的位址**: `--host 0.0.0.0` 啟動時印的是 `http://0.0.0.0:<port>`, 而 `dashboard --list` 對同一個 server 印 `http://127.0.0.1:<port>`. 兩者不一致, 而印錯的是啟動訊息那一邊 — `0.0.0.0` 不是瀏覽器打得開的位址. 新增共用的 `connectableUrl`, dashboard 與 skill-metrics 都改用它.
242
+
243
+ - **`tospec skill-metrics` 走埠時說明原因**: 這個命令刻意不留 registry, 也沒有 `--list` / `--stop`, 撞到連接埠就往上走. 測試期間發現一個前一天啟動的實例仍佔著基準埠 26693, 上面還疊了兩個, 而 `dashboard --list` 看不到它們, CLI 沒有任何方式指出它們的存在. 走埠現在會明說 26693 被占用可能是先前的執行, 並說明本命令不留 registry. 兩個 local server 一併補上 `/index.html` 路由 (原本 `/` 回 200 而 `/index.html` 回 404).
244
+
245
+ - **SHALL/MUST 警告說明這條檢查只認英文**: 依既有 ADR, 這條檢查只比對兩個英文關鍵字, 所以從 ERROR 降為 WARNING, 避免擋掉以其他語言陳述義務的 requirement. 但訊息本身讀起來像一條無條件的義務, 沒有提到這項限制 — 用中文寫 spec 的團隊因此每一條 requirement 都固定帶一個警告, `validate --all --strict` 會拒絕整個 repo, 而沒有任何記載的出路. 訊息現在說明規則的範圍、為何維持 warning, 以及非英文 spec 應避開 `--strict`. 決策記錄: `tospec/decisions/20260814_134116-shall-must-missing-is-a-warning.md`.
246
+
247
+ ### 修正
248
+
249
+ - **並行 `archive` 讓主 spec 少掉一整條 requirement**: spec 合併是 read-modify-write — 讀取每一份目標 spec、在記憶體裡組出合併結果、全部驗證通過才寫入 — 而沒有任何東西讓它序列化. 兩個 archive 同時執行時雙方都讀到合併前的內容, 後寫的那份抹掉前一份的 requirement. 在 Windows 上約五分之一的重疊組合會發生. 每一個可觀察的訊號都說它成功了: 兩邊都 exit 0、都回報 `added: 1`、兩個 change 目錄都搬進了 `tospec/changes/archive/`; 輸掉的那份 delta 於是留在 archive 裡宣稱一條主 spec 沒有的 requirement, 而沒有任何命令偵測得到 — 沒有東西會拿已歸檔的 delta 去對照主 spec, 所以 `validate --all` 回報全面通過. 修法是跨行程 advisory lock, 涵蓋 `buildUpdatedSpec` → validate → write 整段; 起點刻意放在 change 選單與確認提示**之後**, 因為鎖跨過互動等待會讓其他行程被一個沒人看管的終端機擋住任意久, 而那兩個提示不讀任何 spec 內容, 本來就在臨界區之外. 改用寫入前重讀比對 (compare-and-swap) 遭否決: 它偵測得到衝突, 但除了失敗之外無事可做, 而且是在 change 的其他閘門都通過之後才失敗, 正是呼叫端手上資訊最少的時候.
250
+ - 修復過程中鎖自己還有一個缺陷, 一併記於決策記錄: `open(path, 'wx')` 先建檔再寫入紀錄, 兩者之間鎖檔是空的; 等待方把這個空檔案讀成「沒有紀錄 = 已失效」, 刪掉一個仍在使用的鎖並接手, 於是兩個行程同時持有, 並行合併照樣發生, 只是頻率降到約十分之一. 讀不出內容的鎖現在有一秒寬限期, 而呼叫端的 `timeoutMs` 對這段等待同樣生效.
251
+ - 決策記錄: `tospec/decisions/20260918_230804-archive-holds-a-lock-across-the-spec-merge.md`.
252
+
253
+ - **`MODIFIED` 只比對 scenario 名稱, 內文可被無聲回退**: 防漏檢查比對的是名稱集合, 不比對任何內容. 於是兩個平行開發的 change 改同一條 requirement 時, 後歸檔的那個只要把 scenario 名稱抄齊 — 而它一定抄得齊, 因為作者是在前一個 change 落地之前複製的 requirement — 就能把前一個已歸檔的內文整段回退, 而 `valid: true`、`modified: 1`、零輸出. 新增 `findDroppedBullets`, 在名稱檢查通過後比對 bullet, 以 WARNING 回報. 只比對 bullet 不比對散文: WHEN / THEN / AND 是文法的原子單位且各佔一行, 所以少掉一條 bullet 是真的遺失, 而段落在不同寬度重新折行會改掉裡面每一行卻一個字都沒變 — 對那種情況發警告等於教人忽略這個警告. 維持 WARNING 而非 ERROR, 因為刻意改寫 bullet 就是 `MODIFIED` 的用途, 而它在磁碟上與「用舊版覆蓋」完全相同. 用來做這個比較的 diff 機制原本就在 `tospec show --diff` 裡, 只是 validate 與 archive 沒有用它. 決策記錄: `tospec/decisions/20260918_230804-modified-drop-check-compares-bullets-only.md`.
254
+
255
+ - **tasks.md 的 checkbox 計數不認 code fence**: 任務計數是逐行比對, 沒有做 fence 遮罩 — 而 delta spec parser、主 spec 結構檢查、Purpose placeholder 檢查、以及 dashboard 自己的 markdown renderer 全都透過同一個 `buildCodeFenceMask` 認得 fence, 只有這裡沒有. 原始碼註解承認了這件事但只提到縮排式 code block, 實際咬到的是更常見的 fenced block: 一份說明「任務要怎麼寫」的 tasks.md 就會踩到. 三個面同時失效 — archive 閘門把文件範例當成未完成任務, 於是要過只能 `--yes`, 而 `--yes` 會把所有閘門降成 warning, 等於為了繞開一個假警報而關掉真的檢查; dashboard 的進度回報 2/3 而它自己的 renderer 只畫出一個 checkbox, 同一塊面板上分母與使用者數得到的框永遠對不起來; `POST /api/task` — dashboard 唯一的寫入路徑 — 接受 fence 內的行號並真的改寫了文件. 三處全部改走既有的 helper, `setTaskDone` 對 fence 內的行改回「不是任務」, 由 dashboard 轉成 409.
256
+
257
+ - **delta 的 `## Purpose` 逃過主 spec 的兩條規則**: 新 capability 的 delta 開頭寫的 `## Purpose`, archive 會逐字抄進它建立的主 spec. 主 spec 那條路對 Purpose 有兩條規則 (長度不足 50 字元、仍是 placeholder), delta 那條路兩條都沒有. 於是一個 change 可以通過 `validate --strict`、乾淨歸檔, 然後立刻讓 `validate --all --strict` 失敗 — 而此時 delta 已經搬進 `tospec/changes/archive/`, 作者再也改不到它. 這條特別值得修, 因為 `purpose-placeholder.ts` 檔頭自己寫的動機就是這件事 (「When a delta introduces a capability with no usable `## Purpose`, archive stamps the placeholder into the new main spec」): 檢查寫好了, 但只掛在蓋章之後, 沒掛在蓋章之前. 只在 capability 尚無主 spec 時判定 — 既有 capability 的 Purpose 在合併時本來就被忽略 (另有專屬警告), 在這裡評價它等於回報一個對任何東西都沒有效果的字串. 訊息另寫一套, 因為主 spec 那組叫讀者去改主 spec, 而此時主 spec 還不存在.
258
+
259
+ - **profile 收窄後 `tospec init` 不清 skill 目錄**: 把 profile 從 `core` 換成只含兩三個 workflow 之後, `init` 回報 `skills: 2`, 磁碟上卻留著全部 10 個. 同一次執行裡 `.claude/commands/tosx/` 被收窄到 2 個而 skill 目錄沒有 — 一個命令內部兩個目標處理方式相反. 而 agent 是從 skill 目錄載入 skill 的, 不是從 commands 目錄, 所以被排除掉的 workflow 全都還在、還是會被觸發, profile 收窄這個動作等於沒有生效, 同時那句 `skills: 2` 是一句關於 profile 而非關於磁碟的陳述. `update` 一直都清得乾淨, 所以修法是把 `removeUnselectedSkillDirs` 從 `update` 抽到共用模組讓兩者都呼叫; workflow 清單與目錄命名改由 helper 自己從 `WORKFLOW_DEFS` 推導, 呼叫端不再各帶一份可能落後的副本.
260
+
261
+ - **歸檔 ticket 檔名相撞時直接覆寫**: change 目錄的歸檔目的地在任何東西被寫出之前就做了保留與碰撞檢查, 同一個函式裡的 ticket 搬移沒有這道檢查, 而 `moveFile` 會覆寫. 同一個 change 名稱在同一秒內被建立兩次且中間夾一次歸檔時, 第一張 ticket 的永久紀錄被靜默取代 — 而這個前提會自己發生, 連續 create+archive 同名 change 的迴圈就自然產生了 active 與 archived 各有一張同名 ticket 的狀態. 相撞時改為留在 `tospec/tickets/` 並以 `ticket_archive_collision` 回報, 不改名也不覆寫: 帳本是一份紀錄, archive 自作主張替它取第二個檔名會讓索引與它自己 frontmatter 裡的時間戳對不上.
262
+
263
+ - **`decisions/index.md` 的 status 從不回填**: 上一版加上了 status 欄, 但只在寫入該筆時填 — 而 ADR 的生命週期 (proposed → accepted → superseded) 改的是**別筆**的檔案. 生成的 decision skill 同時要求兩件事: 「CLI 擁有索引, 絕不要自己手改 `index.md`」, 以及「若取代了較舊的決策, 把那一筆的 Status 改成 `superseded`」. 照著做, 帳本必然過期, 而 CLI 沒有任何 reindex 途徑可以補回來 — `decision list` 讀檔案回報 `superseded`, `index.md` 這份人實際會打開的檔案還寫著 `proposed`. `upgradeIndexColumns` 看起來像是那個機制, 但它只在 header 還沒有 status 欄的舊帳本上跑一次. 現在每次寫索引都把所有列的 status 從各自的檔案重讀, 另加 `decision list --reindex` 供沒有新增 ADR 時使用, skill 文案同步補上 status 欄與該指令.
264
+
265
+ - **`--schema decision` 的拒絕只在 change 解析成功後觸發**: 這句拒絕存在, 但掛在 change 解析之後, 所以兩條更早的路都走不到它 — 空專案回報「No active changes.」加 exit 0 (一個關於「這個 schema 永遠不可能指向 change」的乾淨答案), 有 change 但沒給 `--change` 則把使用者導去補旗標, 補了之後才被告知整個 `--schema decision` 本來就不該用. schema 名稱的檢查提到 change 解析之前, 三條路徑給同一個答案. (第四輪第 15 項)
266
+
267
+ - **`templates --schema X` 對不存在的 template 回報成功**: 這個命令只做路徑組合, 不確認檔案存在, 於是回報一條會 ENOENT 的路徑並 exit 0, 而 `instructions` 對同一個 schema 直接失敗 — 兩個命令對同一份 schema 是否可用給出相反的答案. 現在加上存在檢查, 缺檔以 warning 回報並在 `--json` 標記 `exists: false`. 維持 exit 0 且不中止整份清單: 其餘 artifact 的路徑仍然正確也仍然有用, 為了一個缺檔拒絕整份清單會讓讀者手上什麼都沒有.
268
+
269
+ - **大小寫只差的 capability 警告訊息指向不存在的路徑**: 合併對這種情況的處理本來就是對的 (認得是同一個 capability, 沒有造出第二份 spec), 但警告訊息用的是 delta 的拼法而非磁碟上的目錄名稱. 在 Windows 上那條路徑仍然打得開所以只是誤導, 在 case-sensitive 的檔案系統上則是一條開不了的路徑. 訊息改印合併實際解析到的目錄名稱; 只影響訊息, 不改變合併對象.
270
+
271
+ - **引數驗證失敗時 `root` 是否解析並不一致**: `new change <不合法名稱>`、`decision new --date <壞值>` 與 `migrate` 回報 `root: null`, 而 `list --type bogus` 與 `templates --schema nosuch` — 同樣是引數驗證失敗 — 都會解析 root. `migrate` 是最明顯的一個: 它回報 `root: null`, 而同一份 JSON 的訊息裡就印著那個路徑. 前兩者改為先解析 root 再驗引數, `migrate` 的失敗信封帶上它本來就知道的 cwd. commander 解析階段的錯誤 (例如 `--concurrency 0`) 維持 `null` — 那類錯誤發生在命令開始執行之前, 確實沒有 root 脈絡可言, 這條界線現在是規則而不是巧合.
272
+
273
+ ### 其他
274
+
275
+ - 測試套件由 88 檔 1279 項增為 91 檔 1319 項. 新增 `test/utils/file-lock.test.ts` (含空鎖檔那一案 — 正是它讓鎖的第一版看起來是對的)、`test/core/round5-regressions.test.ts` 與 `test/commands/round5-command-regressions.test.ts`.
276
+ - `test/core/local-server-bind.test.ts` 與 `test/cli/dashboard-detach-real-spawn.test.ts` 兩項既有斷言改寫: 它們斷言的是 wildcard bind 會印出 `0.0.0.0`, 而該行為已由本版更正.
277
+
278
+ ## [0.19.0-beta.8] - 2026-09-18
279
+
280
+ **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
281
+
282
+ 本版本來自對一個全新空專案連續四輪的全指令面掃描, 共 61 項缺陷 (17 / 11 / 15 / 18). 第一、二、四輪的方法是逐一執行每個命令並比對輸出; 第三輪換了一種找法 — 不看輸出, 改看這個程式庫**自己已經寫下來的政策**, 然後問哪些呼叫端沒有遵守它, 於是該輪 15 項裡多數是本專案在某處明文陳述的規則, 除了一個地方之外到處都成立. 四輪的缺陷共用同一種形狀: 兩個本該一致的東西已經分岔, 而沒有任何一方會發現 — 同一個條件被三個兄弟命令各答一種、同一份資料在 human 模式看得到而 `--json` 沒有、一份模板承諾了沒有任何程式碼執行的行為、一個 artifact 的「必要」在 artifact graph、`validate` 與 `archive` 三處各自定義. 其中六項會遺失工作成果, 一項讓 dashboard 的同源寫入權暴露給 `tospec/` 底下的任意檔案. 第四輪另有三項是第三輪修正的部分回歸, 全部同一個成因: 放寬一個判斷條件之後沒有回頭問「現在還會 match 到什麼」 — 收緊會被既有測試擋下, 放寬只會讓新的輸入被接受, 而那些輸入照定義就沒有測試.
283
+
284
+ ### 新增
285
+
286
+ - **`tospec init` / `update` / `rules` / `migrate` 支援 `--json`**: 這四個命令原本完全沒有機器可讀的輸出, 而它們正是 agent 在專案初始化與維護時會執行的那幾個. 一併把各命令的失敗 null-shape 集中到單一表格, 並將 `test/cli/json-failure.test.ts` 由逐命令案例改寫為表格驅動的全面掃描, 因此少了失敗 payload 的新命令現在預設就會讓測試失敗 — 原本沒有任何機制要求新命令必須具備失敗 payload, 漂移才是預設值.
287
+
288
+ - **`status` / `instructions --json` 帶出 `changeMetadata` (goal, decisions)**: `.tospec.yaml` 的 `goal` 與 `decisions` 會被驗證、會被持久化, 卻不出現在 agent 唯一看得到的那兩個命令裡. 這直接抵銷了 `tospec-propose` skill「撰寫 proposal 時引用已連結的 ADR」這條指示 — agent 無從得知有哪些 ADR 被連結上. 兩個欄位現在隨 change 狀態一起交付.
289
+
290
+ - **`tospec/decisions/index.md` 新增 status 欄**: 索引是人實際會打開的那份檔案, 而一則 `superseded` 的 ADR 在裡面讀起來與現行決策完全一樣, 必須逐一開啟決策檔才分得出來. 既有的 ledger 會在下一次寫入時就地升級, 狀態值從決策檔本身回填, 不需要遷移步驟.
291
+
292
+ - **`instructions apply` 的 `missingContext` 與檔案推導的任務編號**: 前者為非阻斷式診斷, 回報 apply 階段缺少哪些輸入; 後者讓任務編號取自檔案內容而非呼叫端的計數.
293
+
294
+ ### 安全
295
+
296
+ - **dashboard 的 `/api/render` 不再輸出未淨化的 HTML**: client 端以 `innerHTML` 指派回應內容, 而該路徑沒有 CSP, 因此 `tospec/` 底下任何檔案裡的一個 `<img onerror>` 都能在 dashboard 的 origin 內執行 — 也就落在守衛 `POST /api/task` 的那道 CSRF 邊界之內: 注入的 script 不是跨來源請求, 該防線所倚賴的 preflight 對它從不適用, 所以寫入權等同開放. 修正分三層: raw HTML 一律轉義而非採用允許清單 (spec 文件沒有使用 HTML 的理由, 而允許清單是一項持續的維護義務, 漏掉一個標籤就等於漏掉全部); URL scheme 改為解析而非樣式比對 (`java<tab>script:` 在瀏覽器裡會正規化, 在 regex 裡不會); 並讓每個回應都帶 `script-src 'self'`, 使前兩層萬一漏掉時仍有一道不依賴淨化正確性的防線. 決策記錄: `tospec/decisions/20260918_171106-dashboard-markdown-escapes-raw-html.md`.
297
+
298
+ ### 變更
299
+
300
+ - **`tospec archive` 新增 artifact 完整性閘門 (breaking change)**: 一個只有 `specs/<cap>/spec.md` — 沒有 `proposal.md`、沒有 `design.md`、沒有 `tasks.md` — 的 change 可以通過 `validate --strict` 並乾淨歸檔. 三個元件各持有部分視野而沒有任何一方提出異議: artifact graph 知道 sdd schema 把這三份標為 `optional: false`, 而它只回報、不設閘; `validate` 檢查的是已存在檔案的內容, 一份從未被寫出的檔案沒有內容可以失敗, 所以驗證是空洞地通過; `archive` 的閘門看的是驗證結果、任務勾選與 sync report, 沒有一個會去問 graph. 而歸檔是不可逆點 — change 移入 `tospec/changes/archive/`, delta 併入 `tospec/specs/`, 這套工作流程存在的理由 (why 與 how) 就此從未被記錄. 現在 archive 透過 `status` 所用的同一組 `loadChangeContext` + `formatChangeStatus` 詢問 graph, 以 `archive_artifacts_incomplete` 設閘並在訊息中指名缺哪幾份. 閘門可被 `--yes` 覆寫, 與 `archive_tasks_incomplete` 一致: 缺少規劃文件是規劃義務而非正確性義務, 呼叫端可能有其理由, 要求的是這項省略被說出來而不是被假定. 改為硬性錯誤遭否決 — 從他處匯入的 change、中途才採用 schema 的 change, 其文件永遠不會存在, 而本輪發現的失效是沉默, 不是寬鬆. 決策記錄: `tospec/decisions/20260917_155623-archive-gates-on-artifact-completeness.md`.
301
+
302
+ - **`status --json` 以 `optional` 取代部分情況下的 `skipped` (breaking change)**: 對 `skipped` 做特例處理的讀取端需要調整. 症狀是「schema 宣告為選用」與「這個 change 宣告不做」兩件事共用同一個狀態值, 而選用的 artifact 因此對 `nextSteps` 完全隱形, 永遠不會出現在任何一個下一步裡 — 一份選用但仍然值得寫的文件, 呼叫端不會被告知它存在. 兩者現在是相異的狀態. 決策記錄: `tospec/decisions/20260918_171157-optional-and-skipped-are-distinct-statuses.md`.
303
+
304
+ - **ticket frontmatter 的 `type` 改說 change-type 詞彙, schema 另立 `schema:` 欄 (breaking change)**: ticket 寫 `type: <schema>`, 而同一個 change 的 `.tospec.yaml` 與 `list --json` 寫 `type: <changeType>` — 一個鍵、兩套詞彙, 分佈在同一個 change 的兩份檔案裡. 對 `issue` 而言兩個詞恰好相同所以完全不可見; dashboard 早已把這道分裂吸收成一條同時比對 `.badge-requirement, .badge-sdd` 的 CSS 規則, 也就是說症狀早就被觀察到, 只是被當成樣式問題處理掉了.
305
+
306
+ - **`validate --strict` 的判準收緊到與 archive 一致 (breaking change)**: 原本綠燈的 change 可能開始被拒絕. 兩個成因分別修正 — (a) `findArchiveBlockers` 的預演早已算出正確答案, 卻歸檔在 `INFO` 而 `--strict` 只計警告, 於是 `validate --strict` 放行了 archive 即將拒絕的 change; 提升為 `WARNING` 而非 `ERROR`, 因為修改兄弟 change 尚未歸檔的 requirement 是受支援的情境, 用 ERROR 會擋掉它, 而 INFO 會讓歸檔前的閘門全盲. 決策記錄: `tospec/decisions/20260916_154000-archive-dry-run-findings-are-warnings.md`. (b) 見下方「`validate --strict` 對整份都是未填寫 template 的 change 回報 valid」.
307
+
308
+ - **`tospec instructions` 交付 schema 宣告的驗收條件**: `schema.yaml` 宣告了 `requiredSections` 與 `minSectionLength`, `validate` 一直都在強制執行, 而 loader 讀進來之後把它們丟掉了 — human 模式印出固定的 `<!-- To be defined in schema validation rules -->`, `--json` 則整段省略. agent 要得知 sdd 的 `Why` 有 50 字元下限, 唯一的途徑是把寫好的 proposal 送出去被退回. 那個空白區塊是較糟的一半: 它主張「這份 artifact 沒有驗收條件」, 是比沉默更強也更錯的宣稱. 規則現在隨 `ArtifactInstructions.validation` 一起交付, 而 artifact 未宣告任何條件時整個區塊省略, 與 `<project_context>`、`<rules>`、`<unlocks>` 既有的行為一致. 決策記錄: `tospec/decisions/20260917_234821-instructions-carry-acceptance-criteria.md`.
309
+
310
+ - **`archive --skip-specs` 由靜默丟棄改為具名警告**: 完全相同的情況經由 `skip_specs: true` 是一個硬性 ERROR, 經由 `--skip-specs` 旗標卻是無聲歸檔. 根因是兩者是不同種類的東西而外觀像同一個功能: `skip_specs` 是一項宣稱, validator 會去查核它; `--skip-specs` 是一道指令, 合併端直接照做. 旗標現在會指名它丟掉了哪些 capability. 維持為警告而非比照標記改為 ERROR: 旗標是使用者當下明確的指令, 把它變成錯誤等於讓這個旗標沒有用途. 決策記錄: `tospec/decisions/20260918_171157-skip-specs-flag-warns-rather-than-blocks.md`.
311
+
312
+ - **`archive --json` 一律帶 `totals`, `migrate --json` 補上 human 模式的後續步驟**: 前者原本在沒有任何合併發生時整個省略該鍵, 讀取端因此要區分「沒有欄位」與「數值為零」兩種情況, 而它們的意思相同; 現在未合併時歸零輸出. 後者原本漏掉延後的 ticket stub 提示與「接著執行 `tospec init`」這一步, 兩者 human 模式都會印.
313
+
314
+ ### 修正
315
+
316
+ - **空的 workflow profile 讓 `tospec update` 無法恢復**: 工具是否已安裝的判斷來自 `SKILL.md` 的檔案數量, 而 `removeUnselectedSkillDirs` 刪除的正是那些檔案 — 於是清空目錄的那一次執行, 同時銷毀了該工具曾被設定過的唯一證據, 下一次 `update` 回報「No configured tools found」, 只剩 `init` 一條路可回. 根因是 `configured` 同時在回答兩個問題; 現在它回答「這個工具有沒有 skill」供 init 選單使用, 另立的 `isToolInstalled` 回答「tospec 是否管理這個目錄」供 update / rules 使用, 判準是任何 profile 變更都不會移除的 workflow rule 文件. 以 `rules/tospec/` 目錄的存在為判準遭否決: 該目錄同時放著 legacy 的 init-only `decision.md`, 它的存在只證明 tospec 曾經碰過這個目錄一次, 不證明現在仍管理它. `update` 另在清空前提出警告, 指名該負責的 profile 與兩個可以反轉它的命令. 決策記錄: `tospec/decisions/20260916_152000-tool-install-marker-not-skill-count.md`.
317
+
318
+ - **`rules/tospec/decision.md` 被手動編輯後在下一次 `tospec init` 靜默消失**: 同一個目錄底下的兩份產生檔案走在兩套不同的機制上. `single-source-of-truth.md` 經 `planWorkflowRules`: 出貨模板包在一個 sha256 標記裡, 任何寫入前先讀取, 位元組一旦不符即以衝突拒絕, 而拒絕訊息本身就說明 `--force` 不會繞過它. `decision.md` 則經 `writeToolRules`, 且只有 `init` 會呼叫: 沒有標記, 所以無從分辨產生檔案與手改檔案; 沒有計畫, 所以沒有任何東西會回報它; 寫入是無條件的. 編輯它會在下一次 `tospec init` 遺失 — 靜默、狀態碼 0、預設路徑、不需要 `--force`. 而 `tospec rules` — 這個命令的全部職責就是刷新 rule 檔案 — 從不碰它也不列出它, 所以這道漂移連經由設計用途的命令都修不回來. 原始碼在自己的檔頭註解裡點名了這件事 (「Legacy decision rules retain their init-only writer」) 卻沒有把它當成缺陷. 現在只有一套機制: `MANAGED_RULES` 列出每一份 rule 文件, `planWorkflowRules` 對全部進行規劃、雜湊標記、衝突檢查與回報. 讓 `init` 改成「不存在才寫」遭否決 — 它止住了資料遺失, 卻讓該檔案對 `rules` 仍然不可見, 一份已漂移的副本依舊沒有任何命令會說出來. `unmarkedLegacyBodies` 認得舊 writer 產出的確切文字, 因此既有專案靜默升級, 不會為一份沒人動過的檔案撞上衝突. 決策記錄: `tospec/decisions/20260917_234821-every-rule-document-is-managed-alike.md`.
319
+
320
+ - **一個壞掉的 change 就讓 `tospec list` 整份消失**: 一個 `.tospec.yaml` 無法解析的 change 使 `list` 以狀態碼 1 結束、`changes: []`, 每一個健康的 change 都不見了, 而訊息指名了那個未知的 schema 卻從不指名是哪一個 change 宣告了它 — 修好它唯一需要的那項資訊, 正是被扣住的那一項. `status.ts` 以散文寫下了這條政策 (「One malformed change must not blank the sweep, so the entry carries the failure in place instead of aborting」), `validate --all` 也遵守它, `list` 是第三個批次命令, 也是唯一中止的那個. 現在逐一以自己的 try/catch 讀取, 攜帶指名該 change 的 `change_unreadable` 診斷, 並在完整信封仍然送達 stdout 的前提下以狀態碼 1 結束. 壞掉的 change 即使在 `--type` 過濾下也維持列出, 因為過濾讀的正是剛剛失敗的那份 metadata — 把它排除掉, 等於藏起使用者必須修好才能看到其餘內容的那一個. 決策記錄: `tospec/decisions/20260917_234821-batch-commands-degrade-per-item.md`.
321
+
322
+ - **拼錯的 REMOVED 標頭以乾淨的成功歸檔**: `buildUpdatedSpec` 用 `!options.silent` 包住它的非致命警告, 而 archive 設定 `silent: json` — 這道守衛精準地壓制了沒有 console 可讀的那些呼叫端, 留下 `removed: 0`、狀態碼 0、空的 `status[]`, 而該 requirement 仍留在主 spec 裡. 警告現在以帶碼的 notice 回傳並導入 `status[]`; 由同一個 helper 同時負責記錄與列印, 使任何呼叫點都無法只做一半. 比照 MODIFIED / RENAMED 把懸空的 REMOVED 改為致命遭否決: 重新套用一個已經同步過的移除是 no-op, 而早期同步這個模式正倚賴它, 所以修法是讓它可見, 不是讓它失敗. 決策記錄: `tospec/decisions/20260916_153000-spec-merge-notices-are-returned.md`.
323
+
324
+ - **同名的 scenario 讓 MODIFIED 的防漏檢查失效, 可以無聲刪掉一條 scenario**: 名稱是 `findMissingScenarios` 唯一的把手, 所以兩條共用同一個名稱的 scenario 對它而言可以互換 — 把其中一條重述兩次, 數量吻合, 另一條的內容則在歸檔時被刪除, 而全程驗證皆為綠燈. 重複名稱進入主 spec 的唯一途徑是從 delta 合併進來, 所以現在就在 delta 這一端拒絕它.
325
+
326
+ - **`validate --strict` 對「整份都是未填寫 template」的 change 回報 valid**: 三份原封不動的 template 依其構造必然滿足區段規則, 而 `minSectionLength` 把 HTML 註解算進長度 — proposal template 裡那句 71 字元的 `Why` 提示, 滿足了它正在提示的那個 50 字元下限, 而一個真正寫出來的十字回答反而收到警告. 註解不再計入長度, 且 `validate` 改為回報 `status` 早已算出的 stub 狀態, 兩者共用同一個述詞而非各自判斷. 決策記錄: `tospec/decisions/20260918_171157-validate-reads-the-stub-status-status-already-computes.md`.
327
+
328
+ - **`new change --schema <壞掉的 schema>` 成功, 產出永久不可用的 change**: `validateSchemaExists` 只檢查目錄存在, 從不載入檔案, 於是產出一個沒有 `type`、沒有 ticket、連自己的成功 payload 裡都沒有 `ticketPath` 的 change, 而沒有任何命令讀得了它. 更糟的是 `tospec schemas` 把壞掉的 schema 藏起來, `new change` 的錯誤訊息卻把它當成可用選項宣傳 — 兩個命令對同一份 schema 給出相反的答案. 現在在寫出任何東西之前先載入 schema, 壞掉的項目留在清單裡並攜帶其原因, 而每一份「Available schemas」清單都改由真正載入成功的那些組成.
329
+
330
+ - **ticket 帳本產生重複與錯配, archive 搬走的是舊的那一張**: 放棄一個 change 會留下它的 ticket (`rm -rf` 是唯一的途徑), 用同一個名稱重建會再寫一張, 而 archive 取 readdir 順序 — 也就是最舊的那張 — 因此把這次的執行歸檔到那個被放棄的嘗試的 ticket 底下, 並讓真正的那張永遠留在原地. 決策記錄: `tospec/decisions/20260918_171157-one-active-ticket-per-change-name.md`.
331
+
332
+ - **`show <issue-change>` 從不印出 `task.md`**: 主文件被寫死為 `proposal.md`, 而 issue schema 從不產生這份檔案, 於是 `show` 落到 ticket stub 並印出十一行 frontmatter, 取代了根因、修復計畫、測試計畫與任務清單. 主 artifact 現在從 schema 解析.
333
+
334
+ - **artifact 為 `stub` 時形成死路**: 檔案存在但內容仍是 template 的狀態下, `buildNextSteps` 只比對 `ready` 與全部完成, 所以這是唯一一個回報 `isComplete: false` 卻同時給出空 `nextSteps` 的狀態 — 沒有指示也沒有理由, 而這恰好是呼叫端無法自行推斷的情況, 因為目錄列表看起來是完整的. archive 接著把那些檔案稱為「missing required artifact(s)」, 把讀者送去磁碟上找檔案, 而它的 `fix` 指向的正是那個無話可說的 `status`. human 模式一直都指名了它 (`[!] design (stub: ...)`), 所以這單純是機器契約的缺口.
335
+
336
+ - **扁平 `--json` 失敗信封沒有任何 data key**: `status --change` 是第三個扁平 payload, 卻什麼都沒有 null 掉, 理由正是一輪之前的 ADR 已經否決過的那一個. 更關鍵的是該 ADR 所規定的兩項測試在空鍵集上都是空洞地通過 — 「失敗信封攜帶的每一個鍵在成功時都存在」對於零個鍵恆為真 — 所以規則現在改為直接斷言, 而 payload 會 null 掉 `changeName`. 決策記錄: `tospec/decisions/20260917_155623-flat-json-payloads-null-a-real-success-key.md`.
337
+
338
+ - **`decision` 指令線缺 root 紀律**: `decision new` 只建立 `tospec/decisions/`, 留下一個半成品 root, 而其他每個命令接著都把它解析為一個專案, 於是 `validate --all` 與 `status --all` 開始對一個從來不是專案的目錄回報乾淨的全面通過 — 這正是那兩個命令拒絕隱含 root 所要避免的空洞通過, 從側門重新進來了一次. `createChange` 一直都會補完 root 並附有說明理由的註解, 兩者現在共用 `completeRootStructure`. `decision list` 同樣會在任何地方都以狀態碼 0 回答「這裡沒有決策」, 現在比照其他批次命令拒絕隱含 root.
339
+ - **`decision new --force` 改寫檔案卻不更新 `index.md`**: 不追加第二列是對的, 什麼都不做則不是 — index.md 於是繼續宣傳前一份的標題與摘要, 而 `decision list` 從磁碟讀到的是新的. 現在以檔名比對就地改寫該列; 標題正是一次改寫最可能變動的東西.
340
+ - **`--date` 接受 `20261345_996199`**: 檢查只看形狀, 而這是唯一一個不由 `formatTimestamp` 產生的時間戳; 該錯誤值會成為檔名、帳本裡的人類可讀日期, 以及一個排序上壓過每一筆真實紀錄的鍵. 改以 `Date` 來回轉換而非逐欄位範圍檢查, 使月份長度與閏日都取自行事曆.
341
+
342
+ - **巢狀子命令的 `--help` 提示指向不存在或不相干的命令**: 提示用的是 `command.name()` — 葉節點的名稱. 對每一個 top-level 命令都正確, 因為兩者恰好重合; 對兩個巢狀命令則都錯: `new change` 指名了一個根本不是命令的字串, commander 因此印出 top-level help 並以狀態碼 0 結束, 於是這則建議看起來像是被回答了; 而 `decision new` 指名了建立 change 的那一組 — 一個真實存在、但回答另一個問題的命令. 現在由完整路徑組出.
343
+
344
+ - **一批對同一份資料給出兩個答案的較小修正**: `config set workflows` 在非 custom profile 下被接受、逐 id 驗證、儲存並由 `config list` 顯示, 然後被丟棄, 因為只有 `custom` 會去查詢它 — 在 `update` 之前沒有任何東西否定「它有生效」這個信念; 兩端現在都會警告, 而 `config list` 顯示時一併重述該但書. `config set --allow-unknown` 寫得進去的鍵, `config get` 讀不出來 — `list` 看得到、`unset` 移除得掉, 唯獨這個旗標存在的目的所服務的可腳本化讀取端說它無效; `get` 現在也接受該旗標, 只放寬已知鍵檢查, 絕不放寬原型安全檢查, 與 `set` 的切分方式相同. `POST /api/task` 會勾選 change 目錄下任何 `.md` 的 checkbox — 這不是路徑穿越 (root 限制成立), 但 `proposal.md` 與 `design.md` 正是這套工作流程存在所要保存的紀錄, 而該寫入在 UI 裡不留任何痕跡, 因為讀取端只回報 schema 追蹤的那份檔案; 兩端現在共用 `resolveTaskFiles`. 執行期失敗從 null-shape 回報 `root: null`, 即使解析其實已經成功 — `status --change nope` 會一邊列出該 root 底下可用的 change 一邊宣稱自己沒有 root, 呼叫端因此分不出「不是專案」與「專案沒問題, 只是 change 不存在」; 解析出的 root 現在記錄於 `resolveRootForCommand` 並僅由 `emitFailure` 填入, commander 層與 root 解析本身的失敗維持 `null`, 那對它們而言是準確的. `status --schema decision` 會規劃一個位於 `tospec/changes/<change>/decision.md` 的 artifact — `new change` 與 `instructions` 早已拒絕的孤兒 — 且其 `nextSteps` 要呼叫端去執行那個保證會拒絕的 `instructions`; 發出指示的那個命令, 正是沒有守衛的那一個. 另外四項是第三輪修正沒有觸及到的同類呼叫端: `list --type` 在解析 root 之前先驗證自己的參數, `init` / `update` / `rules` 在規則衝突時寫死 `root: null`, `instructions` 的「Valid artifacts」清單漏了 `apply`, 以及 `--schema decision` 的守衛坐在 change 解析之後因而在空專案裡從不觸發.
345
+
346
+ - **第一輪的其餘修正**: 未放置於既有 capability 的新能力缺少 `## Purpose` 時, 會以字面上的 TBD 靜默進入合併後的 spec (模板補上該區段, archive 回報該次替換); `new change --schema decision` 建立出沒有命令能抵達的 change, `--decisions` 接受對應不到任何 ADR 的檔名 (兩者改為預先拒絕, ADR 模板不再宣稱一個沒有東西會寫入的反向連結); `show --json` 扣住 requirement 與 scenario 名稱 — 正是 MODIFIED / REMOVED / RENAMED delta 必須逐字重現、而 archive 會為此硬性失敗的那些字串; `--yes` 不再把「rerun with --yes」當成修法回聲給使用者; 兩個 SCREAMING_SNAKE 狀態碼改為 lower_snake; 非專案目錄在 list / status / archive / validate 四處統一回報單一的 `no_tospec_root`; dashboard 的已歸檔計數不再把 change 與其 ticket 重複計入; `dashboard -d` 不再重複 `Error:` 前綴; 無 delta 時的訊息改以 `skip_specs` 為誠實的替代方案, 而非暗示去發明一條 requirement; workflow rule 檔名去掉 `sourc` 這個錯字, 遷移邏輯讀舊 slug、先寫再刪, 且遇到已編輯的副本時拒絕而非移除.
347
+
348
+ ### 其他
349
+
350
+ - **四輪掃描的方法與結果**: 測試由 83 檔 / 1112 passed 成長至 88 檔 / 1279 passed (1 skipped 為既有的 `it.runIf(platform !== 'win32')`, 與本版改動無關). 新增的測試大多正是它們的缺席才讓這些缺陷通過的那些斷言 — 扁平失敗信封至少攜帶一個 data key、`--help` 提示指名一個真實存在的命令、被編輯過的 rule 文件能存活過每一個會寫入規則的命令、遷移結果通過 `tospec validate --all`. 第二輪與第三輪的修正合併於同一個 commit: 第二輪的 11 項修復當時尚未提交, 而第三輪的 15 項觸及其中 13 個相同檔案, 拆開會需要 hunk 層級的手術, 並產生一個連自己的測試都跑不過的中間 commit.
351
+
352
+ - **`src/core/markdown-render.ts` 的兩個裸 NUL 位元組改寫為 `\x00` 逸出序列**: URL 淨化用的字元類別 `[\x00- ]` 把 NUL 直接寫成裸的控制字元. 執行結果完全正確, 但 git 的二進位偵測因此把整個檔案判為 binary, 於是它的提交對這個檔案印出 `Bin 0 -> 4490 bytes` 而不是逐行 diff — 本版唯一一項安全性修正所在的檔案, 就這樣在沒有任何可讀 diff 的情況下通過了審查. 逸出序列在 regex 字元類別裡與裸位元組完全等價, 所以這是純粹的來源表述修正, 行為與測試數皆不變.
353
+
354
+ - 上游 OpenSpec 參考點仍停在 `e062b95` (1.12.0), 本版與上游比對無關.
355
+
356
+ ## [0.19.0-beta.7] - 2026-09-16
357
+
358
+ **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
359
+
360
+ 本版本分兩層. 第一層是十個各自診斷到根因的 issue change, 它們共用同一種形狀: **命令的回報與它實際做的事分岔** — 為沒做成的工作回報成功 (`--detach` 印出一個沒人在監聽的 URL、archive 合併不到任何 delta 仍以狀態碼 0 結束)、丟掉自己已經收集到的資訊 (`--yes --json` 覆寫閘門後的警告、`config get` 失敗時不說原因), 或是描述一個它沒有的行為 (`init` 指向不存在的 `README.md`、dashboard 自稱唯讀). 第二層是對這十個 change 的實作逐條複查 Fix Plan / Test Plan / Tasks 所發現的九項缺口 — 關鍵在於**它們全部通過了完整測試**. 這九項可歸成三類: 放寬判斷條件後沒有回頭問「現在還會 match 到什麼」(收緊會被既有測試擋下, 放寬只會讓新的輸入被接受, 而那些輸入照定義就沒有測試); 同一條分支上的兩個 change 互相推翻了對方註解所陳述的前提; 以及 Test Plan 的項目在轉寫成 Tasks 時流失, 而兩份清單之間沒有任何機制對帳. 下列各項已把複查的修正併入它所修正的條目, 而非另立一節.
361
+
362
+ ### 變更
363
+
364
+ - **`config list --json` 的設定內容改放在 `config` 鍵之下 (breaking change)**: 輸出形狀由 `{ version, ...config }` 改為 `{ version, config: { ... } }`, 取值路徑由 `.profile` 變成 `.config.profile`. 症狀是執行 `tospec config set version 9 --allow-unknown` 之後, `config list --json` 的信封 `version` 會變成 `9`, 讀取端再也無從判斷這份文件的格式版本. 根因不在 `version` 這個欄位名稱, 而在**使用者資料與信封 metadata 共用同一個命名空間**: `GlobalConfigSchema` 是 `.passthrough()`, `--allow-unknown` 又刻意允許寫入這個 build 還不認識的任意 top-level key, 而展開發生在信封欄位之後, 所以使用者的 key 必定覆蓋信封; `root` 與 `status` 是同一個洞的另外兩個入口. 曾評估「展開後強制覆寫信封欄位」與「寫入時拒絕保留字」: 前者把資料遺失換了個方向 — 使用者確實寫進設定檔的 `version` 會從 `config list --json` 靜默消失, 而 `config get version` 仍看得到, 兩個命令對同一份設定給出不同答案; 後者修不了已經寫在設定檔裡的 key. 兩者還都需要隨信封演進手動維護一份保留字清單, 漏掉時沒有任何訊號, 只有巢狀化讓衝突在結構上不可能發生. 決策記錄: `tospec/decisions/20260916_121544-config-list-json-nests-under-config-key.md`.
365
+
366
+ - **delta spec 的 REMOVED / RENAMED 條目不再強制 `### Requirement:` 前綴**: 症狀是照出貨模板寫成 `- \`Old Name\`` 的 REMOVED 條目被判定為「no requirement entries parsed」, 而錯誤訊息建議改寫成 `### Requirement:` 區塊 — 一個模板從未示範過的形式. 根因是**被接受的語法只存在於一條 regex 裡**: 搜遍 `schemas/`、`assets/`、`src/core/templates/` 與 `.agents/`, `FROM:` 一次都沒出現, 唯一的說明是模板裡「RENAMED uses a FROM/TO pair」這句正確但不足以照做的註解. 因此修正不只放寬 parser, 而是三件事一起做: 在兩份 `templates/spec.md` 與兩份 `schema.yaml` 的 `specs` artifact instruction 裡放進可直接複製的範例 (後者才是 agent 經由 `tospec instructions specs` 實際收到的文字), 並在 RENAMED / REMOVED 區段存在卻解析出零筆時改印所需語法, 而非沿用指向無效修法的通用建議.
367
+ - **放寬的範圍隨後被重新界定**: 首次實作把 REMOVED 的 bullet 比對放寬成任意縮排、任意內容, 於是 `Migration:` 底下的巢狀續行與 `---` 分隔線各自都成了一筆 removed requirement — `---` 是 lazy quantifier 的典型陷阱, `(.+?)` 為了讓後續 pattern 成功而吃掉 token 中段, 解析出一個名為 `--` 的 requirement — 結果一份寫法完全合理的 REMOVED delta 反而拿到兩個假的 ERROR 而無法歸檔. 現限定為區塊頂層的單行 bullet: 不允許前導空白排除巢狀續行, 要求破折號後有分隔空白排除 `---` (這正是 Markdown 自己區分 list item 與 thematic break 的方式). RENAMED 維持寬鬆而不跟著收緊, 因為 `FROM:` / `TO:` 關鍵字本身就是錨點, 縮排與位置不參與判斷, 同樣的放寬在那裡不產生歧義. 決策記錄: `tospec/decisions/20260916_121552-removed-bullet-grammar-limited-to-top-level.md`.
368
+
369
+ - **`tospec archive` 的成功文件可帶 `status` 警告陣列**: `--yes` 覆寫任務閘門後, 該閘門的 `code`、`message` 與 `fix` 不再被丟棄, 而是以 `severity: "warning"` 進入成功文件的 `status`. 原本 `opts.yes` 分支在 JSON 模式下直接返回, 兩個閘門 (`archive_tasks_missing`、`archive_tasks_incomplete`) 攜帶的完整診斷就此消失. `!opts.json` 這個條件本身有正當理由 — 散文印在 stdout 會破壞 JSON 文件 — 但當初採取的做法是丟掉警告, 而不是把它導進文件自己的 `status` 陣列; 契約裡早就定義了 severity 欄位, 警告被丟棄唯一的原因是當時檯面上只有「印在 stdout」這一個選項. 狀態碼維持 0: archive 確實成功了, `--yes` 就是使用者這麼說的, 這是回報修正而非新增閘門.
370
+ - **失敗路徑同樣不再丟掉已收集的警告**: `--no-validate --yes --json` 會先觸發 `archive_confirmation_required` 警告, 若之後合併失敗, 失敗文件原本只帶錯誤. 而一次「跳過驗證之後才失敗」的執行, 正是讀者最需要知道那次覆寫的時候 — 它就是這次失敗之所以可達的原因.
371
+
372
+ - **`tospec dashboard` 的 `--help` 說明它會寫入**: 描述改為「task checkbox updates are the only writes, confined to this tospec root and blocked for archived or sync-certified changes」. 原描述與 `CLAUDE.md` 的架構段落都仍稱它唯讀, 而 `POST /api/task` 早已會把勾選寫回 change 的 tasks 檔; 模組自己的檔頭註解描述得完全正確, 沒跟上的是使用者在決定要不要開這個連接埠之前唯一會讀的那段文字.
373
+
374
+ - **`tospec-issue` 在寫入 task.md 時必須對帳 Test Plan 與 Tasks**: skill 的第 6 步與 guardrail 新增一條規則 — Test Plan 的每一條待補測試, 都必須對應到一個編號 task, 或在 Test Plan 裡說明為何不需要. 這是本版第二層複查發現的流失途徑: `tospec validate` 看到的是兩段散文, apply 則在 Tasks 的勾選框打完時回報完成, 所以一條沒有變成 task 的 Test Plan 項目會靜默消失, 而該 change 看起來仍然是完整的. 兩個實例都由此而來 — dashboard 的 `--host 0.0.0.0 --allow-remote --detach` 回歸測試與 decision 的同秒不同 topic 測試. 另評估過放在 `tospec-apply` 的 `VERIFY.md`: 那裡是複查時才觸發, 且依其定義是選用的 (「run when the user asks for a review」), 擋不下這一批.
375
+
376
+ - **`tospec init` 不再指向不存在的 `README.md`**: 收尾訊息中無條件輸出的 `Documentation: README.md` 已移除. 那行大概是為套件自身的 README 而寫, 但它在使用者的新專案裡呈現為一個專案相對路徑, 而 `init` 只寫入 `tospec/`、`.agents/` 與設定的工具目錄, 不會產生該檔. 相較於改指向套件首頁 URL, 直接移除較安全: `init` 本來就以具體的下一步作結, 而一個必須持續保持正確的連結, 就是一個還會再次過期的連結.
377
+
378
+ ### 修正
379
+
380
+ - **`tospec status` 從不讀取 `skip_specs`, 宣告無規格的 change 永遠停在未完成**: `skip_specs` 原本只有兩個消費者 — validate 的 `skipSpecsMarkerIssues` 與 archive 的 `options.skipSpecs` 分支 — `src/core/artifact-graph/` 底下一次都沒出現. 根因是 artifact graph 純粹由檔案存在性與 schema 的 `optional` 旗標推算狀態, 而 `skip_specs` 正是針對 schema 層級預設值的**逐 change 例外**, graph 沒有任何途徑看見它; 三個命令因此對同一個 change 給出不同答案. 修正讓 status 讀取 validate 與 archive 早已在讀的同一個標記 (`readSkipSpecsMarker`), 並將該 artifact 回報為 `skipped` 而非 `ready`, 同時把格式錯誤的值以 `invalidReason` 揭露而不是靜默略過. 另一個選項是在 sdd schema 裡把 `specs` 標成 `optional: true`, 但那會對每一個 sdd change 取消這項要求, 與 schema 的本意正好相反.
381
+
382
+ - **`tospec dashboard --detach` 為綁定失敗的 child 印出 URL 並以狀態碼 0 結束**: `child.pid === undefined` 只攔得住 `spawn` 本身失敗; 成功 spawn 之後所有可能出錯的事 — `--allow-remote` 拒絕、`EADDRINUSE`、權限錯誤、啟動時的例外 — 全發生在 `stdio: 'ignore'` 之後而無從觀測, parent 接著還為一個正在結束的 process 寫下 pid 紀錄. 修正是讓 child 有辦法回報結果: stderr 改為 `pipe`, child 綁定成功後送出 `LISTENING <url>`, parent 等到這一行才寫 pid 紀錄並印出 URL, 失敗則轉述 child 自己的錯誤文字並以非零狀態碼結束. 在 parent 預先檢查 host 只能修好重現步驟裡的那一種, 對 `EADDRINUSE` 或任何未來的啟動失敗仍會回報成功.
383
+ - **等待本身加上逾時**: 原先的握手只 race `LISTENING` 與 `exit` 兩個事件, 但一個被 spawn 的 process 有三種結局 — 成功、失敗, 以及**兩者皆非**. child 若綁定後卡住, parent 會永遠等下去, `--detach` 直接 hang 且沒有任何輸出. 現加上 10 秒逾時, 訊息說明的是「沒有觀測到 child 回報」而非宣稱啟動失敗 (child 仍在執行, 可能只是還沒起來), 並指向 `--list` / `--stop`. `child.unref()` 一併移進 `finally`: 在逾時這條路徑上 child 還活著, 未 unref 的 handle 會在錯誤都回報完之後繼續綁住 parent 的 event loop.
384
+ - **成功路徑補上真實 spawn 的測試**: 原本唯一的覆蓋是自己 emit `LISTENING http://...` 的 stub, 與實作對同一個字串雙向耦合 — 這種閉環只有在兩邊同時寫錯時才會失敗. 新的測試實際起一個 detached dashboard, 用 `--list` 確認看得到, 再以 `--stop` 收尾, 全程不提及那個 token.
385
+
386
+ - **`tospec dashboard --port` 完全沒有驗證**: `Number(options?.port ?? 5620)` 沒有 `Number.isInteger` 或範圍檢查, 所以 `notanumber` 變成 `NaN` 一路抵達 URL 字串; 連埠號遞增迴圈也擋不住, 因為 `used.has(NaN)` 恆為 false, 該值原封不動通過. 現以明確的整數與 `0..65535` 範圍檢查走標準錯誤路徑, `-p 0` 維持可用 — 它是把埠號選擇交給作業系統, `--list` 本來就正確回報實際埠號.
387
+
388
+ - **`tospec decision new --force` 覆寫檔案卻仍追加一列索引**: `appendIndexRow` 是無條件的, 當 `--force` 取代既有檔案時該檔的列已經存在, 於是索引多出一列重複. 既有的限制註解記錄了「append-only、不去重」這個決定, 但它設想的是「對同一個 topic 重跑 `new` 會多一列」, 沒有涵蓋 `--force` — 那裡的檔案並不是第二筆紀錄. 修正讓索引列以「這次寫入是新檔」為條件, 符合索引本身的語意: 一筆決策一列, 而不是一次呼叫一列.
389
+ - **存在性檢查不是原子的**: 檢查與寫入之間隔著 `loadTemplate` 與 `renderDecision`, 那是貨真價實的 I/O 寬度; 兩個 process 可以同時通過檢查、同時寫入, 而 `fs.writeFileSync` 預設的 `'w'` 旗標無條件截斷, 後者靜默覆蓋前者. 現改用 `{ flag: 'wx' }` 讓檔案系統來執行這道守衛, `EEXIST` 翻譯回既有的訊息, 使用者可見的行為不變; `--force` 維持 `'w'`. 任何 check-then-write 的方案都留有一個窗口, 無論把它縮到多小, 而檔案系統早就提供了這個原語.
390
+
391
+ - **capability 資料夾內檔名寫錯的 delta 驗證全綠卻被靜默丟棄**: `findSpecFiles` 只收集 basename 恰為 `spec.md` 的檔案, 而 validator 早已備有一整組針對「永遠不會被合併的 delta 檔」的守衛 (specs 根目錄的 `spec.md`、深度超過一層的路徑、點開頭的資料夾), 每一條都以同一句理由成立. 第四種失敗模式完全相同的情況 — 對的資料夾、錯的檔名 — 沒有守衛, 根因是**它在守衛迴圈看到之前就被 walker 過濾掉了**: 守衛只能裁決 walker 交給它的檔案. 合併端讀的同樣是寫死的 `spec.md`, 兩邊一致, 所以沒有任何地方回報衝突. 修正是新增一個回傳 `specs/` 下所有 `*.md` 的走訪器並在同一個迴圈裡以 ERROR 指名該檔, 而不是放寬合併路徑 — 合併只讀 `spec.md` 是版面配置的契約, 改動它會讓同一資料夾內的兩個檔案變得語意不明.
392
+
393
+ - **`tospec config get` 對未知的 key 與未設定的 key 都靜默失敗**: `getNestedValue` 對「這個 key 存在但沒有值」與「這個 key 不屬於 schema」都回傳 `undefined`, 單一的 `undefined` 分支於是把兩種不同的使用者錯誤壓成同一次無聲離開. 根因是 `get` 從不驗證 key 是否為真 — `set` 不會有這個問題, 因為它在寫入前會對照 schema 驗證. 現先驗證 key (沿用 `set` 的判準, 兩個命令對「什麼是有效的 key」保持一致), 未知的 key 與未設定的 key 各給一則 stderr 診斷. stdout 在兩種情況下都維持空白, 保住 `(raw, scriptable)` 的契約.
394
+
395
+ - **`tospec migrate` 丟棄 `openspec/project.md` 並寫出一行式的 `config.yaml`**: `migrate.ts` 裡搜不到 `project.md` 一字. 這比表面上嚴重: `openspec/project.md` 就是 OpenSpec 的專案脈絡, 其 tospec 對應物是 `tospec/config.yaml` 的 `context:` — `tospec instructions` 餵給 agent 的那個值 — 靜默丟失它等於把專案的技術棧、慣例與領域知識從其後每一份 artifact 的 prompt 裡拿掉. 由於 OpenSpec 專案的脈絡放在 `project.md` 而不是 `config.yaml`, 「來源沒有 config.yaml」才是真實遷移的常見路徑, 而那條分支只寫一行, 使用者連放回去的位置都看不到. 現將其內容折進 `context:` 區塊 (而非另存為 `tospec/project.md` — `context:` 才是 CLI 真正會讀的欄位, 放在 `tospec/` 下的 `project.md` 不會被任何東西讀取), 絕對分支改用 `init` 所用的同一份模板, 並在 summary 裡據實說明脈絡被帶過去、因既有 context 而未帶、來源為空, 或根本不存在.
396
+ - **產生的 YAML block scalar 補上明確縮排指示字元**: `context: |` 沒有 indentation indicator, 而 YAML literal block 的縮排是由**第一個非空行**推斷的, 所以 `project.md` 若以縮排行開頭 (四空格 code block、縮排清單), 推斷值會變成 6, 其後每一行正常縮排都不足而使區塊中途終止, 產出一份無法 parse 的 `config.yaml` — 而 migration 仍印出「Context: project.md -> config.yaml context:」並以狀態碼 0 結束, 與這個 change 本來要修的靜默失敗同類. 現改為 `context: |2`, 讓縮排不再取決於被序列化的內容.
397
+ - **遷移的 change 不補 ticket stub**: summary 會回報有多少個 change 需要補. ticket 檔名帶時間戳, 而其語意是「這項工作被提出的時間」, migrate 推導不出正確的值 — 遷移當下的時間、檔案 mtime、OpenSpec 封存目錄名只有日期的前綴, 全都是編造. 產生一份時間不可信的 ticket, 會把一個本來只是「缺少」的狀態變成「存在但內容錯誤」, 而後者更難發現. 另補上「遷移結果通過 `tospec validate --all`」的回歸測試, 把「缺 ticket 不影響驗證」這個前提釘住. 決策記錄: `tospec/decisions/20260916_121552-migrated-changes-defer-ticket-stubs.md`.
398
+
399
+ - **數個 `--json` 失敗缺少 null 資料鍵, 或根本沒有輸出 JSON 文件**: `show` 的非互動提示只寫 `console.error` 而從不檢查 `options.json`, 同檔案裡另外兩個失敗分支 (`unknown_item`、`ambiguous_item`) 都正確地經由 `emitFailureStatus` 帶上 `{ item: null, root }`, 唯獨這一個被漏掉; `instructions` 的失敗則完全沒有傳 payload. 根因不是這兩處各自寫錯, 而是**沒有任何機制要求一個新命令必須具備失敗 payload**, 所以漂移是預設值. 除了補齊兩處, `test/cli/json-failure.test.ts` 由逐命令案例改寫為表格驅動的全面掃描, 少了失敗 payload 的新命令現在預設就會讓測試失敗. `show --type` 一併改用 commander 的 `.choices(['change', 'spec'])`, 讓「無效的值」不再與「沒有給值」一樣被折成 `undefined` — 檢查因此與選項宣告放在一起, 和 `--sort` 既有的做法一致.
400
+
401
+ - **`tospec decision new` 的 topic 錯誤訊息自稱「Change name」**: 重用 `validateChangeName` 來檢查文法是對的 — topic 與 change name 共用同一套 kebab-id 文法, 而 `change-utils.ts` 是該文法唯一的定義處 — 缺陷在於它的錯誤字串是為單一呼叫端撰寫的, 另一個呼叫端直接包裝而未翻譯. 現讓 `validateChangeName` 接受呼叫端提供的名詞 (預設 `'Change name'`), `decision new` 傳入 `'Topic'`. 在 `decision` 呼叫點改寫字串會留下兩份會各自漂移的訊息, 修在共用驗證器才能維持一套文法、一套訊息.
402
+
403
+ - **`tospec validate` 的 Next steps 對放置錯誤印出無關的 delta 建議**: 判定一則 issue 是否與 delta 有關的依據是「路徑以 `spec.md` 結尾」, 而同一批改動新增的錯檔名守衛會產出 `auth/extra.md`、`auth/README.md` 這類路徑, 使該註解宣稱的前提不再成立. 更直接的是它反向也錯: specs 根目錄的放置錯誤路徑恰為 `spec.md`, 因此會收到三條通用的 delta 建議 — 而 `printNextSteps` 存在的意義正是不要「為另一種失敗給建議」. 判定改為 `endsWith('/spec.md')`: 差一個斜線, 但它讓三種鄰近形狀都落在正確的一側 — 兩種放置錯誤 (訊息本身已指名該改成什麼路徑) 與整個 change 的 `file` 哨兵 (其訊息經 `enrichTopLevelError` 後已含完全相同的三條建議).
404
+
405
+ ### 其他
406
+
407
+ - **`tospec dashboard --detach` 的兩個 pid record writer 刻意保留, 並補上等價性測試**: parent 與 detached child 都會寫同一份 pid record. 複查一度認定 parent 那次是純重複, 實際判定是兩者各自為某一種失敗模式下唯一存在的紀錄 — child 那次是每個 dashboard (含前景執行) 為自己寫的, 也是 parent 在握手中途被砍或逾時放棄時僅存的一份; parent 那次則保證呼叫回傳時紀錄已經存在, 因為 child 是**送出 `LISTENING` 之後**才寫自己的, 少了它, 緊接著的第二次 `--detach` 會找不到東西可擋, 而綁定後卡住的 child 會變成追蹤不到的 orphan. 兩者寫入的位元組相同, docstring 已據實說明順序與理由; 新測試從子目錄啟動, 讓兩邊各自推導 `projectRoot` 再斷言等價, 一旦哪天分岔就會失敗.
408
+
409
+ - **本版第二層複查的基準與結果**: 十個 change 的 task.md 逐條對照實際程式碼, 加上 `pnpm build && pnpm test` 全綠 — 九項發現全部是「測試通過但仍然錯」的情況. 其中兩項有可獨立執行的紅燈訊號 (YAML block scalar 的 parse 錯誤、REMOVED delta 的假 ERROR), 其餘是註解與實際行為的落差, 或是邊界情況本來就沒有斷言. 複查修正完成後為 83 個檔案 / 1112 passed, 較基準新增 17 個測試; 1 skipped 為既有的 `it.runIf(platform !== 'win32')`, 與本版改動無關.
410
+
411
+ - 上游 OpenSpec 參考點仍停在 `e062b95` (1.12.0), 本版與上游比對無關.
412
+
413
+ ## [0.19.0-beta.6] - 2026-09-14
414
+
415
+ **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
416
+
417
+ 本版本來自對 `feature/sync-1.12.0` 整條分支的逐行審查 (165 個檔案, 約 10.9k 行新增), 共回報 14 項發現: 12 項修正, 2 項查證後判定不需修改. 這些缺陷散落在七個模組, 卻只有兩種形狀. 其一是**條件寫成「當初想得到的那幾種寫法」, 而不是它所代表的概念** — wildcard 位址只列三種拼法、空字串被當成使用者提供的名稱、殘留偵測掃了比目標更大的目錄; 每一條單獨讀都成立, 遇到沒被列舉的那一個就靜默走錯分支. 其二是**重構只搬走主要路徑, 邊界上的既有保證留在原地** — 清理被關進「這次有寫入東西」的條件裡、讀取失敗從「照舊附加」變成「整段放棄」、診斷訊息跟著 verdict 分流時漏掉通過的那一半. 本版另推翻 0.19.0-beta.5 引入的 skill 連結交付: 該機制在單一平台上是對的, 在跨平台團隊裡卻使提交進 git 的內容取決於貢獻者的作業系統.
418
+
419
+ ### 變更
420
+
421
+ - **相依工具的 skill 目錄改以複製交付, 不再使用連結 (breaking change)**: `tospec init` / `tospec update` 寫入 `.claude/skills/tospec-*` 與 `.codex/skills/tospec-*` 的方式, 由指向 `.agents/skills/` 的連結改回獨立複製. 症狀是提交進 git 的內容會隨貢獻者的作業系統改變 — 連結沒有跨平台的統一寫法: Windows 用 junction, git 會穿透它並記錄成一般的 `100644` 檔案; POSIX 用 symlink, git 記錄成 mode `120000` 且 blob 內容就是目標路徑字串. 於是 POSIX 貢獻者跑一次 `update` 會把二十個一般檔案變成 `120000` blob, Windows 貢獻者再跑一次又變回來, 雙方都不是有意修改內容, 卻都在審查裡看起來像刻意的編輯. 更嚴重的是本專案 `core.symlinks` 為 `false`: 這種 blob 取出後會變成一個內容為 `../../.agents/skills/tospec-apply` 的文字檔佔住 skill 目錄的位置, 所有 agent 靜默載入不到任何東西. 根因在交付機制本身依賴平台, 不在 git 設定, 所以先嘗試的 gitignore 方案被否決 — 它只是把依賴作業系統的機制藏起來, 還在「clone 完成」與「手上有 skill」之間插入一個建置步驟, 代價正好落在這次要照顧的跨平台隊友身上. 換得的代價是編輯 `.agents/skills/` 後需重跑 `tospec update` 才會反映到工具目錄, 而本專案的 skill 正文本來就由 TypeScript 產生, 重跑 update 早已是既定流程. 決策記錄: `tospec/decisions/20260914_205300-copy-dependent-skill-dirs-for-cross-platform-teams.md`.
422
+ - **舊版留下的連結不需要遷移步驟**: `copyDir` 會先清空目的地, 而 `fs.rm(recursive)` 對 junction 或 symlink 是解除連結而非遞迴刪除, 因此停在 0.19.0-beta.5 的專案下次執行 `tospec update` 即就地轉換為複製, canonical 目錄不受影響.
423
+ - `init` 的輸出不再區分 linked / copied 兩種結果, 改為單一行說明哪些目標拿到複製, 以及編輯 canonical skill 後需要重跑; `DeliveryKind` 匯出一併移除.
424
+ - 這推翻的是 `20260910_214500` 的交付機制一項, 該 ADR 其餘決定 (agents 列為一般可選取列、`requires` 取代 always-on、清空選單等同 `--tools none`、偵測不到時的回退) 全數不受影響; 舊 ADR 已標記為部分被取代.
425
+
426
+ - **`tospec archive` 不再重跑一次合併預檢**: 驗證流程裡的 `findArchiveBlockers` 會逐一重建每份受影響的 spec — 每個 capability 都重讀並重新解析 delta 與整份主 spec — 藉此以 INFO 等級回報合併會拒絕什麼. 對 `tospec validate` 而言這正是它的用途: 作者在嘗試歸檔前就知道. 對 archive 而言則是同一份工作做兩次, 因為 archive 緊接著就執行真正的合併, 而真正的合併早已把同一個前置條件轉成 `archive_spec_update_failed` 並以非零狀態碼結束 — 那是 INFO 等級本來就辦不到的事. 改為新增 `archivePreflight` 選項, archive 傳入 `false`. 「validate 綠燈 ⟺ archive 通過驗證」這項等價關係談的是 verdict, 而 INFO 從來不影響 verdict.
427
+
428
+ ### 修正
429
+
430
+ - **`--allow-remote` 搭配 `::0` 等寫法會綁定所有介面後拒絕每一個請求**: `WILDCARD_HOSTS` 收的是字串拼法而非位址, 只列了 `0.0.0.0`、`::` 與完整展開的 IPv6 形式. `tospec dashboard --allow-remote --host ::0` 因此通過綁定守衛、在每個介面上監聽, 然後 `acceptsAnyHostHeader` 回報 false, 使每一個從其他機器抵達的請求都因 Host header 既非 loopback 也不字面等於 `::0` 而被 403. 這正是 `20260910_150312` 當初要消除的「綁定後拒絕全部」失效, 只是換一種同義的位址拼法就再度成立 — 根因不是清單漏了哪一項, 而是比對的對象是拼法而不是位址. 現以 `normalizeHost()` 先把值送進 WHATWG URL parser 正規化 (Node core 裡唯一現成的 IPv6/IPv4 正規化器, 會把 `::0`、`0:0:0:0:0:0:0:0`、`::` 收斂成同一個字串), 集合改存正規形式. 往清單裡補上漏掉的拼法被否決: 那是用一份更長的清單再犯一次同樣的錯. 正規化同時套用到三個檢查而非只有出錯的那一個, 因為該模組自己的檔頭就寫著「同一道防護的第二份實作, 是修好第一份之後仍然存在的漏洞」. 一併修好同類的反向缺陷: `--host 0:0:0:0:0:0:0:1` 原本會被當成非 loopback 而拒絕綁定, 那是守衛在反對一種拼法而不是在反對一次曝險. 無法解析的 Host header 仍在正規化之前就被既有的 regex 閘門擋下.
431
+ - **`tospec archive ""` 會對互動選單剛列出的目錄丟出名稱格式錯誤**: 判斷名稱來源的 `nameFromArgument` 比對的是 `changeName !== undefined`, 於是空字串被算成「使用者提供了名稱」, 而後續的 `!changeName` 仍然把它送進互動選單. kebab 格式守衛接著就對選單剛回傳的目錄執行 — 而那道守衛自己的註解寫明它只該作用於參數路徑, 因為選單列出的是磁碟上真實存在的目錄, 其中有些早於這條約束 (`tospec migrate` 逐字複製 OpenSpec 的目錄名, snake_case 在那裡是慣例). 這是 0.19.0-beta.3 修好「守衛跑在選單結果上」之後, 由同一道守衛的另一個入口重新製造出來的. 現改為 `typeof changeName === 'string' && changeName.length > 0`: 空字串是沒有提供名稱, 不是提供了一個空的名稱.
432
+ - **ticket 無法讀取時, 歸檔會靜默丟失 Related Decisions 區段**: `appendDecisionLinks` 為了偵測 ticket 自身的換行慣例而先讀檔 (這是 0.19.0-beta.3 修正 CRLF 混用時加上的), 但讀取失敗時直接 `return`. `appendFile` 從頭到尾不需要讀取權限, 所以這個改動把一條原本能運作的路徑 — ticket 可附加但不可讀回 — 變成 Related Decisions 永久遺失, 而歸檔仍回報成功. 換行慣例是外觀問題, 決策連結是資料; 現在讀取失敗改為回退到 `\n` 並照常附加.
433
+ - **`--tools none` 不再清理退役的 skill 目錄**: `removeLegacySkillDirs` 在重構中被移進 `if (tools.length > 0)` 條件內. 但清除 tospec 自己在舊版產生的目錄, 與這次執行是否寫入新內容無關 — 被關進條件後, `--tools none` (使用者用來停止 tospec 寫入 agent 內容的方式) 反而讓 `.agents/skills/tospec-verify` 這類退役 workflow 留在磁碟上, 繼續被每個讀取 `.agents/skills/` 的 agent 載入, 與使用者的意圖正好相反. 現移回條件之外.
434
+ - **清空選單或 `--tools none` 會印出成功橫幅卻不說明什麼都沒寫**: 成功訊息的每一個區塊都以「選取非空」為前提, 於是空選取的執行會印出 `tospec Setup Complete`、config 行, 以及一行叫使用者執行 `tospec-propose` 的提示 — 而那是一個並不存在於磁碟上的 skill. 接受空選取是 `20260910_214500` 的決定, 沒有問題; 把它回報成一次普通的成功才是問題, 尤其清空選單是很容易誤觸的操作. 現會明說沒有寫入任何 skill、command 或 rule, 且不再建議一個不存在的 skill.
435
+ - **相依關係提示印給了沒有選到該工具的人, 且印在無法影響選擇的時機**: `discloseToolDependencies` 迭代整張 `AI_TOOLS` 並由 `execute()` 無條件呼叫 (僅 `--tools none` 例外), 所以 `tospec init --tools agents` 會印出「Claude Code 會把 skill 連結進 .agents/skills/, 因此選它也會一併選取 Agents」— 對方既沒有安裝 Claude Code, 也沒有任何選單可被這句話影響. 該方法自己的註解已經寫出理由: 這段說明存在的意義是「在使用者回答之後才抵達的解釋無法影響答案」, 而該前提只在有提示可回答時成立. 現改由互動選單路徑在提出問題前呼叫.
436
+ - **專案自有的 `.codex/rules/` 檔案會被誤判為 tospec 殘留**: `hasAnyRuleFile` 計算 `.codex/rules/` 底下任何位置的任何檔案, 但它觸發的提示宣稱那些檔案是 tospec 的產物, 並指名 `.codex/rules/tospec/` 為可刪除的對象. 於是一個從未執行過舊版 tospec、只是自己在 `.codex/rules/team-style.md` 放了規範的專案, 每次 `init` 與 `update` 都會收到這則提示, 而它指名的路徑並不存在. `writeToolRules` 只寫入 `rules/tospec/` 之下, 掃描範圍現與之對齊 — 與 skill 那一半早已限定 `tospec-` 前綴的做法一致, 也才符合該模組自己在下一段就寫明的承諾: 目錄單純存在並不構成殘留.
437
+ - **通過的 `tospec validate` 仍把診斷訊息寫到 stderr**: findings 刻意印在 verdict 分支之外, 讓通過項目上的 WARNING 依然可見 (只在失敗時印等於把降級實作成刪除, 這是 0.19.0-beta.1 的既有決定). 但它們一律走 `console.error`, 於是 `tospec validate <id>` 可能以狀態碼 0 結束卻留下非空的 stderr. pre-commit hook、檢查該串流的 CI 步驟, 以及解析輸出的 agent 都會把它讀成失敗 — 同一個降級換條路徑再被實作成刪除一次, 而且這次還附帶一次假警報. 現在每個項目的 findings 跟著自己的 verdict 走同一條串流 (bulk 模式亦同), 狀態碼 0 即代表 stderr 為空.
438
+ - **`resolveToolSelection` 遇到相依環會產出錯誤的順序而不是拒絕**: 守衛在重新走到「正在拜訪中」的節點時提早 `return`, 等於吸收掉環而不是回報它, 而且仍然產出一份清單 — 由於內層呼叫先 push, 那份清單會把相依者排在它的相依對象之前. 呼叫端正是依照回傳順序寫入目標, 為的就是讓 canonical 目錄先存在再從中複製, 因此這份靜默的結果會讓它從一個尚未寫入的來源去產生工具目錄. 沒有任何順序能滿足一個環, 所以錯的是資料表本身, 必須在編輯發生的地方就說出來: 現改為丟出例外. 目前出貨的 `AI_TOOLS` 無環, 另有一項測試斷言這件事, 因此這道守衛保護的是未來新增的列而非現存狀況.
439
+
440
+ ### 其他
441
+
442
+ - **TypeScript 7.0.2 與 vitest 5.0.0 首次在確實安裝的狀態下通過驗證**: `pnpm-lock.yaml` 自 0.19.0-beta.5 起已 pin 住這兩個 major 版本, 但本版開工時 `node_modules` 實際仍是 typescript 5.9.3 與 vitest 4.1.10, 且 `pnpm install --frozen-lockfile` 判定兩者不相容、需要先清空 `node_modules`. 專案沒有 CI, 沒有任何地方會察覺這段落差. 補上一個陷阱: 該次 `pnpm install` 因為沒有 TTY 可確認清空動作而放棄安裝, 卻以狀態碼 0 結束 — 任何以離開碼為唯一判準的腳本都會被它騙過; 需要 `CI=true` 或 `--config.confirmModulesPurge=false` 才會實際執行. 對齊 lockfile 後重跑, `pnpm build` 在 tsgo 原生版 TypeScript 7.0.2 下通過, 測試在 vitest 5.0.0 下 1017 passed / 1 skipped (80 個檔案), 原始碼與 `vitest.config.ts` 均無需為此修改.
443
+ - **兩項回報經查證後判定不需修改**: 其一是 `resolveArtifactOutputsAsync` 與同步版重複三個分支的疑慮 — `outputs.test.ts` 已有 parity 測試, 對非 glob、單層 wildcard 快速路徑、fast-glob 回退、前綴不存在四個分支各自斷言兩者結果相同, 漂移已被守住; 且它宣稱要消除的停頓在它宣稱的路徑上並不存在 (`collectOverview` 只經由同步的 `artifactOutputExists` 與 `listTasksForChange` 觸達本模組, 而 `dashboard-data.ts` 記錄的量測是 30 個 change 下最長停頓 2.1ms, 那正是用同步版量到的). 真正錯的是 docstring 指錯了服務對象, 已改正並註明任何新行為都必須加入 parity 清單. 其二是 `metrics-refresh-atomic.test.ts` 的 temp 目錄洩漏 — 清理實際存在於兩個測試各自的 `finally` 區塊, 實測執行前後 temp 目錄零殘留.
444
+ - **審查修正立為 issue change 追蹤**: `tospec/changes/code-review-fixes/`, 14 項發現逐一記錄處理方式, 含上述兩項「查證後不修改」的理由. `CLAUDE.md` 的 generated-files 段落同步更新, 說明工具目錄是複製而非連結; 其中提到的三個手寫 skill 目錄 (`grill-me` / `grill-with-docs` / `grilling`) 已在 0.19.0-beta.5 退役, 該段敘述一併更正.
445
+ - 上游 OpenSpec 參考點仍停在 `e062b95` (1.12.0), 本版與上游比對無關.
446
+
447
+ ## [0.19.0-beta.5] - 2026-09-11
448
+
449
+ **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
450
+
451
+ 本版本是一次例行的相依套件更新: 依 npm-check-updates 回報的六個過時套件逐一升級, 三個 patch/minor 沒有引出任何相容性問題, 兩個 major 版本 (vitest、@inquirer/core) 也只是換了版號, 唯獨 TypeScript 5.9.3 直接跳到 7.0.2 這一步, 暴露出套件本身重構帶來的相容性落差. 六個套件刻意逐一升級並各自跑過完整 build 與 test, 而非一次套用全部版本再統一驗證, 使得唯一出狀況的那一個能被準確歸因, 不必事後在六個同時變動的套件裡回溯.
452
+
453
+ ### 變更
454
+
455
+ - **六個過時相依套件依 npm-check-updates 報告逐一升級並驗證**: `marked` 18.0.6 → 18.0.12, `@inquirer/prompts` 8.5.2 → 8.7.2, `zod` 4.4.3 → 4.6.2 (皆為 patch/minor, 未觀察到任何行為變化), 以及三個 major: `@inquirer/core` 11.2.1 → 12.0.3, `vitest` 4.1.10 → 5.0.0, `typescript` 5.9.3 → 7.0.2. 每次只調整一個套件, 立即跑 `pnpm build` 與 `pnpm test` 確認 998 passed / 1 skipped (80 個檔案) 不變, 再進入下一個.
456
+ - `@inquirer/core` 升級前, 直接相依其實已經與傳遞相依不同步: `@inquirer/prompts` 8.7.2 底下的 `@inquirer/input` 等子套件已要求 `@inquirer/core` `^12`, 專案自己宣告的卻仍是 `^11.2.1`, 使 `node_modules` 內同時存在兩份互不相容的 `@inquirer/core`. 升級後傳遞相依收斂回單一版本 (減少 4 個套件), 這次升級因此也是在修一個既有的相依漂移, 不只是取得新版號.
457
+ - `@types/node` 另補到 24.13.4, 純型別定義更新, 不影響執行期行為.
458
+ - `pnpm` 的供應鏈保護機制 (`minimumReleaseAge`) 認定 `zod@4.6.2` 發布時間過近, 自動在 `pnpm-workspace.yaml` 加入 `minimumReleaseAgeExclude` 例外項才放行安裝, 屬於 pnpm 自身的預期行為.
459
+
460
+ ### 修正
461
+
462
+ - **`build.js` 的 tsc 路徑解析在 TypeScript 7 下丟出 `ERR_PACKAGE_PATH_NOT_EXPORTED`**: `runTsc` 原本以 `require.resolve('typescript/bin/tsc')` 取得執行檔路徑, 而 TypeScript 7 的 `package.json` `exports` map 不再對外公開 `./bin/tsc` 這個 subpath (只有 `bin` 欄位仍指向它) — Node 一旦看到 `exports`, 就把它當成允許清單, 未列出的 subpath 一律視為不存在, 於是一條先前正常運作的路徑, 在編譯行為本身完全沒變的情況下失效. 改為經由仍公開的 `./package.json` 取得套件根目錄, 再以檔案系統路徑手動組出 `bin/tsc`, 不再依賴該 subpath 是否被 exports map 收錄. tsc 對 `src/**/*` 的型別檢查結果不變, 沒有任何原始碼因這次升級而修改.
463
+
464
+ ### 其他
465
+
466
+ - **本版未立為 sdd change, 也未新增 ADR**: 六個套件的版號調整與一處相依套件重構後的路徑解析修正, 屬於例行維護, 沒有推翻或新立任何架構取捨. 驗證涵蓋 `pnpm build`、`pnpm test`、`pnpm skills`/`pnpm templates` 兩支產出腳本, 以及對本專案自身 `tospec/` 目錄執行 `tospec validate --all` 與 `--version`/`--help` 的 CLI 手動驗證. 上游 OpenSpec 參考點仍停在 `e062b95` (1.12.0), 本版與上游比對無關.
467
+
468
+ > **補記 (0.19.0-beta.6 加註)**: 上面這段與本節開頭的「例行相依套件更新」描述涵蓋不全. 本版實際還出貨了 `bd45c76 feat(init)!`, 該 commit 以常駐的 `.agents` 目標取代 Codex 寫入目標並改用連結交付 skill, 是一次破壞性變更; 隨附的 `agents-tool-target` change 已歸檔, 手寫的 grill skill 系列一併退役, 並新增兩份 ADR (`20260910_201813-codex-tool-replaced-by-agents-dir.md`、`20260910_214500-agents-listed-target-linked-delivery.md`), 因此「未立為 sdd change, 也未新增 ADR」在事實上不成立. 本條目依慣例保留原文不改寫, 缺漏的部分改由此註記與 0.19.0-beta.6 的條目說明; 其中的連結交付機制已於 0.19.0-beta.6 推翻.
469
+
470
+ ## [0.19.0-beta.4] - 2026-09-10
471
+
472
+ **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
473
+
474
+ 本版本只改一份檔案的正文: 隨套件發布、載入到 agent 的 workflow 規則模板. 0.19.0-beta.3 已經把它縮減為正確的兩條原則, 這次要處理的不是它要求什麼, 而是它怎麼被讀 — 一份以權威身分載入 agent 的文字, 若只寫命令不寫理由, 它能約束的就只有它字面涵蓋到的情境.
475
+
476
+ ### 變更
477
+
478
+ - **workflow 規則模板改寫為結構化英文, 每條規則附上自己的失效機制**: 模板正文原本是兩條 bullet, 第一條英文、第二條中文, `IMPORTANT:` 夾在第一條的句子中間, 兩條都只給命令不給理由. 混語言與埋沒的強調只是表面症狀; 成因在於這份檔案的身分 — 它是被當成權威來源載入 agent 的那一份文字, 而沒有理由的命令在邊界情境無法自我防守: agent 讀得懂「除非必要」的字面意思, 卻推不出違反它會發生什麼, 於是「必要」的門檻實際上由當下的方便程度決定. 第一條還有一個更具體的問題: 「以程式碼為準」沒有指出方向, 而 tospec 自身就內建 `/tospec-sync` 這種把規格對齊程式碼的流程, 「文件與程式碼不一致」因此很容易被讀成「該修程式碼讓它符合文件」, 恰好是這條規則要禁止的那一半. 現改為兩個具名 section, 各自寫出規則、可執行的行為 (回報落差而非改程式碼去迎合文件; 斷言前先在程式碼中驗證) 與失效機制 (文件記錄的是撰寫當下的意圖, 會隨程式演進漂移; 歸檔目錄不隨程式更新), 並把優先序提到標題下方獨立一行, 明講它高於文件、註解、規劃產物與先前對話. 只把中文那條翻成英文的最小改法被否決: 那修掉了語言不一致, 卻原樣留下真正會失效的部分. 規則語意一字未變, `agent-workflow-rules` 規格要求的三件事 (程式碼優先、預設不讀兩個 archive 目錄、只保留原則而不複製各 skill 的程序) 全數保留, 因此本次沒有 delta spec. 既有產出檔的正文仍符合自身記錄的雜湊, 下次 `init` / `tospec rules` / `update` 會判定為正常升級而非衝突, 使用者無須手動處理.
479
+
480
+ ### 其他
481
+
482
+ - **本版未立為 change, 也未新增 ADR**: 變更落在 `agent-workflow-rules` 既有規格的框架之內, 是措辭與結構的改寫, 沒有推翻或新立任何取捨, 因此 `tospec/decisions/` 自 0.19.0-beta.3 的三份 ADR 之後沒有新增. 本專案自身的 `.claude` / `.codex` 規則檔已由 `tospec rules` 重新產生, 與 `assets/` 模板同步. 上游 OpenSpec 參考點仍停在 `e062b95` (1.12.0), 本版與上游比對無關. 測試 959 passed / 1 skipped (78 個檔案).
483
+
484
+ ## [0.19.0-beta.3] - 2026-09-10
485
+
486
+ **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
487
+
488
+ 本版本把 0.19.0-beta.2 出貨時明列的七項缺陷全數修掉, 並替換掉同一版才引入的 workflow 規則正文. 七項缺陷沒有共同的機制, 只有共同的發現方式: 它們是審查 `main...HEAD` 時逐行讀出來的, 測試套件一項都沒有覆蓋到. 但它們確實共有一種形狀: **守衛檢查的內容是對的, 位置是錯的** — 檢查跑在互動選單覆寫過的變數上、掃描跑在比目標更大的索引上、路徑推導對歸檔目錄回答了一個不存在的 change 名稱、opt-in 只抵達兩道防護的其中一道. 每一個守衛單獨看都成立, 放在邊界的錯誤一側就什麼都沒有保護到. 而 beta.2 之所以帶著它們出貨, 是因為當時只把它們記成一份 issue change 與一支 probe script: 明列已知缺陷不等於處理它們.
489
+
490
+ ### 變更
491
+
492
+ - **workflow 規則模板改為兩條原則, 不再重述各 skill 的階段邊界**: 0.19.0-beta.2 引入的模板正文是七條分階段指引 (先讀既有 specs 與 ADR、規劃只產出文件、實作依已確認 tasks、決策走 tospec-decision、同步不得掩蓋缺漏、歸檔前完成驗證、不強制所有開發先走 tospec), 現改為兩條: 程式碼為唯一事實來源, 以及除非必要或使用者明確要求, 否則不讀取 `tospec/changes/archive` 與 `tospec/tickets/archive`. 縮減的理由不是那七條寫錯了, 而是它們每一條都已由各 skill 自身的指引定義並強制, 規則檔重述它們等於製造第二份需要同步的副本 — 而規則檔正是被當作權威來源載入 agent 的那一份, 一旦 skill 文案調整, 它會靜默過時且沒有任何機制會發現. 規則檔真正要解決的問題是 agent 把過時文件當成現況事實, 尤以已歸檔的變更與 ticket 為甚: 它們不隨程式演進更新, 卻與現行文件同樣會被語意檢索命中, 而分階段程序指引解決不了這件事. 一併承認 beta.2 記下的顧慮 (「避免把 code wins 誤用為範圍授權」) 被推翻: 該護欄改由 sync 與 archive 兩個 skill 的既有指引承擔. 保留七條再疊上程式碼優先的折衷被否決 — 那不必推翻任何理由, 但留下的正是本次要移除的那份會過時的副本. 既有產出檔的正文仍符合自身記錄的雜湊, 因此下次 `init` / `tospec rules` / `update` 會判定為正常升級而非衝突, 使用者無須手動處理. 決策記錄: `tospec/decisions/20260910_142708-workflow-rule-template-source-code-precedence.md`.
493
+
494
+ ### 修正
495
+
496
+ - **`tospec archive` 的名稱格式檢查跑在互動選單的結果上**: 該守衛自己的註解寫明它只該作用於參數路徑, 因為互動選單列出的是磁碟上真實存在的目錄, 其中有些早於 `KEBAB_ID_REGEX` 這條約束. 程式碼卻把檢查放在 `changeName = selectedChange` 之後, 對同一個變數執行 — 於是選單前一秒才列出來的 snake_case change, 參數與選單兩條路徑都歸不了檔. 這不是罕見形狀: `tospec migrate` 逐字複製 OpenSpec 的目錄名, 而 snake_case 在那裡是慣例. 修正改為在互動分支有機會覆寫名稱之前先記下來源. 把整段檢查上移也能修好, 但會讓下一位讀者只能從敘述順序反推意圖, 而註解已經把意圖寫下來了.
497
+ - **wildcard 綁定加上 `--allow-remote` 會 403 掉每一個連進來的 client**: `assertBindableHost` 把 `--allow-remote` 讀成「已接受網路曝險」, 而 `isAllowedHostHeader` 從頭到尾只對綁定位址做字面比對. 沒有任何 Host header 能字面等於 `0.0.0.0`, 於是 `tospec dashboard --host 0.0.0.0 --allow-remote` 印出 URL、在每個介面上監聽, 然後拒絕每一個真的抵達的請求. 成因是那個 opt-in 只抵達了兩道防護的其中一道, 而使用者觀察到的是一個壞掉的 dashboard, 不是一次被拒絕的 host — 這正是靜默失敗最貴的地方. 現由啟動時解析一次的 `acceptsAnyHostHeader` 把 opt-in 帶到 Host 檢查面前; loopback 與具體位址的 rebinding 防護一字未動. 不在 predicate 內部把 `0.0.0.0` 正規化為「任意」: 它看不到旗標, 那等於把「wildcard 就是信任所有人」埋成一個在呼叫端完全看不見的假設. 決策記錄: `tospec/decisions/20260910_150312-allow-remote-wildcard-host-policy.md`.
498
+ - **delta spec 裡重複的一般標題被當成重複的 delta 區段擋下**: `findDuplicateSections` 掃的是未經過濾的一般區段索引, 於是一份 delta spec 寫了兩個 `## Purpose` 就會以「無法判斷哪些 requirement 屬於哪一段」的訊息驗證失敗 — 對一個不擁有任何 requirement 的標題而言這句話是假的, 而它足以擋下一份 delta 內容完全合法的檔案歸檔. 這是 0.19.0-beta.1 修正重複區段靜默遺失時一併引入的迴歸: 新增的拒絕路徑正是沒有人會走的那一條, 所以過寬的掃描範圍出貨時無人察覺. 掃描現限縮為四個 delta 標題, 沿用 parser 本來就在消費的那組名稱; 改為過濾 `splitTopLevelSections` 被否決, 那是一個通用索引, 其他呼叫端依賴它的完整性.
499
+ - **dashboard 的 `POST /api/task` 可以改寫已歸檔 change 的 checkbox**: `changeNameFromTaskFile` 假設路徑形狀為 `tospec/changes/<name>/`, 對已歸檔的 change 因此回傳字面字串 `archive`. 凍結檢查接著去找一份在那個路徑下不可能存在的 `sync-report.md`, 讀到 null, 便放行了寫入 — 於是經 sync 認證、理應永久不變的歸檔內容可被就地編輯. 現在 `changes/archive/` 底下的寫入一律直接拒絕, 且擋在 sync 閘門之前, 因為已歸檔的 change 是「已經不可變」而不是「即將變得不可變」, 讓它去走一次凍結判定等於承認那個判定有可能放行. 在 `resolveTospecFile` 拒絕該前綴則被否決, 範圍過大: `/api/render` 共用同一個 helper, 而它確實應該以唯讀方式呈現歸檔文件.
500
+ - **metrics 重新整理失敗時, 會用舊的擷取時間標示新的數字**: 重新整理把兩份快取與 `capturedAt` 各自從自己的 await 直接指派出去. 第二次讀取拋錯時, 第一份快取已經換上新值而時間戳還停在舊的, 之後每一個請求都送出標著過期擷取時間的較新數字 — 正是 `capturedAt` 在 0.19.0-beta.1 被引入時要消除的那種歧義, 只是這次由失敗路徑重新製造出來. 兩次讀取現在都先落在區域變數, 三個欄位一起提交. 讓它正確的不是 `Promise.all`, 而是一起提交.
501
+ - **`tospec skill-metrics` 的綁定拒絕訊息指名一個它並不接受的旗標**: 訊息結尾寫著 `Pass --allow-remote`, 而 `skill-metrics` 從未註冊這個旗標 — 唯一無法照著建議行動的指令, 正是收到這個建議的那一個. 審查原本推論「這個守衛是死碼, 刪掉即可」, 該推論被推翻: `startMetricsServer` 是匯出的且接受 host, 守衛在程式化路徑上是活的, 刪除它會移除一項真實的檢查. `assertBindableHost` 現改由呼叫端提供補救文字, 沿用它對 `serverName` 已經在用的模式, 使守衛本身不再含有任何旗標名稱 — 第三個 local server 因此無法繼承一句它兌現不了的建議. 為求對稱而替 `skill-metrics` 補上 `--host` / `--allow-remote` 被否決: 那是為了讓一句話成真, 而擴大 transcript 衍生資料的曝險面. 決策記錄: `tospec/decisions/20260910_154657-metrics-server-stays-loopback-only.md`.
502
+ - **格式錯誤的 spec id 在 `/api/specs` 回 500 而非 400**: `handleChangeDetail` 為它的 decode 加了守衛並回 400; `handleSpec` 做的是同一個 decode 卻沒有守衛, 於是 `%zz` 拋出的 `URIError` 以 500 帶著原始錯誤字串浮上來. 兩者早已分岔, 而一則註解還宣稱它們「刻意完全相同」 — 註解描述的是撰寫當下的意圖, 沒有任何東西強制它繼續為真. 兩者現在共用一個 `decodePathSegment`. 只修 `handleSpec` 也能還原那個不變式, 但會讓同一則註解有機會第二次過時.
503
+
504
+ ### 其他
505
+
506
+ - **beta.2 明列的七項缺陷以 `branch-review-defects` 立為 issue change 並已歸檔**: 歸檔為 `20260910_164546-branch-review-defects`, sync 判定兩條 delta Requirement 皆為 MATCH 並將 `local-server-host-policy` 併入 `tospec/specs/`. 七項中有五項改變了可觀察行為卻沒有對應的 delta spec (選單名稱守衛、重複區段掃描範圍、歸檔寫入拒絕、原子化重新整理、格式錯誤 id 的狀態碼); 依規則這些一律回報而非事後補寫規格, sync-report 已據實記錄. 隨 beta.2 附上的 `probe.mjs` 隨該 change 一併歸檔, 七項斷言現已全綠. `agent-workflow-rules` 也在本版完成歸檔 (`20260910_142926-agent-workflow-rules`), 補上了 beta.2 出貨時尚缺的 sync.
507
+ - **三份新 ADR, 全部來自本版**: `20260910_142708-workflow-rule-template-source-code-precedence` (模板正文縮減, 取代前一份 ADR 的模板內容段, 其七項機制決策不受影響)、`20260910_150312-allow-remote-wildcard-host-policy`、`20260910_154657-metrics-server-stays-loopback-only`. 上游 OpenSpec 參考點仍停在 `e062b95` (1.12.0), 本版與上游比對無關. 測試 959 passed / 1 skipped (78 個檔案).
508
+ - **儲存庫根目錄整理**: `how-to-publish.md` 移入 `docs/`, 內容未改; 刪除 `report.md` (v0.11.0 對 `output/` 的 skill 稽核) 與 `update.md` (對 2026-08-14 某個 commit 的上游比對分析) 兩份已完成任務的工作筆記. 兩者沒有任何東西引用, 而一份釘在舊上游 ref 的分析比沒有分析更糟 — 它讀起來像是現況. 選擇刪除而非移進 `docs/`: 需要那些推理時 git 歷史仍然握有它們.
509
+
510
+ ## [0.19.0-beta.2] - 2026-09-10
511
+
512
+ **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
513
+
514
+ 本版本只做一件事: 讓 agent 的 workflow 規則從「init 寫一次就再也回不去的檔案」變成套件能持續更新的產出物. 原本的規則正文是一個寫死在 TypeScript 裡的字串, 只在 init 途中寫出一次 — 而 init 是為「還沒有 tospec 的專案」設計的, 於是套件之後改了規則, 既有專案沒有任何指令拿得到. 一旦要讓它可更新, 下一個問題立刻成立且無法迴避: 產出檔與被使用者改過的檔案在磁碟上長得一模一樣, 而更新規則的每一種天真做法, 不是每次升級都誤判為衝突, 就是每次升級都靜默輾過使用者的修改.
515
+
516
+ ### 新增
517
+
518
+ - **共用 workflow 規則模板, 以及新的 `tospec rules [path]` 入口**: tospec 過去只在 init 寫入一份固定的 `rules/tospec/decision.md`, 除此之外沒有第二份規則的容身處, 也沒有任何入口能在套件升級後補寫或刷新它. 成因不是缺少一個指令, 而是正文的存放位置: 字串常數只能經由「重跑 init」抵達磁碟, 而那條路徑本身帶著建立整個專案佈局的副作用, 沒有人會為了拿一份規則去走它. 現在正文獨立為套件內的 `assets/rules/tospec/single-sourc-of-truth.md` 隨 npm 發布, 由 `init`、新增的 `tospec rules` 與既有的 `update` 三個入口共用, 寫入 `.claude/rules/tospec/single-sourc-of-truth.md` 與 `.codex/rules/tospec/single-sourc-of-truth.md`, 與既有的 `decision.md` 並存, 不遷移也不合併 — 這次要新增的是另一份規則, 而搬動一份已經在使用者專案裡的檔案是獨立的取捨, 不該搭這班車. 正文不隨專案 schema 或已安裝的 workflow 變動, 也不開放專案覆寫: 內容由維護者持有正是這次的需求, 而覆寫機制等於為了一個還沒有人提出的問題, 先引入一組優先序規則. `tospec rules` 只處理已安裝 tospec skills 的 agent, 且完全不碰 skills、commands 與設定 — 單純存在一個 `.claude/` 目錄不代表該專案要 tospec 的規則. Codex 這次只產生檔案: 該目錄沒有官方的 Markdown 自動載入機制, 與其代為寫入 `AGENTS.md` 或 `config.toml` 製造一個看似會生效的設定, 不如在輸出裡明說載入需自行處理. 決策記錄: `tospec/decisions/20260910_001756-agent-workflow-rules.md`.
519
+ - **產出檔內嵌版本標記與內容雜湊, 用來分辨套件升級與使用者手改**: 規則一旦可以被更新, 覆寫就成了問題 — 「內容與當前 template 不同」同時是這兩件事的樣子, 以它為判準必然二選一地錯: 要嘛每次套件升級都被當成衝突擋下, 要嘛使用者的每一次修改都在下一次 `update` 靜默消失. 根本原因是判準問錯了對象: 該問的不是「它跟現在的 template 一不一樣」, 而是「它還是不是一份沒被動過的產出」. 現在每份產出在框架標記中記錄自身正文的 SHA-256, 只要正文仍與它自己記錄的雜湊相符, 就是未經手改的產出 — 無論它出自哪一版 template, 都能安全換上新版; 正文被改、標記外多出內容、標記缺失、重複或格式錯誤, 則一律視為衝突並原樣保留. 選擇檔內自證而非在外部保存一份「上次產出了什麼」的紀錄: 後者能讓產出維持純文字, 但代價是第二份狀態, 而當它與檔案本身失去同步時, 沒有任何東西能判斷哪一邊才是對的. 比對前先正規化行尾, 因此以 CRLF 檢出的檔案不會被誤判為手改.
520
+ - **規則衝突在任何專案寫入之前整次擋下**: 三個入口都先對所有目標建立唯讀計畫, 任一目標衝突就整次失敗, 此時 skills、commands、設定、legacy 清理與另一個 agent 的規則全部尚未被動過. 若改為「輪到哪個 agent 才檢查它自己」, 第二個 agent 的衝突會留下第一個 agent 已經更新的檔案, 使用者拿到的是一個沒有任何指令描述得出來的中間狀態, 而重跑會再走一次同樣的部分更新. init 為此把兩件既有的寫入動作 — legacy user state 遷移, 以及以實際寫入探測權限的 validate — 移到預檢之後: 「衝突時不寫入任何專案檔案」這句話, 若把探測性的寫入排除在外就不成立. `--force` 不略過衝突檢查, 也不提供強制覆寫旗標: force 目前的意思是「即使產出已是最新也重新產生一次」, 而不是「丟棄使用者的修改」, 讓一個旗標同時代表這兩件事, 會使它在最該謹慎的時候最危險. 解除衝突的方式是自行保留修改內容、刪除該規則檔後重跑.
521
+
522
+ ### 其他
523
+
524
+ - **本版的變更以 `agent-workflow-rules` 立為 sdd change, 尚未歸檔**: 五組任務全數完成且測試全綠 (929 passed), 但 change 仍留在 `tospec/changes/`, 歸檔前的 sync 留待下次. 隨附一份 ADR (`20260910_001756-agent-workflow-rules.md`), 是自 0.19.0-beta.0 以來的第一份. 上游 OpenSpec 參考點仍停在 `e062b95` (1.12.0), 本版與上游比對無關.
525
+ - **另記錄了七項本版尚未修正的缺陷**: `branch-review-defects` 這個 issue change 記下針對 `main...HEAD` 的審查結果, 並附一支 `probe.mjs` 作為回饋迴圈 (每項斷言的是**正確**行為, 因此 `FAIL` 代表缺陷仍在). 出貨當下七項全紅, 所以它們在 0.19.0-beta.2 中依然存在. 在此明列是因為其中兩項會影響使用者觀察得到的行為: dashboard 的 `POST /api/task` 仍可改寫已歸檔 change 的 checkbox, 而 `tospec skill-metrics` 的繫結拒絕訊息會指名一個它並不接受的 `--allow-remote` 旗標.
526
+
527
+ ## [0.19.0-beta.1] - 2026-09-08
528
+
529
+ **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
530
+
531
+ 本版本不跟進上游, 而是一次針對自身程式碼的稽核. 五組修正共用同一個形狀: **規則早已寫在別處, 只是從未被抵達** — 「改寫檔案時保留其原本的行尾」寫在 CLAUDE.md, 「寫入任何一份之前先驗證每一份重建的規格」寫在該函式自己的註解, 「第二份實作就是一個洞」寫在 `local-server.ts` 的模組開頭, 「同一個 requirement 名稱只會出現一次」則是兩個資料結構各自的假設, 而沒有任何一層負責執行它. 這些不是被判斷為不適用而略過的規則, 而是根本沒有程式碼走到它們面前; 徵兆也因此一致: 成功路徑寫得異常小心的模組, 在自己的失敗路徑與改寫路徑上放棄了同樣的標準, 而指令仍然回報成功.
532
+
533
+ ### 新增
534
+
535
+ - **metrics 頁面標示數據的擷取時間**: 報表數據是刻意快取的 — transcript 在啟動時一次讀完, 因此 URL 印出時它背後的報表已經產得出來 — 但頁面從未說過這些數字擷取於何時, 於是一個刻意的快取與一個壞掉的快取在畫面上完全相同. 使用者實際觀察到的是「重新整理瀏覽器不會重新讀取」. 稽核據此推得的「Refresh 按鈕不存在」是錯的: 該按鈕自快取導入起就存在, `/api/report?refresh=1` 也確實重讀兩個來源 — 稽核漏看了它, 真正缺的是擷取時間本身. 現由 server 一併回傳 `capturedAt` 並顯示在 Refresh 旁, 兩種隨附語系皆有.
536
+
537
+ ### 安全
538
+
539
+ - **`/api/changes/<name>` 是最後一個未檢查路徑跳脫的端點**: `/api/specs`、`/api/activity`、`/api/render` 與 `POST /api/task` 都有跳脫檢查, 只有這一個把請求輸入直接接成檔案系統路徑. 它之所以存活, 是因為兩層看似互相涵蓋的正規化其實不重疊: WHATWG URL 會把 pathname 裡**字面**的 `..` 解掉 — 實測 `/api/changes/../../x` 會以 `/x` 抵達, 根本進不了 handler, 這正是隨手測試會覺得它安全的原因 — 但它不做 percent decode, 因此 `%2e%2e%2f` 原樣通過, 再由 `decodeURIComponent` 在所有 URL 層正規化都跑完之後還原成 `../../`, 位置就在 join 到 changes 目錄的前一行. 會出事的形式, 恰好是 router 自身的解析不處理的那一種. 影響是一個涵蓋任意路徑的檔案存在性 oracle (200 對 404), 命中時把該目錄當成一個 change 讀回; `/api/render` 的 `tospec/` 前綴限制仍然成立, 因此屬於目錄探測加上有界的內容外洩, 而非任意檔案讀取. 「只綁 loopback」在此不是緩解措施 — 使用者已經開著的瀏覽器, 正是同一個模組裡 DNS rebinding 防護存在的理由. 修正直接沿用 `handleSpec` 的那三行, 未改以 `KEBAB_ID_REGEX` 驗證: 既有專案的 change 目錄名早於該約束, 用 regex 會 403 掉 CLI 仍然列得出來的 change; 字元拒絕擋住跳脫而不縮限 dashboard 顯示得了的範圍. 這是遺漏而非決策 — `dashboard-server.test.ts` 明文為其他三個端點斷言了跳脫拒絕, 防護與它的測試是一起被跳過的.
540
+ - `decodeURIComponent` 對格式錯誤輸入 (`%zz`) 拋出的 `URIError` 改回 400, 原本是 router 的 500. 這是回應碼正確性, 不屬於安全修正本身.
541
+ - **`tospec skill-metrics` 會繫結 0.0.0.0 而不提出反對**: metrics server 接受同一個 host 選項、走同一條 `listenFrom`、與 dashboard 共用同一個模組的 `isAllowedHostHeader` 與 `serveAsset`, 唯獨繫結防護內嵌在 `startDashboardServer` 之中 — metrics 沒有東西可呼叫, 於是同一個套件裡, dashboard 拒絕的繫結 metrics 接受. 現抽出為 `local-server.ts` 的 `assertBindableHost` 並由兩者呼叫, 而非貼一份到 `metrics.ts`: 貼上去產生的正是該模組開頭警告的那個「第二份實作」, 而第三個 local server 會再繼承同一個洞. `MetricsServerOptions` 相應加上 `allowRemote`; 不為 skill-metrics 開放 `--host` 旗標 — 增加介面是功能決策, 不屬於補洞. `isAllowedHostHeader` 防的是 Host header 的 DNS rebinding, 不是 socket 繫結在哪個介面上, 以 IP 直接請求是另一個攻擊面, 它從來不是這條防護的替代品.
542
+
543
+ ### 變更
544
+
545
+ - **dashboard 展開 artifact 不再為單一 glob 形狀啟動 glob 引擎**: `resolveArtifactOutputs` 對每個 change 的每個 globbed artifact 各呼叫一次 fast-glob, 而這條路徑在每次存檔都會重跑 (fs.watch + 200ms debounce + SSE + refetch). 目前所有隨附 schema 宣告過的 glob 形狀只有 `specs/*/spec.md` 一種 — sdd 與 issue 皆同, 單一整段萬用字元, 且 delta spec 的層級由 `20260730_014309-delta-spec-layout-one-level` 固定為一層 — 因此每次呼叫付出的 pattern 編譯與 stream 建置, 買到的是一次 readdir 就能回答的東西. 30 個 change 的合成專案隔離量測: 4.7ms → 0.8ms, 整條總覽路徑 88ms → 63ms. 快速路徑只認這一種形狀, 其餘一律退回 fast-glob, 因為 `generates` 由 schema 撰寫, 使用者自訂 schema 可能宣告它表達不了的形狀; 等價性由測試釘住而非假設 — 排序與正規化後的輸出、`onlyFiles` (一個**名為** `spec.md` 的目錄不得命中)、前綴目錄不存在時回傳空陣列、dotfile 預設 (`*` 不匹配開頭的點, 否則 `.hidden/spec.md` 會開始被算成輸出), 以及五種必須仍然走 fast-glob 的形狀.
546
+ - 稽核的兩條原始前提沒有通過量測, 一併記錄以免下次重推. 其一: 「同步彙整會把 event loop 佔住整段時間, 期間 server 什麼都回答不了」是錯的 — 以自我重排的 `setImmediate` 對原本的程式碼取樣, 最長停頓是 2.1ms, 因為 change 之間本來就有 await 把同步工作切開了; 總延遲是真的, 停頓不是, 而 SSE 心跳或並行的 `/api/render` 感受到的正是停頓.
547
+ - 其二: `collectOverview` 因此刻意保留循序迴圈. 改成跨 change 的 `Promise.all` 已實作、已量測、已回退 (59ms 總時間, 但最長停頓 5.5ms), 原因是 `artifactSummaryFor` 與 `listTasksForChange` 在 await 之前都有同步工作, 並行啟動會讓這些前段全擠在同一個 tick. 為它補上每 change 一個 `setImmediate` — 看似顯然的解法 — 結果更糟: 30 個 continuation 在同一 tick 解析, 30 個 immediate 落進同一個 check phase 被 Node 一次排乾, 造成 49ms 不中斷的停頓, 恰好是稽核以為早已存在的那個徵兆. 用 4ms 總延遲換三倍的無回應窗口加更多程式碼並不划算; 真正的非同步收益落在每 change 三筆獨立讀取與 specs 迴圈的平行化上.
548
+ - 計畫中預留的「每 change artifact 狀態快取」未實作: 它本來就以這次量測為前提, 而在 2.1ms 停頓之下, 沒有理由為一個「狀態一律由檔案存在性推導、從不儲存」的設計引入儲存狀態.
549
+
550
+ ### 修正
551
+
552
+ - **重複的 delta 區段與主規格重複 requirement 被靜默丟棄**: 兩處把內容收進以名稱為鍵的容器, 而沒有任何一層執行那個唯一性假設 — 於是 requirement 消失, validate 與 archive 雙雙回報成功. `splitTopLevelSections` 以標題為鍵存進一般物件, 第二個 `## ADDED Requirements` 直接覆蓋第一個, 第一段底下的所有 requirement 從此不可觸及; 這之所以不只是錯而且不可見, 是因為 validate 與 archive 讀的是同一份 `parseDeltaSpec` 輸出 — 重複區段不是驗證器選擇不檢查, 而是驗證器結構上看不見. 單一區段內的重複 *requirement* 原本就會被攔 (`validator.ts` 的 "Duplicate requirement in ADDED"), 缺的是區段這一層. `buildUpdatedSpec` 是同一個形狀: `bodyBlocks` 進 Map 以 requirement 名稱為鍵, 而 `orderedKeys` 每個原始區塊各佔一格, 兩個同名區塊於是把兩格都解析到最後那一塊 — 第一份本文被取代, 倖存者被輸出兩次, 且完全不需要有任何 delta 動到那條 requirement, 任何一次經過該檔案的合併就足夠.
553
+ - parser 改回傳 `Map<string, SectionBody[]>`, 鍵為小寫標題, 每次出現各自解析後再串接結果. 保持各次出現分離而非合併其行, 有兩個決定性理由: 合併會跨越作者寫下的邊界捏造結構 — 一個 RENAMED 區段的 `FROM:` 會與另一段的 `TO:` 配對, 發明出兩段都沒有宣告的 rename; 而 `SectionBody.offset` 是單一基準索引, 四個呼叫端以 `offset + i + 1` 推導行號, 串接不相鄰的文件切片沒有合法的基準, 第二段回報的行號會靜默錯誤 — 正是本次要移除的那類缺陷. 逐次解析讓每個純量 offset 維持正確, 四個呼叫端一行未動.
554
+ - 鍵改小寫同時修掉同一種遺失的第二種樣貌: `## added requirements` 原本是另一個物件鍵, 而不分大小寫的查詢從此永遠找不到它. 改用 Map 也讓 `## __proto__` 這個標題落進自有項目, 而不是 `Object.prototype` 上的存取器.
555
+ - 驗證端直接拒絕重複的 delta 區段而非合併它: 兩個相同標題是編輯意外, 合併等於替作者猜測他想怎麼分組. 兩半分工不同 — parser 保證即使繞過驗證也不會遺失 (`archive --no-validate` 是真實存在的旗標), 驗證器則在修改還便宜的時候告訴作者這份檔案壞了.
556
+ - 主規格端新增 `duplicate-requirement` 這個 kind 到 `findMainSpecStructureIssues`, 而非在 `nameToBlock` 迴圈裡拋錯: 該函式本來就在合併前走過目標、本來就回報兩種結構缺陷、也本來就會讓 `buildUpdatedSpec` 在任何寫入之前中止, 因此不需要新的中止機制, 並免費地經由 `applySpecRules` 抵達 `tospec validate`. 放在 Map 迴圈裡的守衛則會在 `findMainSpecStructureIssues` 已經宣告該檔案乾淨之後, 從更低層再開火 — 兩個守衛對同一個檔案各說各話.
557
+ - **改寫檔案時破壞行尾字元與 fenced 區塊內的空行**: CLAUDE.md 已明文寫下這三處違反的規則 — 「改寫檔案時保留其原本的行尾」— 因此這是對既有約定的執行落差, 而非新政策; 它引用的前例也不是假設: 一個 CRLF 的 tasks 檔曾讓 archive 對一個任務其實已完成的 change 回報 `archive_tasks_missing`.
558
+ - 空行收合跑過整份文件. 它存在的理由是 recompose 串接的四段各自帶著邊界換行, 接縫會累積空行; 但 `replace(/\n{3,}/g, '\n\n')` 分不出接縫與作者寫在程式碼範例裡的空行, 於是每一次 archive 都悄悄改動了規格裡的範例. 改為逐行處理並以檔案內既有的 `buildCodeFenceMask()` 遮罩 — 同檔另外兩個函式已經在用它 — 接縫依建構方式必然落在 fence 之外, 遮罩不會損失任何收合原本要做的事.
559
+ - CRLF 的 `spec.md` 被改寫成 LF, 在 Windows 上產生整檔 diff, 把實際只動了一條 requirement 的變更埋掉. 行尾是在管線**前端**被破壞而非寫入時: `extractRequirementsSection` 呼叫 `normalizeDocument`, `normalizeBlockRaw` 又逐塊正規化一次, `recompose` 以 `'\n'` 串接 — 等 `writeUpdatedSpec` 執行時已經沒有東西可保留. 解析前正規化是正確的而且保留 (底下每一條結構 regex 都假設 LF, 而違反該假設正是當初 checkbox 缺陷的成因); 缺陷在於那個約定被丟棄而不是被記錄下來. 現由 `buildUpdatedSpec` 在原始位元組上偵測並回傳, `writeUpdatedSpec` 還原.
560
+ - 採整檔旗標而非 `setTaskDone` 用的逐行保真: 後者改寫的是一份其餘完全未動的檔案中的一行, 每一行的行尾都有定義; 而合併後的規格含有原本不存在的行, 逐行對它們沒有定義, 檔案層級的約定才是唯一涵蓋得了輸出的答案. 因此混合行尾的輸入會統一輸出為 CRLF — 那確實改動了原本沒被碰的那一半, 而這是正確的取捨, 因為另一個選項是替新行憑空發明一個行尾.
561
+ - `appendDecisionLinks` 附加的是硬編 LF 的區塊. `appendFile` 從不讀取檔案, 於是 ticket 自身的約定從未被查詢, 一份 CRLF 的 ticket 會變成標題之上 CRLF、之下 LF. 現在改為先讀取. 鄰居 `retargetTicketRef` 原本就是安全的, 但那是出於建構方式而非出於留意 — 它的 pattern 排除行終止符, 所以 replace 只會在行內改寫 — 該推理現已寫成註解, 以免下一位讀者把它「簡化」進同一個陷阱.
562
+ - **六處錯誤路徑缺陷隱藏或半套用了真實的失敗**: 六個小缺陷都落在「已經出事之後才會執行」的路徑上, 而它們所在的程式碼在成功路徑上異常小心. 合併記錄是因為它們共用這個性質, 其中兩個還共用一個更具體的成因: 守衛被放在邊界的錯誤一側.
563
+ - **archive 的歸檔目錄碰撞檢查跑在主規格寫入之後**: 走到該檢查, 代表規格已合併而 change 仍在 `tospec/changes/` 裡, 於是重試會再套用同一批 delta, REMOVED/RENAMED 接著走它們的「已同步」分支, 計數不再描述現實. 這與同一個函式自己陳述的不變式字面相反 — 「寫入任何一份之前先驗證每一份重建的規格, 使晚期的驗證失敗真的讓所有目標維持不變」. 該檢查只需要一個時間戳與一個名稱, 沒有任何東西迫使它必須晚; 現移到寫入之前, 時間戳只計算一次並沿用於搬移, 使被檢查的目錄就是被建立的目錄. 觸發機率很低 (需要同一秒內對同名 change 執行兩次), 但後果是一次沒有 undo 的半套用合併.
564
+ - **`Change not found` 從它自己的 try 之內拋出**: 該分支是死碼, 暗示一個並不存在的區別; 而那個裸 catch 同時把真正的 IO 失敗改寫成「找不到」— 權限問題被回報成 change 不存在. 改為 `fs.stat(...).catch(...)` 並以既有的 `isMissingPathError` 收窄, 只有真的不存在的路徑才能回答「找不到」. 單用 `.catch(() => null)` 會保留吞噬, 收窄才是重點.
565
+ - **`<name>` 從未檢查形狀**: 帶分隔符的名稱會讓 `changeDir` 指到 `tospec/changes/` 之外, 歸檔目標落在非預期的位置. 這不是安全邊界 — 那是一個已經握有 shell 的人給的 CLI 參數 — 但 `KEBAB_ID_REGEX` 已經定義了合法形狀而這條路徑忽略它, 於是一個打字錯誤換來的是困惑的失敗而不是清楚的訊息. 只在參數路徑驗證: 互動式選單列的是真實目錄, 其中有些早於這條約束, 拒絕一個 CLI 剛剛才提供的選項會讓可歸檔的 change 無法歸檔.
566
+ - **`shared-output` 的 `asStatus` 以裸屬性存取讀 `.diagnostic`**: 於是 `throw null` 或裸的 `Promise.reject()` 會讓那個存取本身拋錯 — 在錯誤處理器之內, 以一個不相關的 TypeError 取代真正的成因, 並完全抑制 JSON 外殼, 而這個 CLI 的契約是每一次 `--json` 失敗都恰好送出一份 JSON. 同檔的 `asErrorMessage` 早就防了這個形狀, 一個 optional chain 讓這一行與它一致.
567
+ - **`readConfigFile` 只把 SyntaxError 當成不可用**: `[1,2,3]` 與 `"hello"` 都能正常 parse, 於是 spread 產生一個以索引為鍵的物件並被當成設定回傳, 使用者看到的是設定悄悄沒有生效, 而沒有任何訊息指名那個檔案. 現以型別閘門把它們導向同一條 warn-and-default 路徑. 改走既有的 zod `validateConfig` 會更一致, 但那會改變「部分錯誤的設定」的行為 — 未知鍵今天是刻意放行的 — 屬於行為決策, 此處刻意不做.
568
+ - **`--scope` 是整個 codebase 唯一的 `process.exit(1)`**: 對照 9 個檔案裡 36 處 `process.exitCode` 指派, 其中 12 處就在同一個檔案. `process.exit` 會在待寫出的 stdout/stderr 未必已 flush 時終止行程, 在 Windows pipe 上是有記錄的截斷來源. 它存活的理由是結構性的而非疏忽: 該檢查位於 commander 的 `preAction` hook, 在那裡 return 並不會阻止 action 執行, 所以 exit 是唯一看得出效果的做法. 修正補上缺少的中止機制 — 一個 `CliAbortError` 標記, 在 `runCli` 統一攔截, 而 `runCli` 原本完全沒有 try/catch, 因此也一併獲得一個把逸出的例外轉成乾淨 exit code 的地方. 勝過在 12 個子指令 action 裡各重複一次檢查 (那是比被修的缺陷更糟的一致性問題, 而第 13 個會漏掉), 也勝過 commander 的 `program.error()` — 它內部呼叫的還是 `process.exit`.
569
+
570
+ ### 其他
571
+
572
+ - **本版五項修正各自立為 issue change 並歸檔**: `duplicate-key-silent-requirement-loss`、`preserve-line-endings-on-rewrite`、`archive-error-path-robustness`、`dashboard-trust-boundary-gaps`、`dashboard-overview-blocking-glob`. 全部來自一次針對自身程式碼的稽核而非上游比對, 因此上游 OpenSpec 參考點仍停在 `e062b95` (1.12.0). 本版未產生新的 ADR: 五項修正都落在既有決策的框架之內, 沒有推翻或新立任何取捨.
573
+
9
574
  ## [0.19.0-beta.0] - 2026-09-07
10
575
 
11
576
  **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
@@ -359,6 +924,19 @@ Dashboard 進化為可背景常駐、多專案並存的服務, 並補上 TDD 導
359
924
 
360
925
  - 新增 `prepack` script 與 npm publish 的準備設定, 完備套件發行流程.
361
926
 
927
+ [0.19.0-beta.13]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.12...v0.19.0-beta.13
928
+ [0.19.0-beta.12]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.11...v0.19.0-beta.12
929
+ [0.19.0-beta.11]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.10...v0.19.0-beta.11
930
+ [0.19.0-beta.10]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.9...v0.19.0-beta.10
931
+ [0.19.0-beta.9]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.8...v0.19.0-beta.9
932
+ [0.19.0-beta.8]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.7...v0.19.0-beta.8
933
+ [0.19.0-beta.7]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.6...v0.19.0-beta.7
934
+ [0.19.0-beta.6]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.5...v0.19.0-beta.6
935
+ [0.19.0-beta.5]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.4...v0.19.0-beta.5
936
+ [0.19.0-beta.4]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.3...v0.19.0-beta.4
937
+ [0.19.0-beta.3]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.2...v0.19.0-beta.3
938
+ [0.19.0-beta.2]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.1...v0.19.0-beta.2
939
+ [0.19.0-beta.1]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.0...v0.19.0-beta.1
362
940
  [0.19.0-beta.0]: https://github.com/seanmars/tospec/compare/v0.18.0...v0.19.0-beta.0
363
941
  [0.18.0]: https://github.com/seanmars/tospec/compare/v0.17.0...v0.18.0
364
942
  [0.17.0]: https://github.com/seanmars/tospec/compare/v0.16.0...v0.17.0