@seanmars/tospec 0.26.0-beta → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -2
- package/README.md +3 -1
- package/assets/how/page.mjs +235 -0
- package/assets/how/skeleton.html +790 -0
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +8 -0
- package/dist/commands/config.js.map +1 -1
- package/dist/core/shared/skill-generation.d.ts +7 -1
- package/dist/core/shared/skill-generation.d.ts.map +1 -1
- package/dist/core/shared/skill-generation.js +3 -1
- package/dist/core/shared/skill-generation.js.map +1 -1
- package/dist/core/templates/fragments/debugging.d.ts +32 -0
- package/dist/core/templates/fragments/debugging.d.ts.map +1 -0
- package/dist/core/templates/fragments/debugging.js +32 -0
- package/dist/core/templates/fragments/debugging.js.map +1 -0
- package/dist/core/templates/fragments/verify.d.ts +2 -2
- package/dist/core/templates/fragments/verify.d.ts.map +1 -1
- package/dist/core/templates/fragments/verify.js +5 -3
- package/dist/core/templates/fragments/verify.js.map +1 -1
- package/dist/core/templates/skill-templates.d.ts +2 -0
- package/dist/core/templates/skill-templates.d.ts.map +1 -1
- package/dist/core/templates/skill-templates.js +2 -0
- package/dist/core/templates/skill-templates.js.map +1 -1
- package/dist/core/templates/workflows/apply.d.ts.map +1 -1
- package/dist/core/templates/workflows/apply.js +16 -2
- package/dist/core/templates/workflows/apply.js.map +1 -1
- package/dist/core/templates/workflows/explore.js +3 -3
- package/dist/core/templates/workflows/grill.d.ts.map +1 -1
- package/dist/core/templates/workflows/grill.js +3 -1
- package/dist/core/templates/workflows/grill.js.map +1 -1
- package/dist/core/templates/workflows/how.d.ts +3 -0
- package/dist/core/templates/workflows/how.d.ts.map +1 -0
- package/dist/core/templates/workflows/how.js +283 -0
- package/dist/core/templates/workflows/how.js.map +1 -0
- package/dist/core/templates/workflows/issue.d.ts.map +1 -1
- package/dist/core/templates/workflows/issue.js +13 -0
- package/dist/core/templates/workflows/issue.js.map +1 -1
- package/dist/core/templates/workflows/restate-your-goal.d.ts +17 -0
- package/dist/core/templates/workflows/restate-your-goal.d.ts.map +1 -0
- package/dist/core/templates/workflows/restate-your-goal.js +45 -0
- package/dist/core/templates/workflows/restate-your-goal.js.map +1 -0
- package/dist/core/templates/workflows/update.d.ts.map +1 -1
- package/dist/core/templates/workflows/update.js +1 -0
- package/dist/core/templates/workflows/update.js.map +1 -1
- package/package.json +2 -2
- package/schemas/issue/schema.yaml +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -6,10 +6,57 @@
|
|
|
6
6
|
|
|
7
7
|
tospec 是一套 spec-driven development CLI: 以 schema 定義文件結構與工作流程, 進度由檔案系統狀態推算, 開發方法則封裝於 Skill 之中, 使 AI 工具 (Claude Code / Codex) 得以循序完成需求釐清、規格撰寫、設計、任務拆解、實作到歸檔的完整流程.
|
|
8
8
|
|
|
9
|
-
## [0.
|
|
9
|
+
## [0.27.0] - 2026-10-23
|
|
10
|
+
|
|
11
|
+
本版本有兩個主題. 第一個是「以執行結果作為證據」: issue 與 apply 在多處把推理當成證據, 例如走讀程式碼就勾選項目, 修正失敗就再修一次, 效能數字不經檢驗就拿來排序假設; 現在每一處都要求一次執行, 或明確標示為未驗證. 第二個是「讓使用者看得到 agent 的理解」: 新的 `tospec-restate-your-goal` 讓 explore 與 grill 以 agent 自己的話重述結論給使用者確認, 新的 `tospec-how` 以含互動圖表的離線 HTML 頁面解說程式碼. 另外, apply 的 review 與 update 會讓 agent 重抄檔案或指令已有的內容 (整份 diff、整份 artifact); 現在改為傳遞路徑與指令, 或只修改有變動的段落. 多數內容改編自 pstack (MIT) 的 skill 與原則. 本版沒有 breaking change.
|
|
12
|
+
|
|
13
|
+
### 新增
|
|
14
|
+
|
|
15
|
+
- **`tospec-how` 以離線 HTML 頁面解說程式碼怎麼運作**: 在對話中回答「X 怎麼運作」時, 架構、呼叫順序與生命週期只能用文字描述, mermaid 在終端機也只顯示原始碼. 根本原因是這些內容本質上是圖, 文字是最難讀的表示方式. `tospec-how` 沿用 pstack `how` 的方法: 判斷問題大小, 把複雜的問題拆成 2-4 個角度交給平行的唯讀 explorer, 再整合成資深工程師需要的深度. explorer 的 prompt 只有問題、角度與 `EXPLORER.md` 的路徑, 由 explorer 自己讀取這份說明, 不由 agent 為每個 explorer 重抄一次. 它先讀相關的 capability spec 與 `accepted` ADR, 解說以實際執行的程式碼為準, 不一致處列在 Gotchas. 產出是寫到 `<系統 temp>/tospec-how/` 的單一 HTML 檔, 不改動 working tree; 對話中只回 2-4 句摘要與檔案路徑.
|
|
16
|
+
- 頁面 (`assets/how/skeleton.html`) 的 Content-Security-Policy 是 `default-src 'none'`, 任何外部資源都無法載入, 所以一定可以離線觀看. 圖表是 JSON spec, 由頁面依格位自行排版: `flow` 涵蓋架構圖 (以 group 標示層級或 process)、流程圖 (decision 節點) 與狀態圖 (start / end 節點), `sequence` 涵蓋呼叫順序 (reply、self-call、階段分隔). renderer 在節點外框裁切連線, 連線經過其他節點時自動繞開, 標籤放在不壓到節點與其他標籤的位置. 讀者可以逐步瀏覽 (按鈕或方向鍵), 點選節點或連線查看說明與可複製的 `path:line`, 也可以放大圖表.
|
|
17
|
+
- 沒有採用 mermaid: 內嵌它會讓每個頁面與套件各增加約 3 MB. 也沒有讓 agent 直接寫 SVG 座標: 節點一多就會重疊. grid spec 讓 agent 只決定位置與連線; spec 表達不了的圖仍可手寫 `<svg>`.
|
|
18
|
+
- agent 只寫一個 content 檔 (`lang` / `title` / `question` / `meta` 四行 header、一行 `---`, 再接 sections), 由 skill 附帶的 `page.mjs` (只用 Node 內建模組) 以 `build` 包進 skeleton 並檢查. 沒有讓 agent 讀 skeleton 再寫出填好的副本: 每頁都要重新輸出約 38 KB 的樣式與程式, 約 1 萬個 output token, 抄錯一個字元圖表就壞掉, 而檢查不比對程式. 也沒有把 skeleton 複製到 temp 再讓 agent 用 Edit 填入: Edit 要求先 Read 整份檔案, 副本也仍然可以被改壞. 由 build 組裝, skeleton 必定原封不動地進入頁面, 不需要另外比對. header 的值由 build 跳脫; 填入時用 split/join, 因為 `String.replace` 會把內容中的 `$&` 當成取代語法.
|
|
19
|
+
- spec 寫錯時只有瀏覽器顯示紅框, agent 看不到. 所以 build 在回報前做相同的檢查: 不存在的 id、兩個節點同一格、超過三行的標籤、無效的 JSON、外部資源, 以及不在 temp 資料夾的檔案. content 檔缺少 header 的值、貼進整份頁面、檔名不是 `.content.html` 或不在 temp 資料夾時, build 在寫出任何頁面前就失敗. `page.mjs dir` 輸出寫入的資料夾, agent 不需要依 OS 猜 temp 路徑.
|
|
20
|
+
- 與 pstack 原版相同, 只能手動呼叫 (`disable-model-invocation`). 「X 怎麼運作」是日常問題, 自動觸發會把兩句話就能回答的問題變成 subagent、HTML 頁面與三次權限提示. 執行 `node` 與寫入 temp 時會跳出權限提示: `Write` 規則無法限定路徑, 而不限路徑的 `Write` 對唯讀 skill 範圍太大.
|
|
21
|
+
- **`tospec-restate-your-goal`, explore 與 grill 以它確認結論**: explore 與 grill 都以「重述共同理解並取得使用者確認」結束, 但兩者各自描述要重述哪些項目, 也沒有規定怎麼重述. agent 照抄使用者的字句也能通過確認, 誤解因此沒有被發現, 因為只有改寫成自己的話才會暴露誤解. 現在這一步集中在一個 skill: 以對話為主要依據 (對話只談一個 change 時, 以 `status --change` 讀它的規劃文件), 以自己的話寫出 Goal / Problem / Decided / Out of scope / Assumptions, 重述目標而不是使用者指定的解法, 然後停下等使用者確認或修正. explore 的步驟 4 與 grill 的 Converge 以 sub-step 執行它, grill 另外要求涵蓋它的發現 (成立與被推翻的 claim、明確化的 assumption、已回答與刻意保留的問題). 使用者修正時兩者都回到訪談, 只有確認後的重述才能進入 ADR 與 handoff.
|
|
22
|
+
- 與 pstack 原版 (`in-your-own-words`) 不同, 它可以被 agent 呼叫, 因為 explore 與 grill 要把它當成 sub-step. 新的 test 斷言被當作 sub-step 的 skill 不能設定為只能手動呼叫, 否則 agent 看不到它, sub-step 會靜默失效.
|
|
23
|
+
- 它的 prose 刻意不寫出 explore 或 grill 的名稱: `findWorkflowReferenceGaps` 把每個 `tospec-<id>` 視為相依, 寫出名稱會讓只安裝 restate 的 profile 收到缺少 skill 的警告. 相依的方向相反: custom profile 選了 explore 或 grill 但沒選 `restate-your-goal` 時, init、update 與 config 會回報缺少它.
|
|
24
|
+
- **apply 在做到一半的項目下留下 resume 行**: checkbox 只記錄完成或未完成. 一次執行在項目中途停下 (暫停、使用者中止、session 結束) 時, 沒有任何紀錄說明已經寫了什麼, 下一次執行會重做已完成的部分, 或相信找到的程式碼而略過未完成的部分. 現在 apply 步驟 5 規定停下的執行保留未勾選的 checkbox, 並在下方加一行巢狀 bullet `- Resume: <已完成>; <剩下的>; <下一步>`. 接手的執行先對照程式碼確認這些內容, 再從那裡繼續, 勾選項目時刪除這一行, 所以完成的 change 不會把過時的筆記帶進 archive.
|
|
25
|
+
- 沒有另開 resume 檔: tracked file 是 apply 最先讀取的檔案, 也是 tospec 唯一的進度紀錄, 另一個檔案需要自己的查找與清理.
|
|
26
|
+
- 格式不影響進度計數, 也不觸發 validate 的 checkbox 誤寫檢查. skill 禁止以 `[` 開頭, 因為 `CHECKBOX_LIKE` 會標記它. test 從產生的 skill 取出格式, 交給 `parseTasksFromContent` 與 `findTaskMarkerIssues` 驗證, 之後格式若改成計數或 validate 會誤讀的樣子, 這個 test 會失敗.
|
|
27
|
+
|
|
28
|
+
### 變更
|
|
29
|
+
|
|
30
|
+
- **issue 的診斷從歷史找假設, 先檢驗前提, 並決定修正的位置**: 診斷有三個 agent 會掉進去的缺口.
|
|
31
|
+
- 假設只來自閱讀程式碼. regression 的 git 歷史, 以及「重新啟動才出現」的症狀所依賴的持久化狀態, 通常是找到原因最快的途徑, 但沒有步驟指向那裡. 步驟 4 現在指名 `git log -S` / `git log -L` (唯讀, 遵守共用的 `GIT_COMMAND_RULE`), 並說明 commit 只是候選, 不是原因. `git bisect run` 需要使用者同意才能執行, 因為它會在診斷自己的 debug 修改之下切換 checkout.
|
|
32
|
+
- 步驟 5 在第一個確認的假設停下, 但沒有說明 probe 一直失敗時怎麼做, 每次失敗只換來另一個建立在同一個未檢驗前提上的 probe. 現在同一個前提失敗兩次後, agent 先寫下前提並檢驗它, 再做第三個 probe; 所有假設都被推翻時回到步驟 4, 所以 `task.md` 不會建立在未確認的原因上.
|
|
33
|
+
- 「修根本原因, 不修症狀」仍讓「在拋出例外的那一行加 guard」看起來像根本修正, 因為 guard 就在症狀出現的函式裡. Fix Plan 現在詢問觸發的狀態是否合法, 由答案決定修正的位置, 並寫出修正安全的依據: 以實際執行證明, 或標為未證明.
|
|
34
|
+
- 這些規則放在新的 `fragments/debugging.ts`, 供 apply 的 issue 分支共用. `task.md` template 刻意不改: stub detection 以「與目前 template 完全相同」判定 stub, 修改 template 會讓既有專案中未編輯的舊 stub 被判定為 done. 新的 Fix Plan 內容改由 skill 步驟與 schema 的 instruction 傳達.
|
|
35
|
+
- **apply 以執行結果勾選項目, 檢查 caller 以外的讀取端, 並在修正一直失敗時質疑診斷**: apply 在三個地方把推理當成證據.
|
|
36
|
+
- 步驟 5 要求確認「描述的行為確實可行」, 但沒有定義什麼算數. 走讀程式碼不論對錯都令人信服, 項目因此被勾選. 現在「確實可行」是指一次執行: test、feedback loop, 或驅動實際程式碼且結果錯誤時會失敗的命令. 沒有東西可以執行的項目, 在報告中標為「未以執行驗證」. 沒有採用擋下這種項目, 因為有些行為只有使用者的環境能觸及.
|
|
37
|
+
- 根本原因的 guardrail 檢查每個 caller, 但它停在 grep 找得到的地方. 修改會離開函式的資料 (持久化的檔案或欄位、wire format、config key、feature flag), 會破壞 symbol 搜尋找不到的讀取端. 所以共用 guardrail 現在要求找出這份資料其他的讀取端與寫入端. 它放在 Guardrails 而不是 issue 分支, 因為 sdd 的 slice 修改這類資料的頻率和 fix 一樣.
|
|
38
|
+
- 兩個 issue 分支中, 修正一直無法通過 `task.md` 的 feedback loop 時, apply 的回應是更多修正, 但這些失敗正是 Root Cause 有誤的證據. 現在兩個分支共用上一項的 `PREMISE_CHECK`, 把 Root Cause 當作最常見的前提. 測試推翻它時 apply 停下並回報: apply 不改寫診斷, 修訂 `task.md` 是 `tospec-update` 的工作. 在拋出例外的那一行加 guard、retry 或吞掉錯誤, 只在 Fix Plan 判定觸發狀態合法時允許. 分支指回那個判定, 不重複描述檢驗方式, 所以這條規則只由 issue 維護.
|
|
39
|
+
- **效能問題的 feedback loop 在修正前後都檢驗數字**: 效能 bug 的 feedback loop 是一個與門檻比較的數字, issue 卻把它當成 failing test. 數字會以 red test 不會的方式出錯: 它可能計入失敗或略過的工作, 來自 debug build, 或雜訊比差距還大. 從這種數字排序的假設, 會讓整個診斷去追一個不存在的瓶頸.
|
|
40
|
+
- issue 步驟 3 新增一段: 依數字排序假設之前, 先確認工作執行時沒有錯誤、環境與 production 一致、數字可重現 (至少 5 次, 記錄中位數與範圍), 以及限制它的因素. 限制因素從另一次執行的 profile 讀取, 因為 profiler 會拖慢它觀察的那次執行. 執行次數、範圍與限制因素和 loop 一起記錄, schema 的 task instruction 也寫入相同的內容.
|
|
41
|
+
- 兩個 apply issue 分支以量測 loop 的方式確認修正: 修正前後各執行相同次數並交替進行, 差距在執行間的範圍內視為沒有差異. 節省的時間以被修改部分原本的耗時為上限, 用來抓出來自「修正略過了工作」的改善.
|
|
42
|
+
- 採用條件段落, 沒有另開效能 schema 或 workflow: 診斷的步驟相同, 只有讓 loop 可信的條件不同, 第二個 schema 會重複其餘的內容. sdd 分支不包含這一段.
|
|
43
|
+
- **apply 的 two-axis review 傳給 subagent 指引, 不貼內容**: `VERIFY.md` 要求把 diff、delta spec、`design.md` 與 main-spec block 貼進兩個 axis 的 prompt. 很大的 diff 因此在 output 中重抄兩次, 抄的過程中刪節或抄錯的部分, 會改變審查的對象, 而兩個 axis 都不知道. 現在 prompt 只帶 change 名稱、`VERIFY.md` 的路徑、要依哪個 axis 的說明, 以及步驟 1 的 diff 範圍 (diff 指令與 base, 或 fallback 的檔案清單); 其餘內容由 axis 以說明中的指令自己讀取. 兩個 axis 原本拿到的每一項輸入 (`context` 與 `rules`、REMOVED 與 RENAMED 的 main-spec block、issue 的 delta) 仍然都有指名, 只是改為要讀取的內容. 共用同一個 diff 指令, 也比各自收到一份手抄的副本更能保證兩個 axis 看到同一份 diff.
|
|
44
|
+
- **update 只修改有變動的段落**: update 的步驟 5 只說確認後「寫入」修訂. 為了改一段文字, agent 可能整份重寫一個大的 spec, 除了花費整份的 output, 抄寫時的任何差錯都會改到使用者在修訂中沒看過的文字. 現在確認的修訂以編輯有變動的段落套用, 其餘保持原樣; 只有修訂取代大部分內容時才整份重寫.
|
|
45
|
+
- propose、issue、decision 與 sync 也檢查過, 沒有修改: template 只是 agent 本來就要填寫的短標題骨架, decision 檔由 CLI 建立, sync 本來就只在原位置改寫單一 Requirement block, 而 delta 語法要求這個 block 完整.
|
|
46
|
+
|
|
47
|
+
### 其他
|
|
48
|
+
|
|
49
|
+
- **skill**: 本版的 skill 變更都要重跑 `tospec update` 才會寫進專案. core profile 在 update 時會加入 `tospec-restate-your-goal` 與 `tospec-how`; custom profile 需要以 `tospec config set workflows` 自行加入.
|
|
50
|
+
- **開發用的相依套件** (不在 npm 套件內): vitest 升到 5.0.5, 並重新解析 vite (8.3.5) 與 source-map-js (1.2.2), 處理 Dependabot alert #6 至 #13 (`@vitest/mocker`、vite、source-map-js). 沒有把 vite 固定在 7.3.7: 那需要宣告一個 repo 從未 import 的 devDependency, 只為了壓住 vitest 已經支援的 peer. Alert #5 (braces) 是已撤回且沒有修正版本的 advisory, 不需處理.
|
|
51
|
+
- **repo 內的文件** (不在 npm 套件內): flow 頁面改用 `DESIGN.md` 的 mono 字型, note 不再使用 side-tab 樣式的左框線, `sync-report.md` 只在寫入它的步驟標為 code. skills guide 與 flow 頁面同步 issue 與 apply 的變更; 這兩個頁面尚未收錄 `tospec-restate-your-goal` 與 `tospec-how`.
|
|
52
|
+
- **測試**: 163 個檔案, 2964 個 test.
|
|
53
|
+
|
|
54
|
+
## [0.26.0] - 2026-10-23
|
|
10
55
|
|
|
11
56
|
本版本的主題是姊妹 change (sister change): 同時進行中、碰到同一個 capability 或 requirement 的其他 change. 先在一個不是 git repository 的專案以 0.25.0 跑完整流程 (explore、decision、propose、apply、archive 前再開第二個 change、含 sync 的 archive、issue), 之後在同一個專案做了七輪端對端測試, 每一輪驗證上一輪的修正, 最後一輪沒有發現需要修正的問題. 找到的缺陷大多出自同一個根本原因: validate 只拿一個 change 對照 `tospec/specs/`, archive 的結果卻取決於其他尚未 archive 的 change. 因此 validate、`instructions apply` 與 archive 現在讀取姊妹 change, 並指出要先 archive 哪一個. 接著把每一種由 archive 順序或 requirement 名稱造成的 finding 都改為結構化欄位 (`code`、`blockedBy`、`suggestedHeader`、`archiveRefusals`), agent 不再比對 message 文字. 其餘是 skill 與 workflow 的修正: ADR 的連結與狀態、`allowed-tools` 涵蓋子步驟、issue 的 planning boundary、非 git 專案的 diff 範圍、regression test 的規則. breaking change 集中在「變更」.
|
|
12
57
|
|
|
58
|
+
本版先在 2026-10-18 以 0.26.0-beta 發布到 npm 的 `beta` tag, 正式版的內容與它相同. 0.25.0 沒有發布到 npm, 所以 npm 的 `latest` 從 0.24.0 直接升到 0.26.0; 從 0.24.0 升級時也要讀 0.25.0 的條目, 它有多項 breaking change.
|
|
59
|
+
|
|
13
60
|
### 新增
|
|
14
61
|
|
|
15
62
|
- **validate 與 `instructions apply` 讀取姊妹 change, 指出要先 archive 哪一個**: change A 以 ADDED 建立 capability 且尚未 archive, change B 對同一個 capability 寫 MODIFIED. validate B 只回報 `Archive would refuse this delta: ... target spec does not exist`, `instructions apply --change B` 則回報 `ready` 且沒有任何警告, agent 實作完才在 archive 失敗. 根本原因有兩個: validate 只讀這個 change 與 `tospec/specs/`, 不知道 A 存在; `instructions apply` 只依檔案位置判定 readiness, 從不執行 merge 的前置條件. 現在 merge 以型別 `MissingTargetSpecError` 拋出「target spec does not exist」, validate 依型別辨識而不比對文字, 經由新的 `listSisterChangeDeltas` 讀取其他進行中的 change, 並在 message 加上 `Archive 'A' first: it adds this capability.` (多個時為 `Archive 'a', 'b' first: each adds this capability.`).
|
|
@@ -762,7 +809,8 @@ Dashboard 進化為可背景常駐、多專案並存的服務, 並補上 TDD 導
|
|
|
762
809
|
|
|
763
810
|
- 新增 `prepack` script 與 npm publish 的準備設定, 完備套件發行流程.
|
|
764
811
|
|
|
765
|
-
[0.
|
|
812
|
+
[0.27.0]: https://github.com/seanmars/tospec/compare/v0.26.0...v0.27.0
|
|
813
|
+
[0.26.0]: https://github.com/seanmars/tospec/compare/v0.25.0...v0.26.0
|
|
766
814
|
[0.25.0]: https://github.com/seanmars/tospec/compare/v0.24.0...v0.25.0
|
|
767
815
|
[0.24.0]: https://github.com/seanmars/tospec/compare/v0.23.0...v0.24.0
|
|
768
816
|
[0.23.0]: https://github.com/seanmars/tospec/compare/v0.22.0...v0.23.0
|
package/README.md
CHANGED
|
@@ -62,12 +62,14 @@ tospec archive add-dark-mode
|
|
|
62
62
|
|
|
63
63
|
## Skill
|
|
64
64
|
|
|
65
|
-
`tospec init` 會裝
|
|
65
|
+
`tospec init` 會裝 12 個 workflow Skill, 在 Claude Code 中以 `/tospec-<name>` 呼叫.
|
|
66
66
|
|
|
67
67
|
| Skill | 作用 |
|
|
68
68
|
|-------|------|
|
|
69
69
|
| `tospec-explore` | 用訪談收斂還很模糊的需求或問題 |
|
|
70
70
|
| `tospec-grill` | 對計畫, 決策, 設計做壓力測試 |
|
|
71
|
+
| `tospec-restate-your-goal` | 用自己的話重述使用者的目標與問題, 讓使用者確認. `explore` 和 `grill` 結尾也會用它確認結論 |
|
|
72
|
+
| `tospec-how` | 解說程式碼怎麼運作, 產出含架構圖, 流程圖, 狀態圖的離線 HTML, 放在系統 temp 資料夾 (只能手動呼叫) |
|
|
71
73
|
| `tospec-propose` | 把討論清楚的需求整理成完整的 sdd change |
|
|
72
74
|
| `tospec-issue` | 診斷 bug 到根因, 產出 issue change |
|
|
73
75
|
| `tospec-decision` | 把架構決策記成永久 ADR |
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Shipped beside the how skill's SKILL.md and run by the agent, so it uses
|
|
2
|
+
// nothing but Node built-ins.
|
|
3
|
+
//
|
|
4
|
+
// node page.mjs dir print the folder pages are written to
|
|
5
|
+
// node page.mjs build <x.content.html> assemble <x>.html from skeleton.html and check it
|
|
6
|
+
//
|
|
7
|
+
// The agent writes only a content file: a few header lines, then the sections.
|
|
8
|
+
// Copying the skeleton (~38 KB of stylesheet and script) itself made every page
|
|
9
|
+
// cost ~10k output tokens, and one character mistyped on the way broke the
|
|
10
|
+
// diagrams with nothing to notice it. Here the skeleton reaches the page
|
|
11
|
+
// byte for byte, and the header values are escaped on the way in.
|
|
12
|
+
//
|
|
13
|
+
// The page draws its diagrams in the browser, where a broken spec shows up as
|
|
14
|
+
// a red box the agent never sees, so the build repeats the page's spec
|
|
15
|
+
// validation (and the offline and placement rules) where the agent can read it.
|
|
16
|
+
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const OUTPUT_DIR = path.join(os.tmpdir(), 'tospec-how');
|
|
22
|
+
const SKELETON = fileURLToPath(new URL('./skeleton.html', import.meta.url));
|
|
23
|
+
const CONTENT_SUFFIX = '.content.html';
|
|
24
|
+
|
|
25
|
+
const CJK = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/;
|
|
26
|
+
// The page wraps a node label at about 21 units a line, CJK counting double.
|
|
27
|
+
const MAX_LABEL_UNITS = 21 * 3;
|
|
28
|
+
const PLACEHOLDERS = ['{{LANG}}', '{{TITLE}}', '{{QUESTION}}', '{{META}}', '<!-- CONTENT -->'];
|
|
29
|
+
const HEADER_KEYS = ['lang', 'title', 'question', 'meta'];
|
|
30
|
+
const REQUIRED_KEYS = ['lang', 'title', 'question'];
|
|
31
|
+
|
|
32
|
+
function labelUnits(text) {
|
|
33
|
+
let n = 0;
|
|
34
|
+
for (const ch of String(text)) n += CJK.test(ch) ? 2 : 1;
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function escapeHtml(text) {
|
|
39
|
+
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function flowErrors(spec) {
|
|
43
|
+
const errors = [];
|
|
44
|
+
const ids = new Set();
|
|
45
|
+
const cells = new Map();
|
|
46
|
+
for (const [i, n] of (spec.nodes ?? []).entries()) {
|
|
47
|
+
if (!n.id) errors.push('nodes[' + i + ']: missing "id"');
|
|
48
|
+
if (ids.has(n.id)) errors.push('node "' + n.id + '" is defined twice');
|
|
49
|
+
ids.add(n.id);
|
|
50
|
+
if (typeof n.col !== 'number' || typeof n.row !== 'number') {
|
|
51
|
+
errors.push('node "' + n.id + '": "col" and "row" must be numbers');
|
|
52
|
+
}
|
|
53
|
+
const cell = n.col + ',' + n.row;
|
|
54
|
+
if (cells.has(cell)) {
|
|
55
|
+
errors.push('nodes "' + cells.get(cell) + '" and "' + n.id + '" share cell col ' + n.col + ', row ' + n.row);
|
|
56
|
+
}
|
|
57
|
+
cells.set(cell, n.id);
|
|
58
|
+
if (labelUnits(n.label ?? n.id) > MAX_LABEL_UNITS) {
|
|
59
|
+
errors.push('node "' + n.id + '": label is longer than three lines; move the detail into "note"');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (ids.size === 0) errors.push('no nodes');
|
|
63
|
+
for (const [i, e] of (spec.edges ?? []).entries()) {
|
|
64
|
+
if (!ids.has(e.from)) errors.push('edges[' + i + ']: unknown "from" node "' + e.from + '"');
|
|
65
|
+
if (!ids.has(e.to)) errors.push('edges[' + i + ']: unknown "to" node "' + e.to + '"');
|
|
66
|
+
}
|
|
67
|
+
for (const [i, step] of (spec.steps ?? []).entries()) {
|
|
68
|
+
for (const key of step.show ?? []) {
|
|
69
|
+
for (const id of String(key).split('->')) {
|
|
70
|
+
if (!ids.has(id)) errors.push('steps[' + i + ']: unknown node "' + id + '" in "' + key + '"');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return errors;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sequenceErrors(spec) {
|
|
78
|
+
const errors = [];
|
|
79
|
+
const ids = new Set();
|
|
80
|
+
for (const [i, p] of (spec.participants ?? []).entries()) {
|
|
81
|
+
if (!p.id) errors.push('participants[' + i + ']: missing "id"');
|
|
82
|
+
if (ids.has(p.id)) errors.push('participant "' + p.id + '" is defined twice');
|
|
83
|
+
ids.add(p.id);
|
|
84
|
+
}
|
|
85
|
+
if (ids.size === 0) errors.push('no participants');
|
|
86
|
+
for (const [i, m] of (spec.messages ?? []).entries()) {
|
|
87
|
+
if (m.divider !== undefined) continue;
|
|
88
|
+
if (!ids.has(m.from)) errors.push('messages[' + i + ']: unknown "from" participant "' + m.from + '"');
|
|
89
|
+
if (!ids.has(m.to)) errors.push('messages[' + i + ']: unknown "to" participant "' + m.to + '"');
|
|
90
|
+
}
|
|
91
|
+
return errors;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isInside(file, dir) {
|
|
95
|
+
const real = (p) => {
|
|
96
|
+
try {
|
|
97
|
+
// native expands Windows 8.3 names, which os.tmpdir() can return.
|
|
98
|
+
return realpathSync.native(p);
|
|
99
|
+
} catch {
|
|
100
|
+
return path.resolve(p);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const rel = path.relative(real(dir), real(file));
|
|
104
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Splits a content file into its header values and its body. The header is
|
|
109
|
+
* `key: value` lines up to the first line that is exactly `---`.
|
|
110
|
+
*/
|
|
111
|
+
export function parseContent(text) {
|
|
112
|
+
const problems = [];
|
|
113
|
+
const lines = text.split(/\r?\n/);
|
|
114
|
+
const end = lines.findIndex((line) => line.trim() === '---');
|
|
115
|
+
if (end === -1) {
|
|
116
|
+
return { header: {}, body: '', problems: ['no "---" line: start the content file with lang / title / question / meta lines, then "---", then the sections'] };
|
|
117
|
+
}
|
|
118
|
+
const header = {};
|
|
119
|
+
for (const line of lines.slice(0, end)) {
|
|
120
|
+
if (!line.trim()) continue;
|
|
121
|
+
const match = line.match(/^\s*([a-z]+)\s*:\s*(.*)$/i);
|
|
122
|
+
if (!match || !HEADER_KEYS.includes(match[1].toLowerCase())) {
|
|
123
|
+
problems.push('unknown header line "' + line.trim() + '" (expected ' + HEADER_KEYS.join(', ') + ')');
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
header[match[1].toLowerCase()] = match[2].trim();
|
|
127
|
+
}
|
|
128
|
+
for (const key of REQUIRED_KEYS) {
|
|
129
|
+
if (!header[key]) problems.push('header "' + key + '" is missing or empty');
|
|
130
|
+
}
|
|
131
|
+
const body = lines.slice(end + 1).join('\n');
|
|
132
|
+
// The skeleton supplies the document shell; a second one nested inside it
|
|
133
|
+
// means the agent copied the skeleton after all.
|
|
134
|
+
if (/<(?:!doctype|html|head|body|style)\b/i.test(body)) {
|
|
135
|
+
problems.push('the content holds a whole page (<html>, <head>, <body> or <style>); write only the <section> elements');
|
|
136
|
+
}
|
|
137
|
+
if (!body.trim()) problems.push('no sections after "---"');
|
|
138
|
+
return { header, body, problems };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The page: the skeleton byte for byte, with the placeholders filled. */
|
|
142
|
+
export function assemble(skeleton, header, body) {
|
|
143
|
+
const values = {
|
|
144
|
+
'{{LANG}}': escapeHtml(header.lang ?? ''),
|
|
145
|
+
'{{TITLE}}': escapeHtml(header.title ?? ''),
|
|
146
|
+
'{{QUESTION}}': escapeHtml(header.question ?? ''),
|
|
147
|
+
'{{META}}': escapeHtml(header.meta ?? ''),
|
|
148
|
+
'<!-- CONTENT -->': body,
|
|
149
|
+
};
|
|
150
|
+
// split/join, not String.replace: a body holding "$&" or "$1" must come
|
|
151
|
+
// through as written.
|
|
152
|
+
let page = skeleton;
|
|
153
|
+
for (const [placeholder, value] of Object.entries(values)) page = page.split(placeholder).join(value);
|
|
154
|
+
return page;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function checkPage(file, html) {
|
|
158
|
+
const problems = [];
|
|
159
|
+
if (!isInside(file, os.tmpdir())) {
|
|
160
|
+
problems.push('the page is not under the temp directory ' + os.tmpdir() + '; write it to ' + OUTPUT_DIR);
|
|
161
|
+
}
|
|
162
|
+
for (const placeholder of PLACEHOLDERS) {
|
|
163
|
+
if (html.includes(placeholder)) problems.push('placeholder left in the page: ' + placeholder);
|
|
164
|
+
}
|
|
165
|
+
if (!html.includes('Content-Security-Policy')) {
|
|
166
|
+
problems.push("the skeleton's Content-Security-Policy meta tag is missing");
|
|
167
|
+
}
|
|
168
|
+
if (/<(?:script|img|link|iframe|source|video|audio)\b[^>]*\b(?:src|href)\s*=\s*["']?(?:[a-z]+:)?\/\//i.test(html)
|
|
169
|
+
|| /url\(\s*["']?(?:https?:)?\/\//i.test(html)) {
|
|
170
|
+
problems.push('the page loads something from outside the file; inline it or drop it');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let index = 0;
|
|
174
|
+
for (const match of html.matchAll(/<script type="application\/json">([\s\S]*?)<\/script>/g)) {
|
|
175
|
+
index += 1;
|
|
176
|
+
let spec;
|
|
177
|
+
try {
|
|
178
|
+
spec = JSON.parse(match[1]);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
problems.push('diagram ' + index + ': invalid JSON (' + err.message + ')');
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (spec.type !== undefined && spec.type !== 'flow' && spec.type !== 'sequence') {
|
|
184
|
+
problems.push('diagram ' + index + ': unknown "type" "' + spec.type + '" (use "flow" or "sequence")');
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const errors = spec.type === 'sequence' ? sequenceErrors(spec) : flowErrors(spec);
|
|
188
|
+
for (const e of errors) problems.push('diagram ' + index + ': ' + e);
|
|
189
|
+
}
|
|
190
|
+
return { problems, diagrams: index };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function build(contentFile) {
|
|
194
|
+
if (!contentFile.endsWith(CONTENT_SUFFIX)) {
|
|
195
|
+
console.log('FAIL name the content file <topic>' + CONTENT_SUFFIX);
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
if (!isInside(contentFile, os.tmpdir())) {
|
|
199
|
+
console.log('FAIL the content file is not under the temp directory ' + os.tmpdir() + '; write it to ' + OUTPUT_DIR);
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
const { header, body, problems: contentProblems } = parseContent(readFileSync(contentFile, 'utf8'));
|
|
203
|
+
if (contentProblems.length) {
|
|
204
|
+
for (const p of contentProblems) console.log('FAIL ' + p);
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
const page = path.join(path.dirname(contentFile), path.basename(contentFile, CONTENT_SUFFIX) + '.html');
|
|
208
|
+
const html = assemble(readFileSync(SKELETON, 'utf8'), header, body);
|
|
209
|
+
writeFileSync(page, html);
|
|
210
|
+
const { problems, diagrams } = checkPage(page, html);
|
|
211
|
+
if (problems.length) {
|
|
212
|
+
for (const p of problems) console.log('FAIL ' + p);
|
|
213
|
+
console.log('Fix the content file and build again; do not edit ' + page);
|
|
214
|
+
return 1;
|
|
215
|
+
}
|
|
216
|
+
console.log('ok: ' + diagrams + ' diagram spec(s)');
|
|
217
|
+
console.log(path.resolve(page));
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function main(argv) {
|
|
222
|
+
const [command, arg] = argv;
|
|
223
|
+
if (command === 'dir') {
|
|
224
|
+
mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
225
|
+
console.log(OUTPUT_DIR);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
if (command === 'build' && arg) return build(arg);
|
|
229
|
+
console.error('usage: node page.mjs dir | node page.mjs build <topic>' + CONTENT_SUFFIX);
|
|
230
|
+
return 2;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
234
|
+
process.exitCode = main(process.argv.slice(2));
|
|
235
|
+
}
|