Day 7、Day 8 讓 AI 對一份剛寫好的規格提問 —— 一個用 Plan Mode 列假設,一個用 /grill-me 追問到底。那份規格是新的,空白處還沒有人填過。今天換一個真的有歷史的:一個上線多年的校園 App,需求寫在客戶給的 Word 檔裡,功能是我當年手工做的。
問題很具體:同一份需求,手工做的時候我填掉了哪些空白?如果讓 OpenSpec 來拆,它會逼我回答哪些我當年沒回答的問題? 前者得從程式碼的行為反推回來;後者今天跑一次,就知道哪些空白會被問出來。
這個工具我四月寫過一篇(延伸閱讀),流程是 propose → apply → archive。那篇之後它已經跳到 1.13.0(本文用這版;9/23 已發布 1.13.2),指令名字沒變,內容變了不少 —— 這是這個系列反覆碰到的事,不再展開。
客戶的 SPEC 是一個 24.6 MB 的 .docx,圖多字少。先用 markitdown 轉成 md:
uvx --from 'markitdown[docx]' markitdown SPEC.docx > spec.md # 58,117 字
從裡面挑一段有業務邏輯、跟 UI 改版無關的:「特殊權限(代課老師功能)」,原文 2,862 字。大意是:帳號原本的功能權限在 module 欄位;Setup API 另外回一個 special_module,可以附加「某功能 × 某些班級」的權限,讓代課老師或行政人員操作不是自己班的功能;班級編號 0 代表全部班級;App 要記住使用者最後選的班級,下一個功能開啟時預設帶入,沒有權限就帶第一個班。
這一段當年我讀完就開始寫程式了。今天把原文原封不動丟給 /opsx:propose,只加一句:「原文有模糊之處請先問我,不要自己假設。」
先說清楚誰在做什麼:讀 repo、提問題的是 Claude;OpenSpec 給的是一條「先 propose、再 apply」的路,和 proposal / spec / design / tasks 這幾份產出的格式。
它沒有直接拆。它先把 repo 翻了一遍,回來第一句是:「這個功能在現有程式碼裡已經有大半實作」—— 然後列了一張表:SPEC 的每一條對到哪個檔、哪幾行、狀態是「已有」「部分」還是「沒有」。校車點名那條是「沒有」,0 的處理是「部分」。
接著五個問題。我把它們按「為什麼原文答不了」分類,最後一欄是我給的答案:
| # | 它問的 | 原文的問題 | 我怎麼答 |
|---|---|---|---|
| Q1 | 這個 change 是修現有落差、重做、還是只補文件? | 範圍 | 以 SPEC 為準重新規劃整個功能 |
| Q2 | 文字寫 class_id,兩個 JSON 範例都寫 class,程式碼解的是 class_id —— 後端到底回哪個? |
文件自己打架 | 兩個都收;都有時取聯集、去掉重複 |
| Q3 | 找不到記住的班級時「帶第一個」—— 第一個是 special_module.class 的順序還是 school_class 的?記住的班級要跨 App 重啟嗎?程式碼現在是先帶老師自己的班 |
文件沒寫 | 記住的班級存 UserDefaults、跨重啟;不可用時先帶老師自己的班 |
| Q4 | 校車點名要不要納入?對應的 special_module.name 是哪個? |
範圍 | 納入,對應 RollCall |
| Q5 | 0 = 全部班級,包不包含老師自己的班? |
文件沒寫 | 包含 |
五題都要我來決定:兩題是範圍,一題是文件自己打架,兩題是原文根本沒寫。而沒寫的那兩題,程式碼裡有 —— Q3 程式碼的答案是「先帶老師自己的班、存 UserDefaults」,Q5 程式碼的答案是「包含」。那些答案是我當年寫程式的時候填的,沒有人審過,也沒有寫回文件。 Day 7 說過,規格的洞不是被問出來,就是被做的人自己填掉 —— 今天有了實物,填的人是當年的我。
Q2 最有意思。我回答它的時候才想起來:當年是 iOS 或 Android 其中一邊卡到 class 這個關鍵字,後端才又加了一個 class_id。所以「一個欄位兩個名字」是有原因的 —— 但原因只在我腦子裡,SPEC 沒記,程式碼只解其中一個。Day 17 那個「以為寫下來了」的故事,換個形狀又出現一次。
我答完五題,它沒有建,又問了兩題:
Q6:校車點名的 API 是用 bus_id 取名單,根本沒有 sc_id 參數 —— 所以 special_module 裡校車點名的班級清單,是只用來開首頁按鈕、還是要加班級選單並改 API?
Q7:找不到記住的班級、而且 teach_sc_id 也不可用(行政人員沒有這個欄位;或老師的班不在該功能的權限清單裡)—— 帶什麼?
Q6 是讀了 BusRollCallRequest 才問得出來的,原文和我的第一輪回答都沒碰到;Q7 是我第一輪的回答留下的洞 —— 我說「帶老師自己的班」,它問「沒有的時候呢」。兩題我都選了最保守的:Q6 只開按鈕 —— 另一個選項是加班級選單,但那要後端改 API,不在這次範圍;Q7 退而帶清單第一個。
不過「第一個」是誰,Q3 其實問過,我只答了記住的班級和老師的班,沒答清單怎麼排。spec 也只寫「可用班級清單的第一個」。後來是實作替我決定的:附加班級照 special_module 裡的順序(0 則照 school_class),老師自己的班排最後。它問了,我漏答,空白又被實作填掉一次 —— 規模小很多,但跟當年是同一件事。
七題、兩輪追問,前後三次 propose,共 $1.85。第三次它才建 change。
openspec validate --strict 通過的 change 長這樣:
| 檔 | 內容 |
|---|---|
proposal.md |
Why、What Changes、三個 capability、影響到哪些檔 |
specs/…/module-access/spec.md |
功能權限 = module ∪ special_module.name;校車點名只開按鈕 |
specs/…/class-scope/spec.md |
雙 key、0 = 全部、可用班級 = 附加班級 ∪ 自己班、右上角切換 |
specs/…/current-class-memory/spec.md |
全 App 共用、UserDefaults、登出清除;遞補順序 |
design.md |
新增一個純邏輯的 SpecialPermissionResolver 集中三條規則;明列哪些舊碼沿用、哪些改寫 |
tasks.md |
6 組 18 項,每一項後面跟著「驗證」:哪個測試、涵蓋哪幾個 scenario |
引一段 spec 的樣子 —— 遞補規則那條,正是 Q3 和 Q7 兩題的答案變成的東西:
### Requirement: 開啟功能時的預設班級遞補規則
開啟任一具班級範圍的功能時,系統 SHALL 依下列順序決定預設班級,取第一個成立者:
1. 記住的目前班級,且該班級在此功能的可用班級清單內。
2. 帳號 teach_sc_id 對應的班級,且該班級在此功能的可用班級清單內。
3. 此功能可用班級清單的第一個班級。
4. 可用班級清單為空時,不預選班級。
#### Scenario: 記住的班級與老師自己的班級都不可用
- WHEN 記住的班級為 C 班,老師 teach_sc_id 為 A 班,該功能可用班級只有 B 班、D 班
- THEN 預設帶入 B 班,且目前班級更新為 B 班
原文那句「如果該功能沒有該班級的權限,則自動帶入該功能有的權限的第一個班級」,變成四段有順序的規則加五個 scenario。當年我讀那句話寫出來的程式碼,對應的是四段裡的第二段 —— 而且是硬寫的。
這一條可以一路追下去:原文一句模糊的話 → Q3 的答案 → Q7 對這個答案的追問 → 四段遞補規則 → 五個 scenario → tasks 第 2 組 → Resolver 裡的 test_resolveDefault_rememberedAndTeacherUnavailable_fallsBackToFirst,就是上面引的那個 scenario。
建 change 的時候,它順便標了三個「從程式碼觀察到、你沒問過但已寫進計畫」的點。我逐行核過:
| 它說的 | 核的結果 |
|---|---|
DBManager.checkModuleCanUse 用 name?.contains(key) 做子字串比對,"Call" 會匹配 "ClassCall" |
屬實,但埋著沒踩到:現在用到的 key 彼此不互為子字串。哪天加一個新功能名剛好是另一個的字尾,它就醒了 |
AppDelegate 的推播導向只看 module,不看 special_module |
屬實:只靠附加權限拿到功能的帳號(SPEC 例 2 的行政人員),收到推播點進去,guard 直接 return,什麼都不會發生 |
setupRightMenuAction 在該功能沒有 special_module 時,把目前班級強制覆寫回 teach_sc_id |
屬實,跟 SPEC 2.3「記住最後選擇」直接衝突:在 A 功能選了 B 班,開一個沒有附加權限的功能,班級就被改回自己的班 |
三個都不是它「跑出來」的,是它讀 SPEC 和讀程式碼之後對不上的地方,而且不是同一種問題:第一個是潛在的 bug,第二個是規格要求了、流程沒做到的行為缺口,第三個是兩條規則互相衝突。當年我三個都沒看到 —— 有 Word 檔,但沒有一份可以逐條拿來對程式碼的結構化規格。
規劃完不做,等於沒驗。我讓它 /opsx:apply 只做第 2 組 —— SpecialPermissionResolver 和它的測試,純邏輯、不碰 UI、不碰 API:
Executed 26 tests, with 0 failures (0 unexpected) in 0.016 seconds
** TEST SUCCEEDED **
tasks 第 2 組(Resolver)對應的 17 個 scenario 每個一個測試,另外 9 個是子字串誤判、同功能重複、nil 輸入這類邊界,合計 26;其餘 16 個 scenario 屬於 JSON 解碼、推播、畫面與持久化,在沒做的那幾組。既有程式碼零改動,只在 pbxproj 登錄兩個新檔。apply 這一步 14 輪(Claude Code 的來回次數)、$1.26。它踩到一個小坑:xcodebuild 用 name=iPhone 16 Pro 被拒,因為本機同名模擬器有好幾個 OS 版本,改用 device id 才過 —— 這種事它自己解掉了,沒來問我。
還有一個它自己做的決定,主動提醒我:Resolver 不認識首頁的功能清單,所以未知的 name 會原樣回傳,交給首頁那層過濾。這跟 spec 相容,但它知道這是一個可以被質疑的選擇,所以標出來。會標出自己假設的 agent,比不標的可信。
不是程式碼寫得好不好 —— 手工版跑了好幾年,OpenSpec 這版只寫了一組。差在兩件事:
第一,空白處由誰填。 同一份原文,當年填空白的是我,填在程式碼裡,沒有審、沒有留痕;今天填空白的還是我,但它先問了七次,答案不是留在對話裡,而是進了 proposal、spec 和 scenario,tasks 裡每一項都指回某個 scenario。class 和 class_id 為什麼有兩個、現在兩個都收,寫在 proposal 和 spec 裡,不在我腦子裡。但 Q3 那個漏答也說明了另一半:工具把空白問出來,不代表空白就消失;人沒回答,實作還是會替你決定。 規格的用處不是讓它問很多題,是讓決定在寫 code 之前留下來。
第二,對不上的地方有沒有人看見。 三個洞在程式碼裡待了很久,這次把規格和舊碼攤開來逐條對,才浮出來。Day 7、8 是拿 AI 對一份新規格提問;今天是三份東西放上同一張桌:客戶原文寫的是「應該是什麼」,當年的程式碼記的是「實際做成什麼」,今天的 spec 是「現在決定要是什麼」。有了第三份可以逐條對照的規格,前兩份對不上的地方才看得見。「拿一份去對另一份」這個動作,跟 Day 7 拿計畫對規格、Day 22 要拿 schema 對 AI 版是同一個。
工具沒有變聰明,它只是堅持先問、再寫、再對,而人在趕工的時候很容易略過。
新專案、規格剛寫好,一份 .md 加上讓 AI 問(Day 7、8)就夠了 —— 一個人三十天的新專案不用 OpenSpec;但一個有歷史的功能要重做,值得。 判準不是專案大小,是有沒有「當年填掉的空白」要重新回答 —— 有的話,七個問題只花 $1.85。三個洞是另一回事:這次把規格重新結構化,再拿去對舊碼,它們才浮出來。
這一篇留下的心法:
有歷史的功能要重做,先讓工具把當年填掉的空白問出來 —— 規格的用處,是讓決定在寫 code 之前留下來。對不上的地方不是難找,是沒有一份可以逐條對照的規格;有了它,舊碼和原文的落差才看得見。
明天:開工。規格早就寫好了 —— 讓 spec-kit 排這十天的工,再跟我排的對一次。
openspec init --tools claude --language zh-TW
uvx --from 'markitdown[docx]' markitdown