@seanmars/tospec 0.19.0-beta.6 → 0.19.0-beta.8

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 (209) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/assets/dashboard/app.js +3 -3
  3. package/assets/rules/tospec/decision.md +3 -0
  4. package/dist/cli/index.d.ts.map +1 -1
  5. package/dist/cli/index.js +205 -36
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/commands/config.d.ts.map +1 -1
  8. package/dist/commands/config.js +151 -23
  9. package/dist/commands/config.js.map +1 -1
  10. package/dist/commands/dashboard.d.ts +34 -9
  11. package/dist/commands/dashboard.d.ts.map +1 -1
  12. package/dist/commands/dashboard.js +213 -21
  13. package/dist/commands/dashboard.js.map +1 -1
  14. package/dist/commands/decision.d.ts +11 -0
  15. package/dist/commands/decision.d.ts.map +1 -1
  16. package/dist/commands/decision.js +182 -20
  17. package/dist/commands/decision.js.map +1 -1
  18. package/dist/commands/shared-output.d.ts +12 -0
  19. package/dist/commands/shared-output.d.ts.map +1 -1
  20. package/dist/commands/shared-output.js +50 -1
  21. package/dist/commands/shared-output.js.map +1 -1
  22. package/dist/commands/show.d.ts +10 -0
  23. package/dist/commands/show.d.ts.map +1 -1
  24. package/dist/commands/show.js +23 -4
  25. package/dist/commands/show.js.map +1 -1
  26. package/dist/commands/validate.d.ts +41 -1
  27. package/dist/commands/validate.d.ts.map +1 -1
  28. package/dist/commands/validate.js +95 -17
  29. package/dist/commands/validate.js.map +1 -1
  30. package/dist/commands/workflow/index.d.ts +5 -5
  31. package/dist/commands/workflow/index.d.ts.map +1 -1
  32. package/dist/commands/workflow/index.js +5 -5
  33. package/dist/commands/workflow/index.js.map +1 -1
  34. package/dist/commands/workflow/instructions.d.ts +23 -0
  35. package/dist/commands/workflow/instructions.d.ts.map +1 -1
  36. package/dist/commands/workflow/instructions.js +84 -16
  37. package/dist/commands/workflow/instructions.js.map +1 -1
  38. package/dist/commands/workflow/new-change.d.ts +2 -0
  39. package/dist/commands/workflow/new-change.d.ts.map +1 -1
  40. package/dist/commands/workflow/new-change.js +32 -4
  41. package/dist/commands/workflow/new-change.js.map +1 -1
  42. package/dist/commands/workflow/schemas.d.ts +2 -0
  43. package/dist/commands/workflow/schemas.d.ts.map +1 -1
  44. package/dist/commands/workflow/schemas.js +38 -1
  45. package/dist/commands/workflow/schemas.js.map +1 -1
  46. package/dist/commands/workflow/shared.d.ts +34 -2
  47. package/dist/commands/workflow/shared.d.ts.map +1 -1
  48. package/dist/commands/workflow/shared.js +37 -3
  49. package/dist/commands/workflow/shared.js.map +1 -1
  50. package/dist/commands/workflow/status.d.ts +14 -0
  51. package/dist/commands/workflow/status.d.ts.map +1 -1
  52. package/dist/commands/workflow/status.js +65 -8
  53. package/dist/commands/workflow/status.js.map +1 -1
  54. package/dist/commands/workflow/templates.d.ts +7 -0
  55. package/dist/commands/workflow/templates.d.ts.map +1 -1
  56. package/dist/commands/workflow/templates.js +13 -0
  57. package/dist/commands/workflow/templates.js.map +1 -1
  58. package/dist/core/archive.d.ts +14 -0
  59. package/dist/core/archive.d.ts.map +1 -1
  60. package/dist/core/archive.js +169 -18
  61. package/dist/core/archive.js.map +1 -1
  62. package/dist/core/artifact-graph/graph.d.ts +13 -3
  63. package/dist/core/artifact-graph/graph.d.ts.map +1 -1
  64. package/dist/core/artifact-graph/graph.js +16 -6
  65. package/dist/core/artifact-graph/graph.js.map +1 -1
  66. package/dist/core/artifact-graph/index.d.ts +1 -1
  67. package/dist/core/artifact-graph/index.d.ts.map +1 -1
  68. package/dist/core/artifact-graph/index.js +1 -1
  69. package/dist/core/artifact-graph/index.js.map +1 -1
  70. package/dist/core/artifact-graph/instruction-loader.d.ts +74 -3
  71. package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
  72. package/dist/core/artifact-graph/instruction-loader.js +79 -31
  73. package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
  74. package/dist/core/artifact-graph/resolver.d.ts +26 -0
  75. package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
  76. package/dist/core/artifact-graph/resolver.js +44 -4
  77. package/dist/core/artifact-graph/resolver.js.map +1 -1
  78. package/dist/core/artifact-graph/stub-detection.d.ts +20 -0
  79. package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -0
  80. package/dist/core/artifact-graph/stub-detection.js +42 -0
  81. package/dist/core/artifact-graph/stub-detection.js.map +1 -0
  82. package/dist/core/change-presenter.d.ts +16 -0
  83. package/dist/core/change-presenter.d.ts.map +1 -1
  84. package/dist/core/change-presenter.js +68 -9
  85. package/dist/core/change-presenter.js.map +1 -1
  86. package/dist/core/change-status-policy.d.ts +12 -1
  87. package/dist/core/change-status-policy.d.ts.map +1 -1
  88. package/dist/core/change-status-policy.js +33 -1
  89. package/dist/core/change-status-policy.js.map +1 -1
  90. package/dist/core/codex-residue.js +1 -1
  91. package/dist/core/config-schema.d.ts +1 -12
  92. package/dist/core/config-schema.d.ts.map +1 -1
  93. package/dist/core/config-schema.js +27 -1
  94. package/dist/core/config-schema.js.map +1 -1
  95. package/dist/core/config.d.ts +37 -0
  96. package/dist/core/config.d.ts.map +1 -1
  97. package/dist/core/config.js +37 -0
  98. package/dist/core/config.js.map +1 -1
  99. package/dist/core/dashboard-activity.js +2 -2
  100. package/dist/core/dashboard-activity.js.map +1 -1
  101. package/dist/core/dashboard-data.d.ts +5 -0
  102. package/dist/core/dashboard-data.d.ts.map +1 -1
  103. package/dist/core/dashboard-data.js +15 -8
  104. package/dist/core/dashboard-data.js.map +1 -1
  105. package/dist/core/init.d.ts +22 -0
  106. package/dist/core/init.d.ts.map +1 -1
  107. package/dist/core/init.js +107 -45
  108. package/dist/core/init.js.map +1 -1
  109. package/dist/core/list.d.ts +1 -1
  110. package/dist/core/list.d.ts.map +1 -1
  111. package/dist/core/list.js +68 -17
  112. package/dist/core/list.js.map +1 -1
  113. package/dist/core/markdown-render.d.ts +33 -0
  114. package/dist/core/markdown-render.d.ts.map +1 -0
  115. package/dist/core/markdown-render.js +103 -0
  116. package/dist/core/markdown-render.js.map +1 -0
  117. package/dist/core/migrate.d.ts +20 -6
  118. package/dist/core/migrate.d.ts.map +1 -1
  119. package/dist/core/migrate.js +102 -6
  120. package/dist/core/migrate.js.map +1 -1
  121. package/dist/core/parsers/change-parser.js +1 -1
  122. package/dist/core/parsers/change-parser.js.map +1 -1
  123. package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
  124. package/dist/core/parsers/markdown-parser.js +16 -0
  125. package/dist/core/parsers/markdown-parser.js.map +1 -1
  126. package/dist/core/parsers/requirement-blocks.js +32 -9
  127. package/dist/core/parsers/requirement-blocks.js.map +1 -1
  128. package/dist/core/root-selection.d.ts +11 -1
  129. package/dist/core/root-selection.d.ts.map +1 -1
  130. package/dist/core/root-selection.js +4 -2
  131. package/dist/core/root-selection.js.map +1 -1
  132. package/dist/core/rules.d.ts +6 -1
  133. package/dist/core/rules.d.ts.map +1 -1
  134. package/dist/core/rules.js +24 -2
  135. package/dist/core/rules.js.map +1 -1
  136. package/dist/core/schemas/base.schema.d.ts +3 -0
  137. package/dist/core/schemas/base.schema.d.ts.map +1 -1
  138. package/dist/core/schemas/base.schema.js +22 -0
  139. package/dist/core/schemas/base.schema.js.map +1 -1
  140. package/dist/core/schemas/change.schema.d.ts +8 -0
  141. package/dist/core/schemas/change.schema.d.ts.map +1 -1
  142. package/dist/core/schemas/spec.schema.d.ts +2 -0
  143. package/dist/core/schemas/spec.schema.d.ts.map +1 -1
  144. package/dist/core/shared/index.d.ts +1 -1
  145. package/dist/core/shared/index.d.ts.map +1 -1
  146. package/dist/core/shared/index.js +1 -1
  147. package/dist/core/shared/index.js.map +1 -1
  148. package/dist/core/shared/rules-generation.d.ts +27 -6
  149. package/dist/core/shared/rules-generation.d.ts.map +1 -1
  150. package/dist/core/shared/rules-generation.js +115 -44
  151. package/dist/core/shared/rules-generation.js.map +1 -1
  152. package/dist/core/shared/tool-detection.d.ts +19 -0
  153. package/dist/core/shared/tool-detection.d.ts.map +1 -1
  154. package/dist/core/shared/tool-detection.js +30 -2
  155. package/dist/core/shared/tool-detection.js.map +1 -1
  156. package/dist/core/spec-presenter.d.ts.map +1 -1
  157. package/dist/core/spec-presenter.js +5 -0
  158. package/dist/core/spec-presenter.js.map +1 -1
  159. package/dist/core/specs-apply.d.ts +15 -1
  160. package/dist/core/specs-apply.d.ts.map +1 -1
  161. package/dist/core/specs-apply.js +33 -11
  162. package/dist/core/specs-apply.js.map +1 -1
  163. package/dist/core/templates/workflows/apply.js +1 -1
  164. package/dist/core/templates/workflows/apply.js.map +1 -1
  165. package/dist/core/templates/workflows/archive.d.ts.map +1 -1
  166. package/dist/core/templates/workflows/archive.js +4 -2
  167. package/dist/core/templates/workflows/archive.js.map +1 -1
  168. package/dist/core/templates/workflows/decision.js +1 -1
  169. package/dist/core/templates/workflows/issue.d.ts.map +1 -1
  170. package/dist/core/templates/workflows/issue.js +3 -0
  171. package/dist/core/templates/workflows/issue.js.map +1 -1
  172. package/dist/core/update.d.ts +26 -0
  173. package/dist/core/update.d.ts.map +1 -1
  174. package/dist/core/update.js +111 -23
  175. package/dist/core/update.js.map +1 -1
  176. package/dist/core/validation/constants.d.ts +1 -1
  177. package/dist/core/validation/constants.d.ts.map +1 -1
  178. package/dist/core/validation/constants.js +6 -1
  179. package/dist/core/validation/constants.js.map +1 -1
  180. package/dist/core/validation/section-validator.d.ts.map +1 -1
  181. package/dist/core/validation/section-validator.js +21 -3
  182. package/dist/core/validation/section-validator.js.map +1 -1
  183. package/dist/core/validation/validator.d.ts +17 -4
  184. package/dist/core/validation/validator.d.ts.map +1 -1
  185. package/dist/core/validation/validator.js +120 -34
  186. package/dist/core/validation/validator.js.map +1 -1
  187. package/dist/utils/change-utils.d.ts +31 -2
  188. package/dist/utils/change-utils.d.ts.map +1 -1
  189. package/dist/utils/change-utils.js +127 -37
  190. package/dist/utils/change-utils.js.map +1 -1
  191. package/dist/utils/file-system.d.ts +1 -1
  192. package/dist/utils/file-system.js +1 -1
  193. package/dist/utils/item-discovery.d.ts +20 -5
  194. package/dist/utils/item-discovery.d.ts.map +1 -1
  195. package/dist/utils/item-discovery.js +28 -9
  196. package/dist/utils/item-discovery.js.map +1 -1
  197. package/dist/utils/spec-files.d.ts +12 -0
  198. package/dist/utils/spec-files.d.ts.map +1 -1
  199. package/dist/utils/spec-files.js +34 -0
  200. package/dist/utils/spec-files.js.map +1 -1
  201. package/dist/utils/task-progress.js +1 -1
  202. package/package.json +1 -1
  203. package/schemas/decision/templates/decision.md +3 -1
  204. package/schemas/decision/templates/index.md +2 -2
  205. package/schemas/issue/schema.yaml +4 -2
  206. package/schemas/issue/templates/spec.md +20 -2
  207. package/schemas/sdd/schema.yaml +4 -0
  208. package/schemas/sdd/templates/spec.md +20 -2
  209. /package/assets/rules/tospec/{single-sourc-of-truth.md → single-source-of-truth.md} +0 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,141 @@
6
6
 
7
7
  tospec 是一套 spec-driven development CLI: 以 schema 定義文件結構與工作流程, 進度由檔案系統狀態推算, 開發方法則封裝於 Skill 之中, 使 AI 工具 (Claude Code / Codex) 得以循序完成需求釐清、規格撰寫、設計、任務拆解、實作到歸檔的完整流程.
8
8
 
9
+ ## [0.19.0-beta.8] - 2026-09-18
10
+
11
+ **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
12
+
13
+ 本版本來自對一個全新空專案連續四輪的全指令面掃描, 共 61 項缺陷 (17 / 11 / 15 / 18). 第一、二、四輪的方法是逐一執行每個命令並比對輸出; 第三輪換了一種找法 — 不看輸出, 改看這個程式庫**自己已經寫下來的政策**, 然後問哪些呼叫端沒有遵守它, 於是該輪 15 項裡多數是本專案在某處明文陳述的規則, 除了一個地方之外到處都成立. 四輪的缺陷共用同一種形狀: 兩個本該一致的東西已經分岔, 而沒有任何一方會發現 — 同一個條件被三個兄弟命令各答一種、同一份資料在 human 模式看得到而 `--json` 沒有、一份模板承諾了沒有任何程式碼執行的行為、一個 artifact 的「必要」在 artifact graph、`validate` 與 `archive` 三處各自定義. 其中六項會遺失工作成果, 一項讓 dashboard 的同源寫入權暴露給 `tospec/` 底下的任意檔案. 第四輪另有三項是第三輪修正的部分回歸, 全部同一個成因: 放寬一個判斷條件之後沒有回頭問「現在還會 match 到什麼」 — 收緊會被既有測試擋下, 放寬只會讓新的輸入被接受, 而那些輸入照定義就沒有測試.
14
+
15
+ ### 新增
16
+
17
+ - **`tospec init` / `update` / `rules` / `migrate` 支援 `--json`**: 這四個命令原本完全沒有機器可讀的輸出, 而它們正是 agent 在專案初始化與維護時會執行的那幾個. 一併把各命令的失敗 null-shape 集中到單一表格, 並將 `test/cli/json-failure.test.ts` 由逐命令案例改寫為表格驅動的全面掃描, 因此少了失敗 payload 的新命令現在預設就會讓測試失敗 — 原本沒有任何機制要求新命令必須具備失敗 payload, 漂移才是預設值.
18
+
19
+ - **`status` / `instructions --json` 帶出 `changeMetadata` (goal, decisions)**: `.tospec.yaml` 的 `goal` 與 `decisions` 會被驗證、會被持久化, 卻不出現在 agent 唯一看得到的那兩個命令裡. 這直接抵銷了 `tospec-propose` skill「撰寫 proposal 時引用已連結的 ADR」這條指示 — agent 無從得知有哪些 ADR 被連結上. 兩個欄位現在隨 change 狀態一起交付.
20
+
21
+ - **`tospec/decisions/index.md` 新增 status 欄**: 索引是人實際會打開的那份檔案, 而一則 `superseded` 的 ADR 在裡面讀起來與現行決策完全一樣, 必須逐一開啟決策檔才分得出來. 既有的 ledger 會在下一次寫入時就地升級, 狀態值從決策檔本身回填, 不需要遷移步驟.
22
+
23
+ - **`instructions apply` 的 `missingContext` 與檔案推導的任務編號**: 前者為非阻斷式診斷, 回報 apply 階段缺少哪些輸入; 後者讓任務編號取自檔案內容而非呼叫端的計數.
24
+
25
+ ### 安全
26
+
27
+ - **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`.
28
+
29
+ ### 變更
30
+
31
+ - **`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`.
32
+
33
+ - **`status --json` 以 `optional` 取代部分情況下的 `skipped` (breaking change)**: 對 `skipped` 做特例處理的讀取端需要調整. 症狀是「schema 宣告為選用」與「這個 change 宣告不做」兩件事共用同一個狀態值, 而選用的 artifact 因此對 `nextSteps` 完全隱形, 永遠不會出現在任何一個下一步裡 — 一份選用但仍然值得寫的文件, 呼叫端不會被告知它存在. 兩者現在是相異的狀態. 決策記錄: `tospec/decisions/20260918_171157-optional-and-skipped-are-distinct-statuses.md`.
34
+
35
+ - **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 規則, 也就是說症狀早就被觀察到, 只是被當成樣式問題處理掉了.
36
+
37
+ - **`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」.
38
+
39
+ - **`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`.
40
+
41
+ - **`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`.
42
+
43
+ - **`archive --json` 一律帶 `totals`, `migrate --json` 補上 human 模式的後續步驟**: 前者原本在沒有任何合併發生時整個省略該鍵, 讀取端因此要區分「沒有欄位」與「數值為零」兩種情況, 而它們的意思相同; 現在未合併時歸零輸出. 後者原本漏掉延後的 ticket stub 提示與「接著執行 `tospec init`」這一步, 兩者 human 模式都會印.
44
+
45
+ ### 修正
46
+
47
+ - **空的 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`.
48
+
49
+ - **`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`.
50
+
51
+ - **一個壞掉的 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`.
52
+
53
+ - **拼錯的 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`.
54
+
55
+ - **同名的 scenario 讓 MODIFIED 的防漏檢查失效, 可以無聲刪掉一條 scenario**: 名稱是 `findMissingScenarios` 唯一的把手, 所以兩條共用同一個名稱的 scenario 對它而言可以互換 — 把其中一條重述兩次, 數量吻合, 另一條的內容則在歸檔時被刪除, 而全程驗證皆為綠燈. 重複名稱進入主 spec 的唯一途徑是從 delta 合併進來, 所以現在就在 delta 這一端拒絕它.
56
+
57
+ - **`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`.
58
+
59
+ - **`new change --schema <壞掉的 schema>` 成功, 產出永久不可用的 change**: `validateSchemaExists` 只檢查目錄存在, 從不載入檔案, 於是產出一個沒有 `type`、沒有 ticket、連自己的成功 payload 裡都沒有 `ticketPath` 的 change, 而沒有任何命令讀得了它. 更糟的是 `tospec schemas` 把壞掉的 schema 藏起來, `new change` 的錯誤訊息卻把它當成可用選項宣傳 — 兩個命令對同一份 schema 給出相反的答案. 現在在寫出任何東西之前先載入 schema, 壞掉的項目留在清單裡並攜帶其原因, 而每一份「Available schemas」清單都改由真正載入成功的那些組成.
60
+
61
+ - **ticket 帳本產生重複與錯配, archive 搬走的是舊的那一張**: 放棄一個 change 會留下它的 ticket (`rm -rf` 是唯一的途徑), 用同一個名稱重建會再寫一張, 而 archive 取 readdir 順序 — 也就是最舊的那張 — 因此把這次的執行歸檔到那個被放棄的嘗試的 ticket 底下, 並讓真正的那張永遠留在原地. 決策記錄: `tospec/decisions/20260918_171157-one-active-ticket-per-change-name.md`.
62
+
63
+ - **`show <issue-change>` 從不印出 `task.md`**: 主文件被寫死為 `proposal.md`, 而 issue schema 從不產生這份檔案, 於是 `show` 落到 ticket stub 並印出十一行 frontmatter, 取代了根因、修復計畫、測試計畫與任務清單. 主 artifact 現在從 schema 解析.
64
+
65
+ - **artifact 為 `stub` 時形成死路**: 檔案存在但內容仍是 template 的狀態下, `buildNextSteps` 只比對 `ready` 與全部完成, 所以這是唯一一個回報 `isComplete: false` 卻同時給出空 `nextSteps` 的狀態 — 沒有指示也沒有理由, 而這恰好是呼叫端無法自行推斷的情況, 因為目錄列表看起來是完整的. archive 接著把那些檔案稱為「missing required artifact(s)」, 把讀者送去磁碟上找檔案, 而它的 `fix` 指向的正是那個無話可說的 `status`. human 模式一直都指名了它 (`[!] design (stub: ...)`), 所以這單純是機器契約的缺口.
66
+
67
+ - **扁平 `--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`.
68
+
69
+ - **`decision` 指令線缺 root 紀律**: `decision new` 只建立 `tospec/decisions/`, 留下一個半成品 root, 而其他每個命令接著都把它解析為一個專案, 於是 `validate --all` 與 `status --all` 開始對一個從來不是專案的目錄回報乾淨的全面通過 — 這正是那兩個命令拒絕隱含 root 所要避免的空洞通過, 從側門重新進來了一次. `createChange` 一直都會補完 root 並附有說明理由的註解, 兩者現在共用 `completeRootStructure`. `decision list` 同樣會在任何地方都以狀態碼 0 回答「這裡沒有決策」, 現在比照其他批次命令拒絕隱含 root.
70
+ - **`decision new --force` 改寫檔案卻不更新 `index.md`**: 不追加第二列是對的, 什麼都不做則不是 — index.md 於是繼續宣傳前一份的標題與摘要, 而 `decision list` 從磁碟讀到的是新的. 現在以檔名比對就地改寫該列; 標題正是一次改寫最可能變動的東西.
71
+ - **`--date` 接受 `20261345_996199`**: 檢查只看形狀, 而這是唯一一個不由 `formatTimestamp` 產生的時間戳; 該錯誤值會成為檔名、帳本裡的人類可讀日期, 以及一個排序上壓過每一筆真實紀錄的鍵. 改以 `Date` 來回轉換而非逐欄位範圍檢查, 使月份長度與閏日都取自行事曆.
72
+
73
+ - **巢狀子命令的 `--help` 提示指向不存在或不相干的命令**: 提示用的是 `command.name()` — 葉節點的名稱. 對每一個 top-level 命令都正確, 因為兩者恰好重合; 對兩個巢狀命令則都錯: `new change` 指名了一個根本不是命令的字串, commander 因此印出 top-level help 並以狀態碼 0 結束, 於是這則建議看起來像是被回答了; 而 `decision new` 指名了建立 change 的那一組 — 一個真實存在、但回答另一個問題的命令. 現在由完整路徑組出.
74
+
75
+ - **一批對同一份資料給出兩個答案的較小修正**: `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 解析之後因而在空專案裡從不觸發.
76
+
77
+ - **第一輪的其餘修正**: 未放置於既有 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、先寫再刪, 且遇到已編輯的副本時拒絕而非移除.
78
+
79
+ ### 其他
80
+
81
+ - **四輪掃描的方法與結果**: 測試由 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.
82
+
83
+ - **`src/core/markdown-render.ts` 的兩個裸 NUL 位元組改寫為 `\x00` 逸出序列**: URL 淨化用的字元類別 `[\x00- ]` 把 NUL 直接寫成裸的控制字元. 執行結果完全正確, 但 git 的二進位偵測因此把整個檔案判為 binary, 於是它的提交對這個檔案印出 `Bin 0 -> 4490 bytes` 而不是逐行 diff — 本版唯一一項安全性修正所在的檔案, 就這樣在沒有任何可讀 diff 的情況下通過了審查. 逸出序列在 regex 字元類別裡與裸位元組完全等價, 所以這是純粹的來源表述修正, 行為與測試數皆不變.
84
+
85
+ - 上游 OpenSpec 參考點仍停在 `e062b95` (1.12.0), 本版與上游比對無關.
86
+
87
+ ## [0.19.0-beta.7] - 2026-09-16
88
+
89
+ **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
90
+
91
+ 本版本分兩層. 第一層是十個各自診斷到根因的 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 時流失, 而兩份清單之間沒有任何機制對帳. 下列各項已把複查的修正併入它所修正的條目, 而非另立一節.
92
+
93
+ ### 變更
94
+
95
+ - **`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`.
96
+
97
+ - **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 區段存在卻解析出零筆時改印所需語法, 而非沿用指向無效修法的通用建議.
98
+ - **放寬的範圍隨後被重新界定**: 首次實作把 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`.
99
+
100
+ - **`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` 就是使用者這麼說的, 這是回報修正而非新增閘門.
101
+ - **失敗路徑同樣不再丟掉已收集的警告**: `--no-validate --yes --json` 會先觸發 `archive_confirmation_required` 警告, 若之後合併失敗, 失敗文件原本只帶錯誤. 而一次「跳過驗證之後才失敗」的執行, 正是讀者最需要知道那次覆寫的時候 — 它就是這次失敗之所以可達的原因.
102
+
103
+ - **`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 檔; 模組自己的檔頭註解描述得完全正確, 沒跟上的是使用者在決定要不要開這個連接埠之前唯一會讀的那段文字.
104
+
105
+ - **`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」), 擋不下這一批.
106
+
107
+ - **`tospec init` 不再指向不存在的 `README.md`**: 收尾訊息中無條件輸出的 `Documentation: README.md` 已移除. 那行大概是為套件自身的 README 而寫, 但它在使用者的新專案裡呈現為一個專案相對路徑, 而 `init` 只寫入 `tospec/`、`.agents/` 與設定的工具目錄, 不會產生該檔. 相較於改指向套件首頁 URL, 直接移除較安全: `init` 本來就以具體的下一步作結, 而一個必須持續保持正確的連結, 就是一個還會再次過期的連結.
108
+
109
+ ### 修正
110
+
111
+ - **`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 的本意正好相反.
112
+
113
+ - **`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` 或任何未來的啟動失敗仍會回報成功.
114
+ - **等待本身加上逾時**: 原先的握手只 race `LISTENING` 與 `exit` 兩個事件, 但一個被 spawn 的 process 有三種結局 — 成功、失敗, 以及**兩者皆非**. child 若綁定後卡住, parent 會永遠等下去, `--detach` 直接 hang 且沒有任何輸出. 現加上 10 秒逾時, 訊息說明的是「沒有觀測到 child 回報」而非宣稱啟動失敗 (child 仍在執行, 可能只是還沒起來), 並指向 `--list` / `--stop`. `child.unref()` 一併移進 `finally`: 在逾時這條路徑上 child 還活著, 未 unref 的 handle 會在錯誤都回報完之後繼續綁住 parent 的 event loop.
115
+ - **成功路徑補上真實 spawn 的測試**: 原本唯一的覆蓋是自己 emit `LISTENING http://...` 的 stub, 與實作對同一個字串雙向耦合 — 這種閉環只有在兩邊同時寫錯時才會失敗. 新的測試實際起一個 detached dashboard, 用 `--list` 確認看得到, 再以 `--stop` 收尾, 全程不提及那個 token.
116
+
117
+ - **`tospec dashboard --port` 完全沒有驗證**: `Number(options?.port ?? 5620)` 沒有 `Number.isInteger` 或範圍檢查, 所以 `notanumber` 變成 `NaN` 一路抵達 URL 字串; 連埠號遞增迴圈也擋不住, 因為 `used.has(NaN)` 恆為 false, 該值原封不動通過. 現以明確的整數與 `0..65535` 範圍檢查走標準錯誤路徑, `-p 0` 維持可用 — 它是把埠號選擇交給作業系統, `--list` 本來就正確回報實際埠號.
118
+
119
+ - **`tospec decision new --force` 覆寫檔案卻仍追加一列索引**: `appendIndexRow` 是無條件的, 當 `--force` 取代既有檔案時該檔的列已經存在, 於是索引多出一列重複. 既有的限制註解記錄了「append-only、不去重」這個決定, 但它設想的是「對同一個 topic 重跑 `new` 會多一列」, 沒有涵蓋 `--force` — 那裡的檔案並不是第二筆紀錄. 修正讓索引列以「這次寫入是新檔」為條件, 符合索引本身的語意: 一筆決策一列, 而不是一次呼叫一列.
120
+ - **存在性檢查不是原子的**: 檢查與寫入之間隔著 `loadTemplate` 與 `renderDecision`, 那是貨真價實的 I/O 寬度; 兩個 process 可以同時通過檢查、同時寫入, 而 `fs.writeFileSync` 預設的 `'w'` 旗標無條件截斷, 後者靜默覆蓋前者. 現改用 `{ flag: 'wx' }` 讓檔案系統來執行這道守衛, `EEXIST` 翻譯回既有的訊息, 使用者可見的行為不變; `--force` 維持 `'w'`. 任何 check-then-write 的方案都留有一個窗口, 無論把它縮到多小, 而檔案系統早就提供了這個原語.
121
+
122
+ - **capability 資料夾內檔名寫錯的 delta 驗證全綠卻被靜默丟棄**: `findSpecFiles` 只收集 basename 恰為 `spec.md` 的檔案, 而 validator 早已備有一整組針對「永遠不會被合併的 delta 檔」的守衛 (specs 根目錄的 `spec.md`、深度超過一層的路徑、點開頭的資料夾), 每一條都以同一句理由成立. 第四種失敗模式完全相同的情況 — 對的資料夾、錯的檔名 — 沒有守衛, 根因是**它在守衛迴圈看到之前就被 walker 過濾掉了**: 守衛只能裁決 walker 交給它的檔案. 合併端讀的同樣是寫死的 `spec.md`, 兩邊一致, 所以沒有任何地方回報衝突. 修正是新增一個回傳 `specs/` 下所有 `*.md` 的走訪器並在同一個迴圈裡以 ERROR 指名該檔, 而不是放寬合併路徑 — 合併只讀 `spec.md` 是版面配置的契約, 改動它會讓同一資料夾內的兩個檔案變得語意不明.
123
+
124
+ - **`tospec config get` 對未知的 key 與未設定的 key 都靜默失敗**: `getNestedValue` 對「這個 key 存在但沒有值」與「這個 key 不屬於 schema」都回傳 `undefined`, 單一的 `undefined` 分支於是把兩種不同的使用者錯誤壓成同一次無聲離開. 根因是 `get` 從不驗證 key 是否為真 — `set` 不會有這個問題, 因為它在寫入前會對照 schema 驗證. 現先驗證 key (沿用 `set` 的判準, 兩個命令對「什麼是有效的 key」保持一致), 未知的 key 與未設定的 key 各給一則 stderr 診斷. stdout 在兩種情況下都維持空白, 保住 `(raw, scriptable)` 的契約.
125
+
126
+ - **`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 而未帶、來源為空, 或根本不存在.
127
+ - **產生的 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`, 讓縮排不再取決於被序列化的內容.
128
+ - **遷移的 change 不補 ticket stub**: summary 會回報有多少個 change 需要補. ticket 檔名帶時間戳, 而其語意是「這項工作被提出的時間」, migrate 推導不出正確的值 — 遷移當下的時間、檔案 mtime、OpenSpec 封存目錄名只有日期的前綴, 全都是編造. 產生一份時間不可信的 ticket, 會把一個本來只是「缺少」的狀態變成「存在但內容錯誤」, 而後者更難發現. 另補上「遷移結果通過 `tospec validate --all`」的回歸測試, 把「缺 ticket 不影響驗證」這個前提釘住. 決策記錄: `tospec/decisions/20260916_121552-migrated-changes-defer-ticket-stubs.md`.
129
+
130
+ - **數個 `--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` 既有的做法一致.
131
+
132
+ - **`tospec decision new` 的 topic 錯誤訊息自稱「Change name」**: 重用 `validateChangeName` 來檢查文法是對的 — topic 與 change name 共用同一套 kebab-id 文法, 而 `change-utils.ts` 是該文法唯一的定義處 — 缺陷在於它的錯誤字串是為單一呼叫端撰寫的, 另一個呼叫端直接包裝而未翻譯. 現讓 `validateChangeName` 接受呼叫端提供的名詞 (預設 `'Change name'`), `decision new` 傳入 `'Topic'`. 在 `decision` 呼叫點改寫字串會留下兩份會各自漂移的訊息, 修在共用驗證器才能維持一套文法、一套訊息.
133
+
134
+ - **`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` 後已含完全相同的三條建議).
135
+
136
+ ### 其他
137
+
138
+ - **`tospec dashboard --detach` 的兩個 pid record writer 刻意保留, 並補上等價性測試**: parent 與 detached child 都會寫同一份 pid record. 複查一度認定 parent 那次是純重複, 實際判定是兩者各自為某一種失敗模式下唯一存在的紀錄 — child 那次是每個 dashboard (含前景執行) 為自己寫的, 也是 parent 在握手中途被砍或逾時放棄時僅存的一份; parent 那次則保證呼叫回傳時紀錄已經存在, 因為 child 是**送出 `LISTENING` 之後**才寫自己的, 少了它, 緊接著的第二次 `--detach` 會找不到東西可擋, 而綁定後卡住的 child 會變成追蹤不到的 orphan. 兩者寫入的位元組相同, docstring 已據實說明順序與理由; 新測試從子目錄啟動, 讓兩邊各自推導 `projectRoot` 再斷言等價, 一旦哪天分岔就會失敗.
139
+
140
+ - **本版第二層複查的基準與結果**: 十個 change 的 task.md 逐條對照實際程式碼, 加上 `pnpm build && pnpm test` 全綠 — 九項發現全部是「測試通過但仍然錯」的情況. 其中兩項有可獨立執行的紅燈訊號 (YAML block scalar 的 parse 錯誤、REMOVED delta 的假 ERROR), 其餘是註解與實際行為的落差, 或是邊界情況本來就沒有斷言. 複查修正完成後為 83 個檔案 / 1112 passed, 較基準新增 17 個測試; 1 skipped 為既有的 `it.runIf(platform !== 'win32')`, 與本版改動無關.
141
+
142
+ - 上游 OpenSpec 參考點仍停在 `e062b95` (1.12.0), 本版與上游比對無關.
143
+
9
144
  ## [0.19.0-beta.6] - 2026-09-14
10
145
 
11
146
  **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
@@ -520,6 +655,8 @@ Dashboard 進化為可背景常駐、多專案並存的服務, 並補上 TDD 導
520
655
 
521
656
  - 新增 `prepack` script 與 npm publish 的準備設定, 完備套件發行流程.
522
657
 
658
+ [0.19.0-beta.8]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.7...v0.19.0-beta.8
659
+ [0.19.0-beta.7]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.6...v0.19.0-beta.7
523
660
  [0.19.0-beta.6]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.5...v0.19.0-beta.6
524
661
  [0.19.0-beta.5]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.4...v0.19.0-beta.5
525
662
  [0.19.0-beta.4]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.3...v0.19.0-beta.4
@@ -840,7 +840,7 @@ function fileAccordion(file) {
840
840
  body.textContent = 'Loading...';
841
841
  try {
842
842
  const data = await api('/api/render?file=' + encodeURIComponent(file.path));
843
- body.innerHTML = data.html; // controlled container; server-rendered markdown
843
+ body.innerHTML = data.html; // sanitized by core/markdown-render.ts - raw HTML is escaped there, not here
844
844
  } catch (e) {
845
845
  loaded = false;
846
846
  body.textContent = 'Failed to render (' + (e.status || 'error') + ').';
@@ -1008,7 +1008,7 @@ async function renderSpecDetail(id) {
1008
1008
 
1009
1009
  try {
1010
1010
  const data = await api('/api/render?file=' + encodeURIComponent(`tospec/specs/${id}/spec.md`));
1011
- body.innerHTML = data.html; // controlled container; server-rendered markdown
1011
+ body.innerHTML = data.html; // sanitized by core/markdown-render.ts - raw HTML is escaped there, not here
1012
1012
  } catch {
1013
1013
  body.textContent = 'Failed to render spec.';
1014
1014
  }
@@ -1063,7 +1063,7 @@ async function renderDecisionContent(pane, file) {
1063
1063
  );
1064
1064
  try {
1065
1065
  const data = await api('/api/render?file=' + encodeURIComponent(d.path));
1066
- body.innerHTML = data.html; // controlled container; server-rendered markdown
1066
+ body.innerHTML = data.html; // sanitized by core/markdown-render.ts - raw HTML is escaped there, not here
1067
1067
  enhanceDecisionQA(body, d.content); // 決策過程 → Q/A cards (client-side, graceful fallback)
1068
1068
  } catch {
1069
1069
  body.textContent = 'Failed to render decision.';
@@ -0,0 +1,3 @@
1
+ # Decision
2
+
3
+ Any material decision reached during discussion must be recorded with /tospec-decision, which creates a decision file and captures the relevant decision details.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAgC,MAAM,WAAW,CAAC;AAwElE,QAAA,MAAM,OAAO,SAAgB,CAAC;AAga9B,OAAO,EAAE,OAAO,EAAE,CAAC;AAEnB,wBAAgB,MAAM,CAAC,IAAI,WAAe,GAAG,IAAI,CAchD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,OAAO,EAAgD,MAAM,WAAW,CAAC;AAoIlF,QAAA,MAAM,OAAO,SAAgB,CAAC;AAygB9B,OAAO,EAAE,OAAO,EAAE,CAAC;AAsBnB,wBAAgB,MAAM,CAAC,IAAI,WAAe,GAAG,IAAI,CAkChD"}