@seanmars/tospec 0.19.0-beta.7 → 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 (194) hide show
  1. package/CHANGELOG.md +80 -1
  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 +191 -33
  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 +135 -24
  9. package/dist/commands/config.js.map +1 -1
  10. package/dist/commands/dashboard.d.ts +2 -2
  11. package/dist/commands/dashboard.d.ts.map +1 -1
  12. package/dist/commands/dashboard.js +75 -10
  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 +164 -26
  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 +13 -3
  25. package/dist/commands/show.js.map +1 -1
  26. package/dist/commands/validate.d.ts +23 -1
  27. package/dist/commands/validate.d.ts.map +1 -1
  28. package/dist/commands/validate.js +61 -12
  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 +62 -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 +151 -21
  61. package/dist/core/archive.js.map +1 -1
  62. package/dist/core/artifact-graph/index.d.ts +1 -1
  63. package/dist/core/artifact-graph/index.d.ts.map +1 -1
  64. package/dist/core/artifact-graph/index.js +1 -1
  65. package/dist/core/artifact-graph/index.js.map +1 -1
  66. package/dist/core/artifact-graph/instruction-loader.d.ts +63 -3
  67. package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
  68. package/dist/core/artifact-graph/instruction-loader.js +35 -26
  69. package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
  70. package/dist/core/artifact-graph/resolver.d.ts +26 -0
  71. package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
  72. package/dist/core/artifact-graph/resolver.js +44 -4
  73. package/dist/core/artifact-graph/resolver.js.map +1 -1
  74. package/dist/core/artifact-graph/stub-detection.d.ts +20 -0
  75. package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -0
  76. package/dist/core/artifact-graph/stub-detection.js +42 -0
  77. package/dist/core/artifact-graph/stub-detection.js.map +1 -0
  78. package/dist/core/change-presenter.d.ts +16 -0
  79. package/dist/core/change-presenter.d.ts.map +1 -1
  80. package/dist/core/change-presenter.js +68 -9
  81. package/dist/core/change-presenter.js.map +1 -1
  82. package/dist/core/change-status-policy.d.ts +12 -1
  83. package/dist/core/change-status-policy.d.ts.map +1 -1
  84. package/dist/core/change-status-policy.js +33 -1
  85. package/dist/core/change-status-policy.js.map +1 -1
  86. package/dist/core/codex-residue.js +1 -1
  87. package/dist/core/config-schema.d.ts +1 -12
  88. package/dist/core/config-schema.d.ts.map +1 -1
  89. package/dist/core/config-schema.js +27 -1
  90. package/dist/core/config-schema.js.map +1 -1
  91. package/dist/core/config.d.ts +37 -0
  92. package/dist/core/config.d.ts.map +1 -1
  93. package/dist/core/config.js +37 -0
  94. package/dist/core/config.js.map +1 -1
  95. package/dist/core/dashboard-activity.js +2 -2
  96. package/dist/core/dashboard-activity.js.map +1 -1
  97. package/dist/core/dashboard-data.d.ts +5 -0
  98. package/dist/core/dashboard-data.d.ts.map +1 -1
  99. package/dist/core/dashboard-data.js +15 -8
  100. package/dist/core/dashboard-data.js.map +1 -1
  101. package/dist/core/init.d.ts +22 -0
  102. package/dist/core/init.d.ts.map +1 -1
  103. package/dist/core/init.js +107 -42
  104. package/dist/core/init.js.map +1 -1
  105. package/dist/core/list.d.ts +1 -1
  106. package/dist/core/list.d.ts.map +1 -1
  107. package/dist/core/list.js +68 -17
  108. package/dist/core/list.js.map +1 -1
  109. package/dist/core/markdown-render.d.ts +33 -0
  110. package/dist/core/markdown-render.d.ts.map +1 -0
  111. package/dist/core/markdown-render.js +103 -0
  112. package/dist/core/markdown-render.js.map +1 -0
  113. package/dist/core/migrate.d.ts +17 -5
  114. package/dist/core/migrate.d.ts.map +1 -1
  115. package/dist/core/migrate.js +48 -2
  116. package/dist/core/migrate.js.map +1 -1
  117. package/dist/core/parsers/change-parser.js +1 -1
  118. package/dist/core/parsers/change-parser.js.map +1 -1
  119. package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
  120. package/dist/core/parsers/markdown-parser.js +16 -0
  121. package/dist/core/parsers/markdown-parser.js.map +1 -1
  122. package/dist/core/root-selection.d.ts +11 -1
  123. package/dist/core/root-selection.d.ts.map +1 -1
  124. package/dist/core/root-selection.js +4 -2
  125. package/dist/core/root-selection.js.map +1 -1
  126. package/dist/core/rules.d.ts +6 -1
  127. package/dist/core/rules.d.ts.map +1 -1
  128. package/dist/core/rules.js +24 -2
  129. package/dist/core/rules.js.map +1 -1
  130. package/dist/core/schemas/base.schema.d.ts +3 -0
  131. package/dist/core/schemas/base.schema.d.ts.map +1 -1
  132. package/dist/core/schemas/base.schema.js +22 -0
  133. package/dist/core/schemas/base.schema.js.map +1 -1
  134. package/dist/core/schemas/change.schema.d.ts +8 -0
  135. package/dist/core/schemas/change.schema.d.ts.map +1 -1
  136. package/dist/core/schemas/spec.schema.d.ts +2 -0
  137. package/dist/core/schemas/spec.schema.d.ts.map +1 -1
  138. package/dist/core/shared/index.d.ts +1 -1
  139. package/dist/core/shared/index.d.ts.map +1 -1
  140. package/dist/core/shared/index.js +1 -1
  141. package/dist/core/shared/index.js.map +1 -1
  142. package/dist/core/shared/rules-generation.d.ts +27 -6
  143. package/dist/core/shared/rules-generation.d.ts.map +1 -1
  144. package/dist/core/shared/rules-generation.js +115 -44
  145. package/dist/core/shared/rules-generation.js.map +1 -1
  146. package/dist/core/shared/tool-detection.d.ts +19 -0
  147. package/dist/core/shared/tool-detection.d.ts.map +1 -1
  148. package/dist/core/shared/tool-detection.js +30 -2
  149. package/dist/core/shared/tool-detection.js.map +1 -1
  150. package/dist/core/spec-presenter.d.ts.map +1 -1
  151. package/dist/core/spec-presenter.js +5 -0
  152. package/dist/core/spec-presenter.js.map +1 -1
  153. package/dist/core/specs-apply.d.ts +15 -1
  154. package/dist/core/specs-apply.d.ts.map +1 -1
  155. package/dist/core/specs-apply.js +33 -11
  156. package/dist/core/specs-apply.js.map +1 -1
  157. package/dist/core/templates/workflows/apply.js +1 -1
  158. package/dist/core/templates/workflows/apply.js.map +1 -1
  159. package/dist/core/templates/workflows/archive.d.ts.map +1 -1
  160. package/dist/core/templates/workflows/archive.js +4 -2
  161. package/dist/core/templates/workflows/archive.js.map +1 -1
  162. package/dist/core/templates/workflows/decision.js +1 -1
  163. package/dist/core/update.d.ts +26 -0
  164. package/dist/core/update.d.ts.map +1 -1
  165. package/dist/core/update.js +111 -23
  166. package/dist/core/update.js.map +1 -1
  167. package/dist/core/validation/constants.d.ts +1 -1
  168. package/dist/core/validation/constants.d.ts.map +1 -1
  169. package/dist/core/validation/constants.js +6 -1
  170. package/dist/core/validation/constants.js.map +1 -1
  171. package/dist/core/validation/section-validator.d.ts.map +1 -1
  172. package/dist/core/validation/section-validator.js +21 -3
  173. package/dist/core/validation/section-validator.js.map +1 -1
  174. package/dist/core/validation/validator.d.ts +9 -3
  175. package/dist/core/validation/validator.d.ts.map +1 -1
  176. package/dist/core/validation/validator.js +58 -8
  177. package/dist/core/validation/validator.js.map +1 -1
  178. package/dist/utils/change-utils.d.ts +28 -0
  179. package/dist/utils/change-utils.d.ts.map +1 -1
  180. package/dist/utils/change-utils.js +115 -26
  181. package/dist/utils/change-utils.js.map +1 -1
  182. package/dist/utils/file-system.d.ts +1 -1
  183. package/dist/utils/file-system.js +1 -1
  184. package/dist/utils/item-discovery.d.ts +20 -5
  185. package/dist/utils/item-discovery.d.ts.map +1 -1
  186. package/dist/utils/item-discovery.js +28 -9
  187. package/dist/utils/item-discovery.js.map +1 -1
  188. package/dist/utils/task-progress.js +1 -1
  189. package/package.json +1 -1
  190. package/schemas/decision/templates/decision.md +3 -1
  191. package/schemas/decision/templates/index.md +2 -2
  192. package/schemas/issue/templates/spec.md +8 -0
  193. package/schemas/sdd/templates/spec.md +8 -0
  194. /package/assets/rules/tospec/{single-sourc-of-truth.md → single-source-of-truth.md} +0 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,84 @@
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
+
9
87
  ## [0.19.0-beta.7] - 2026-09-16
10
88
 
11
89
  **預發布版本**: 需以 `npm install @seanmars/tospec@beta` 明確指定才會安裝, `latest` 仍指向 0.18.0.
@@ -38,7 +116,7 @@ tospec 是一套 spec-driven development CLI: 以 schema 定義文件結構與
38
116
 
39
117
  - **`tospec dashboard --port` 完全沒有驗證**: `Number(options?.port ?? 5620)` 沒有 `Number.isInteger` 或範圍檢查, 所以 `notanumber` 變成 `NaN` 一路抵達 URL 字串; 連埠號遞增迴圈也擋不住, 因為 `used.has(NaN)` 恆為 false, 該值原封不動通過. 現以明確的整數與 `0..65535` 範圍檢查走標準錯誤路徑, `-p 0` 維持可用 — 它是把埠號選擇交給作業系統, `--list` 本來就正確回報實際埠號.
40
118
 
41
- - **`tospec decision new --force` 覆寫檔案卻仍追加一列索引**: `appendIndexRow` 是無條件的, 當 `--force` 取代既有檔案時該檔的列已經存在, 於是索引多出一列重複. 既有的 `ponytail` 註解記錄了「append-only、不去重」這個決定, 但它設想的是「對同一個 topic 重跑 `new` 會多一列」, 沒有涵蓋 `--force` — 那裡的檔案並不是第二筆紀錄. 修正讓索引列以「這次寫入是新檔」為條件, 符合索引本身的語意: 一筆決策一列, 而不是一次呼叫一列.
119
+ - **`tospec decision new --force` 覆寫檔案卻仍追加一列索引**: `appendIndexRow` 是無條件的, 當 `--force` 取代既有檔案時該檔的列已經存在, 於是索引多出一列重複. 既有的限制註解記錄了「append-only、不去重」這個決定, 但它設想的是「對同一個 topic 重跑 `new` 會多一列」, 沒有涵蓋 `--force` — 那裡的檔案並不是第二筆紀錄. 修正讓索引列以「這次寫入是新檔」為條件, 符合索引本身的語意: 一筆決策一列, 而不是一次呼叫一列.
42
120
  - **存在性檢查不是原子的**: 檢查與寫入之間隔著 `loadTemplate` 與 `renderDecision`, 那是貨真價實的 I/O 寬度; 兩個 process 可以同時通過檢查、同時寫入, 而 `fs.writeFileSync` 預設的 `'w'` 旗標無條件截斷, 後者靜默覆蓋前者. 現改用 `{ flag: 'wx' }` 讓檔案系統來執行這道守衛, `EEXIST` 翻譯回既有的訊息, 使用者可見的行為不變; `--force` 維持 `'w'`. 任何 check-then-write 的方案都留有一個窗口, 無論把它縮到多小, 而檔案系統早就提供了這個原語.
43
121
 
44
122
  - **capability 資料夾內檔名寫錯的 delta 驗證全綠卻被靜默丟棄**: `findSpecFiles` 只收集 basename 恰為 `spec.md` 的檔案, 而 validator 早已備有一整組針對「永遠不會被合併的 delta 檔」的守衛 (specs 根目錄的 `spec.md`、深度超過一層的路徑、點開頭的資料夾), 每一條都以同一句理由成立. 第四種失敗模式完全相同的情況 — 對的資料夾、錯的檔名 — 沒有守衛, 根因是**它在守衛迴圈看到之前就被 walker 過濾掉了**: 守衛只能裁決 walker 交給它的檔案. 合併端讀的同樣是寫死的 `spec.md`, 兩邊一致, 所以沒有任何地方回報衝突. 修正是新增一個回傳 `specs/` 下所有 `*.md` 的走訪器並在同一個迴圈裡以 ERROR 指名該檔, 而不是放寬合併路徑 — 合併只讀 `spec.md` 是版面配置的契約, 改動它會讓同一資料夾內的兩個檔案變得語意不明.
@@ -577,6 +655,7 @@ Dashboard 進化為可背景常駐、多專案並存的服務, 並補上 TDD 導
577
655
 
578
656
  - 新增 `prepack` script 與 npm publish 的準備設定, 完備套件發行流程.
579
657
 
658
+ [0.19.0-beta.8]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.7...v0.19.0-beta.8
580
659
  [0.19.0-beta.7]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.6...v0.19.0-beta.7
581
660
  [0.19.0-beta.6]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.5...v0.19.0-beta.6
582
661
  [0.19.0-beta.5]: https://github.com/seanmars/tospec/compare/v0.19.0-beta.4...v0.19.0-beta.5
@@ -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;AAgb9B,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"}
package/dist/cli/index.js CHANGED
@@ -1,22 +1,24 @@
1
- import { emitFailure, isCliAbort } from '../commands/shared-output.js';
2
- import { Command, Option, InvalidArgumentError } from 'commander';
1
+ import { emitFailure, isCliAbort, emitFailureStatus, normalizeCommanderCode, } from '../commands/shared-output.js';
2
+ import { Command, CommanderError, Option, InvalidArgumentError } from 'commander';
3
3
  import { createRequire } from 'module';
4
4
  import path from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { promises as fs } from 'fs';
7
7
  import { AI_TOOLS } from '../core/config.js';
8
- import { UpdateCommand } from '../core/update.js';
8
+ import { UpdateCommand, UPDATE_FAILURE_PAYLOAD } from '../core/update.js';
9
9
  import { ListCommand } from '../core/list.js';
10
10
  import { ArchiveCommand } from '../core/archive.js';
11
- import { MigrateCommand } from '../core/migrate.js';
11
+ import { INIT_FAILURE_PAYLOAD } from '../core/init.js';
12
+ import { RULES_FAILURE_PAYLOAD } from '../core/rules.js';
13
+ import { MigrateCommand, MIGRATE_FAILURE_PAYLOAD } from '../core/migrate.js';
12
14
  import { resolveRootForCommand, resolveTospecRoot, isRootSelectionError, toRootOutput, } from '../core/root-selection.js';
13
- import { ValidateCommand } from '../commands/validate.js';
14
- import { ShowCommand } from '../commands/show.js';
15
+ import { ValidateCommand, VALIDATE_FAILURE_PAYLOAD } from '../commands/validate.js';
16
+ import { ShowCommand, SHOW_FAILURE_PAYLOAD } from '../commands/show.js';
15
17
  import { isInteractive } from '../utils/interactive.js';
16
18
  import { registerConfigCommand } from '../commands/config.js';
17
- import { registerDecisionCommand } from '../commands/decision.js';
19
+ import { registerDecisionCommand, DECISION_NEW_FAILURE_PAYLOAD, DECISION_LIST_FAILURE_PAYLOAD, } from '../commands/decision.js';
18
20
  import { registerMetricsCommand } from '../commands/metrics.js';
19
- import { statusCommand, BATCH_STATUS_FAILURE_PAYLOAD, instructionsCommand, applyInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
21
+ import { statusCommand, BATCH_STATUS_FAILURE_PAYLOAD, STATUS_FAILURE_PAYLOAD, instructionsCommand, applyInstructionsCommand, INSTRUCTIONS_FAILURE_PAYLOAD, APPLY_ARGUMENT, templatesCommand, TEMPLATES_FAILURE_PAYLOAD, schemasCommand, SCHEMAS_FAILURE_PAYLOAD, newChangeCommand, NEW_CHANGE_FAILURE_PAYLOAD, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
20
22
  /**
21
23
  * The options the user actually typed.
22
24
  *
@@ -36,6 +38,38 @@ function userSuppliedOptions(options, command) {
36
38
  return {};
37
39
  return Object.fromEntries(Object.entries(options).filter(([key]) => command.getOptionValueSource(key) !== 'default'));
38
40
  }
41
+ const jsonFailureShapes = new WeakMap();
42
+ function withJsonFailureShape(command, shape) {
43
+ jsonFailureShapes.set(command, shape);
44
+ return command;
45
+ }
46
+ /**
47
+ * Makes commander throw instead of exiting, for the whole command tree.
48
+ *
49
+ * Applied after every subcommand is registered rather than at construction:
50
+ * commander only copies `_exitCallback` into a subcommand at `.command()` time,
51
+ * so setting it on the root alone would leave every subcommand still calling
52
+ * `process.exit` — which is where these failures actually happen.
53
+ */
54
+ function throwOnCommanderExit(command) {
55
+ command.exitOverride((error) => {
56
+ // Attach the command so runCli can recover its null-shape; commander's
57
+ // error carries only a code and a message.
58
+ error.tospecCommand = command;
59
+ throw error;
60
+ });
61
+ for (const child of command.commands)
62
+ throwOnCommanderExit(child);
63
+ }
64
+ /**
65
+ * The root output for a command whose root came from its path argument rather
66
+ * than from a search: `init`, `update` and `rules`. Their success payloads all
67
+ * report `source: 'explicit'`, so their failures say the same thing about the
68
+ * same directory instead of `null` (round 4 report 10).
69
+ */
70
+ function explicitRootOutput(targetPath) {
71
+ return { path: path.resolve(targetPath), source: 'explicit' };
72
+ }
39
73
  function failWithError(error, json) {
40
74
  // The agent contract: every --json failure leaves exactly one JSON
41
75
  // document on stdout (the command's null-shape plus a status array).
@@ -70,6 +104,7 @@ program
70
104
  .option('--tools <tools>', toolsOptionDescription)
71
105
  .option('--force', 'Refresh existing generated files without prompting; does not bypass rule conflicts')
72
106
  .option('--profile <profile>', 'Override global config profile (core or custom)')
107
+ .option('--json', 'Output as JSON (for programmatic use)')
73
108
  .action(async (targetPath = '.', options) => {
74
109
  try {
75
110
  const resolvedPath = path.resolve(targetPath);
@@ -95,49 +130,73 @@ program
95
130
  tools: options?.tools,
96
131
  force: options?.force,
97
132
  profile: options?.profile,
133
+ json: options?.json,
98
134
  });
99
135
  await initCommand.execute(targetPath);
100
136
  }
101
137
  catch (error) {
102
- failWithError(error);
138
+ failWithError(error, {
139
+ enabled: options?.json,
140
+ // Round 4 report 10. These three take their root as an argument rather
141
+ // than searching for it, so it is known even when the run fails — a
142
+ // rule conflict reports absolute paths *under* this root while the
143
+ // envelope claimed `root: null`. Same `source` the success payload uses.
144
+ payload: { ...INIT_FAILURE_PAYLOAD, root: explicitRootOutput(targetPath) },
145
+ fallbackCode: 'init_error',
146
+ });
103
147
  }
104
148
  });
105
149
  program
106
150
  .command('update [path]')
107
151
  .description('Update tospec instruction files')
108
152
  .option('--force', 'Force update even when tools are up to date; does not bypass rule conflicts')
153
+ .option('--json', 'Output as JSON (for programmatic use)')
109
154
  .action(async (targetPath = '.', options) => {
110
155
  try {
111
- const updateCommand = new UpdateCommand({ force: options?.force });
156
+ const updateCommand = new UpdateCommand({ force: options?.force, json: options?.json });
112
157
  await updateCommand.execute(targetPath);
113
158
  }
114
159
  catch (error) {
115
- failWithError(error);
160
+ failWithError(error, {
161
+ enabled: options?.json,
162
+ payload: { ...UPDATE_FAILURE_PAYLOAD, root: explicitRootOutput(targetPath) },
163
+ fallbackCode: 'update_error',
164
+ });
116
165
  }
117
166
  });
118
167
  program
119
168
  .command('rules [path]')
120
169
  .description('Refresh workflow rules for tools with installed tospec skills')
121
- .action(async (targetPath = '.') => {
170
+ .option('--json', 'Output as JSON (for programmatic use)')
171
+ .action(async (targetPath = '.', options) => {
122
172
  try {
123
173
  const { RulesCommand } = await import('../core/rules.js');
124
- await new RulesCommand().execute(targetPath);
174
+ await new RulesCommand().execute(targetPath, { json: options?.json });
125
175
  }
126
176
  catch (error) {
127
- failWithError(error);
177
+ failWithError(error, {
178
+ enabled: options?.json,
179
+ payload: { ...RULES_FAILURE_PAYLOAD, root: explicitRootOutput(targetPath) },
180
+ fallbackCode: 'rules_error',
181
+ });
128
182
  }
129
183
  });
130
184
  program
131
185
  .command('migrate [openspec-dir]')
132
186
  .description('Migrate an OpenSpec project into tospec format (default source: ./openspec)')
133
187
  .option('-f, --force', 'Skip confirmation prompt')
188
+ .option('--json', 'Output as JSON (non-interactive; requires --force)')
134
189
  .action(async (openspecDir, options) => {
135
190
  try {
136
191
  const migrateCommand = new MigrateCommand();
137
192
  await migrateCommand.execute(openspecDir, options);
138
193
  }
139
194
  catch (error) {
140
- failWithError(error);
195
+ failWithError(error, {
196
+ enabled: options?.json,
197
+ payload: MIGRATE_FAILURE_PAYLOAD,
198
+ fallbackCode: 'migrate_error',
199
+ });
141
200
  }
142
201
  });
143
202
  program
@@ -149,31 +208,45 @@ program
149
208
  .option('--type <type>', 'Filter changes by type: "requirement" or "issue"')
150
209
  .option('--json', 'Output as JSON (for programmatic use)')
151
210
  .action(async (options) => {
211
+ // Round 4 report 10. Both the argument check below and the catch block used
212
+ // to hardcode `root: null`, so `list --type bogus` was the one runtime
213
+ // failure in the CLI that denied knowing where it had been pointed —
214
+ // every other command reports the resolved root on failure. Resolution
215
+ // first, and the payload built from what it found.
216
+ let resolvedRoot = null;
217
+ const failurePayload = () => ({
218
+ ...(options?.specs ? { specs: [] } : { changes: [] }),
219
+ root: resolvedRoot ? toRootOutput(resolvedRoot) : null,
220
+ });
152
221
  try {
153
- if (options?.type && options.type !== 'requirement' && options.type !== 'issue') {
154
- throw new Error(`Invalid --type '${options.type}'. Expected "requirement" or "issue".`);
155
- }
156
- const root = await resolveRootForCommand({}, {
222
+ resolvedRoot = await resolveRootForCommand({}, {
157
223
  json: options?.json,
158
- failurePayload: options?.specs ? { specs: [], root: null } : { changes: [], root: null },
224
+ // See the note in workflow/status.ts: an implicit root means no ancestor
225
+ // is a tospec project, and listing "no changes" there is an answer about
226
+ // a place that was never checked.
227
+ allowImplicitRoot: false,
228
+ failurePayload: failurePayload(),
159
229
  });
160
- if (!root) {
230
+ if (!resolvedRoot) {
161
231
  return;
162
232
  }
233
+ if (options?.type && options.type !== 'requirement' && options.type !== 'issue') {
234
+ throw new Error(`Invalid --type '${options.type}'. Expected "requirement" or "issue".`);
235
+ }
163
236
  const listCommand = new ListCommand();
164
237
  const mode = options?.specs ? 'specs' : 'changes';
165
238
  const sort = options?.sort === 'name' ? 'name' : 'recent';
166
- await listCommand.execute(root.path, mode, {
239
+ await listCommand.execute(resolvedRoot.path, mode, {
167
240
  sort,
168
241
  json: options?.json,
169
242
  type: options?.type,
170
- ...(options?.json ? { root: toRootOutput(root) } : {}),
243
+ ...(options?.json ? { root: toRootOutput(resolvedRoot) } : {}),
171
244
  });
172
245
  }
173
246
  catch (error) {
174
247
  failWithError(error, {
175
248
  enabled: options?.json,
176
- payload: options?.specs ? { specs: [], root: null } : { changes: [], root: null },
249
+ payload: failurePayload(),
177
250
  fallbackCode: 'list_error',
178
251
  });
179
252
  }
@@ -219,7 +292,11 @@ program
219
292
  await validateCommand.execute(itemName, options);
220
293
  }
221
294
  catch (error) {
222
- failWithError(error, { enabled: options?.json, fallbackCode: 'validate_error' });
295
+ failWithError(error, {
296
+ enabled: options?.json,
297
+ payload: { ...VALIDATE_FAILURE_PAYLOAD, root: null },
298
+ fallbackCode: 'validate_error',
299
+ });
223
300
  }
224
301
  });
225
302
  program
@@ -245,7 +322,11 @@ program
245
322
  await showCommand.execute(itemName, userSuppliedOptions(options, command));
246
323
  }
247
324
  catch (error) {
248
- failWithError(error, { enabled: options?.json, fallbackCode: 'show_error' });
325
+ failWithError(error, {
326
+ enabled: options?.json,
327
+ payload: SHOW_FAILURE_PAYLOAD,
328
+ fallbackCode: 'show_error',
329
+ });
249
330
  }
250
331
  });
251
332
  // ═══════════════════════════════════════════════════════════
@@ -265,9 +346,7 @@ program
265
346
  catch (error) {
266
347
  failWithError(error, {
267
348
  enabled: options.json,
268
- // The batch null-shape; the single-change failure shape is
269
- // pre-existing contract and stays payload-free.
270
- payload: options.all ? BATCH_STATUS_FAILURE_PAYLOAD : undefined,
349
+ payload: options.all ? BATCH_STATUS_FAILURE_PAYLOAD : STATUS_FAILURE_PAYLOAD,
271
350
  fallbackCode: 'change_error',
272
351
  });
273
352
  }
@@ -280,7 +359,7 @@ program
280
359
  .option('--json', 'Output as JSON')
281
360
  .action(async (artifactId, options) => {
282
361
  try {
283
- if (artifactId === 'apply') {
362
+ if (artifactId === APPLY_ARGUMENT) {
284
363
  await applyInstructionsCommand(options);
285
364
  }
286
365
  else {
@@ -290,7 +369,7 @@ program
290
369
  catch (error) {
291
370
  failWithError(error, {
292
371
  enabled: options.json,
293
- payload: { instructions: null },
372
+ payload: INSTRUCTIONS_FAILURE_PAYLOAD,
294
373
  fallbackCode: 'change_error',
295
374
  });
296
375
  }
@@ -305,7 +384,7 @@ program
305
384
  await templatesCommand(options);
306
385
  }
307
386
  catch (error) {
308
- failWithError(error, { enabled: options.json, payload: { templates: null }, fallbackCode: 'templates_error' });
387
+ failWithError(error, { enabled: options.json, payload: TEMPLATES_FAILURE_PAYLOAD, fallbackCode: 'templates_error' });
309
388
  }
310
389
  });
311
390
  program
@@ -317,7 +396,7 @@ program
317
396
  await schemasCommand(options);
318
397
  }
319
398
  catch (error) {
320
- failWithError(error, { enabled: options.json, payload: { schemas: null }, fallbackCode: 'schemas_error' });
399
+ failWithError(error, { enabled: options.json, payload: SCHEMAS_FAILURE_PAYLOAD, fallbackCode: 'schemas_error' });
321
400
  }
322
401
  });
323
402
  const newCmd = program.command('new').description('Create new items');
@@ -443,12 +522,91 @@ program
443
522
  registerConfigCommand(program);
444
523
  registerDecisionCommand(program);
445
524
  registerMetricsCommand(program);
525
+ /**
526
+ * The `--json` null-shapes, in one table.
527
+ *
528
+ * Kept together rather than beside each command so the contract rule — a
529
+ * failure payload mirrors its success payload, with the data keys nulled — can
530
+ * be read and checked in one place.
531
+ *
532
+ * Every entry is the same object the command's own catch block passes to
533
+ * `failWithError`, never a literal repeated here. Commander can reject a run
534
+ * before the action body exists to catch it, so each command has two failure
535
+ * layers; when this table held its own copies they drifted, and `instructions`,
536
+ * `templates`, `new change` and `decision new` ended up emitting an envelope
537
+ * with `root` from one layer and without it from the other.
538
+ */
539
+ function commandAt(...names) {
540
+ let current = program;
541
+ for (const name of names) {
542
+ const next = current.commands.find((c) => c.name() === name);
543
+ if (!next)
544
+ throw new Error(`No such command to register a JSON failure shape for: ${names.join(' ')}`);
545
+ current = next;
546
+ }
547
+ return current;
548
+ }
549
+ withJsonFailureShape(commandAt('init'), () => INIT_FAILURE_PAYLOAD);
550
+ withJsonFailureShape(commandAt('update'), () => UPDATE_FAILURE_PAYLOAD);
551
+ withJsonFailureShape(commandAt('rules'), () => RULES_FAILURE_PAYLOAD);
552
+ withJsonFailureShape(commandAt('migrate'), () => MIGRATE_FAILURE_PAYLOAD);
553
+ withJsonFailureShape(commandAt('list'), (argv) => argv.includes('--specs') ? { specs: [], root: null } : { changes: [], root: null });
554
+ withJsonFailureShape(commandAt('archive'), () => ({ archive: null, root: null }));
555
+ withJsonFailureShape(commandAt('validate'), () => ({ ...VALIDATE_FAILURE_PAYLOAD, root: null }));
556
+ withJsonFailureShape(commandAt('show'), () => SHOW_FAILURE_PAYLOAD);
557
+ withJsonFailureShape(commandAt('status'), (argv) => argv.includes('--all') ? BATCH_STATUS_FAILURE_PAYLOAD : STATUS_FAILURE_PAYLOAD);
558
+ withJsonFailureShape(commandAt('instructions'), () => INSTRUCTIONS_FAILURE_PAYLOAD);
559
+ withJsonFailureShape(commandAt('templates'), () => TEMPLATES_FAILURE_PAYLOAD);
560
+ withJsonFailureShape(commandAt('schemas'), () => SCHEMAS_FAILURE_PAYLOAD);
561
+ withJsonFailureShape(commandAt('new', 'change'), () => NEW_CHANGE_FAILURE_PAYLOAD);
562
+ withJsonFailureShape(commandAt('decision', 'new'), () => DECISION_NEW_FAILURE_PAYLOAD);
563
+ withJsonFailureShape(commandAt('decision', 'list'), () => DECISION_LIST_FAILURE_PAYLOAD);
446
564
  export { program };
565
+ /**
566
+ * The command's full invocation path, not just its leaf name.
567
+ *
568
+ * `command.name()` is the leaf, which is correct for every top-level command
569
+ * because the two coincide — and wrong for the nested ones. `new change` used to
570
+ * name its parent's leaf alone, which is not a command at all: commander printed
571
+ * the top-level help and exited 0, so the advice looked answered. `decision new`
572
+ * named the change-creation group instead, which is a real command and answers a
573
+ * different question. Both sent the reader somewhere wrong without saying so.
574
+ */
575
+ function commandPath(command) {
576
+ const names = [];
577
+ // Stops at the program itself, whose name is the binary and is already in the
578
+ // template literal around this call.
579
+ for (let node = command; node?.parent; node = node.parent) {
580
+ names.unshift(node.name());
581
+ }
582
+ return names.join(' ');
583
+ }
447
584
  export function runCli(argv = process.argv) {
585
+ throwOnCommanderExit(program);
448
586
  try {
449
587
  program.parse(argv);
450
588
  }
451
589
  catch (error) {
590
+ if (error instanceof CommanderError) {
591
+ // `--help` and `--version` come through here too, having already printed
592
+ // what they were asked for. Only a non-zero exit is a failure.
593
+ if (error.exitCode === 0)
594
+ return;
595
+ const wantsJson = argv.includes('--json');
596
+ if (wantsJson) {
597
+ const command = error.tospecCommand;
598
+ const shape = command ? jsonFailureShapes.get(command) : undefined;
599
+ emitFailureStatus(shape ? shape(argv) : {}, {
600
+ severity: 'error',
601
+ code: normalizeCommanderCode(error.code),
602
+ message: error.message,
603
+ fix: `See tospec ${commandPath(command)} --help`.replace(/\s+/g, ' ').trim(),
604
+ });
605
+ return;
606
+ }
607
+ process.exitCode = error.exitCode || 1;
608
+ return;
609
+ }
452
610
  // The one abort mechanism for places that cannot stop the run by returning
453
611
  // (commander's preAction hooks). Setting the exit code and returning lets
454
612
  // stdout/stderr flush normally, which `process.exit` does not guarantee —