在讓 AI 動手之前,有一份檔案必須先存在:它管的是做完長什麼樣。這件事沒有任何一行 code 看得出來,所以最後能當裁判的只能是測試。這份檔案不先有,動工之後就容易開始亂。
但寫完不算完。需求裡一定有沒寫清楚的地方,而你自己讀不出來 —— 你讀的時候在確認「我有沒有寫」,不是在找「這樣寫夠不夠」。後半就用 Plan Mode 把那些地方問出來:它問、我答、它再問,直到它說「沒有了」。
練習題是一個刻意壓到最簡的圖書館借閱 API:一本書就是一冊,借走了別人就不能借,還了才能借;借期 14 天,逾期只記天數。四條 endpoint,一個人一個下午寫得完 —— 但「同一本書不能同時借給兩個人」這條,靠讀 code 看不出來。
三句話都不需要工具,一個 .md 檔就能做到。而這篇後半要讓 AI 來問,所以還多一條:先寫的這份必須完全沒有 AI 參與過 —— 不然你分不出哪些洞是它問出來的、哪些是它自己填進去的。
需求一開始都是模糊的。規格最容易失敗的寫法,就是把模糊的需求原樣抄進去:
讀者可以借書。
這句話沒有裁判資格 —— 什麼叫可以?兩個人同時借同一本算不算?還了之後多久算可以再借?
改成可判定的版本:
| Feature | 做完 = |
|---|---|
| 查書 | GET 回書名與狀態,且狀態跟借閱紀錄一致(有未歸還紀錄 ⇔ on_loan),測試證明 |
| 借書 ⭐ | 併發測試通過(N 個人同時借同一本,恰好一人成功),且 due_at 是借出當下算好的快照(借出後改借期政策,既有紀錄不變,測試證明) |
| 還書 | 只能還自己未歸還的;還完立刻可借;已歸還再還 → 4xx;overdue_days 用 due_at 快照算 |
| 我的借閱 | 一次 GET 回未歸還、歷史兩段,且 SQL 查詢次數有算過 |
差別在於:上面那句要靠人判斷,下面這四行每一條都能寫成測試。
第二列那個 ⭐ 是整個練習的重心。兩個條件,沒有一個能只靠讀 code 檢查 —— 那是我挑這個題目的原因,也是後半拿去給 Plan Mode 問的那個 feature。
規格通常只寫「要做什麼」。我這次的第二份檔案,就是「明確不做什麼」的非目標清單,因為 AI 很容易順手多做 —— 你叫它做借書,它會順手加預約排隊、逾期通知、罰款、熱門排行,而且不會先問你,直接做出來,再在說明裡寫「順便加了」。每一項單獨看都合理,加起來就是做不完。
這不是我的假設。寫規格之前我先試了一次(那時題目還留著排隊):同一個題目、空目錄、沒有任何規則,兩種 prompt。
| prompt | 它交回 |
|---|---|
| 「需求只有三張表:書目、副本、借閱。請直接給我 SQL」 | 3 張表、4 個索引,51 行 |
| 講需求(借 14 天、排隊、保留 48 小時),不講表數 | 5 張表(多了 members、holds)、8 個索引、四段我沒問的交易流程 SQL、cron 用的索引,204 行 |
第二列多出來的每一項都對,但沒有一項在這次的範圍內;第一列至少說明了一件事:範圍講死,它就比較不容易越界。 非目標清單就是把範圍講死的那份檔案。(兩份原文在 devlog/prior-experiments/,一個字沒改。)
所以在寫「要做什麼」之前,我先把「不做什麼」列出來 —— 每一條都是它會順手加、而我不要的東西:
| # | 不做 | 為什麼 |
|---|---|---|
| 1 | 預約與排隊 | 書被借走就是不能借。排隊、保留期限、逾時釋放是第二階段的題目 |
| 2 | 罰款 / 金流 | 只記逾期天數,不收錢。金流是另一個系統 |
| 3 | 主動通知(到期提醒、Email / 推播) | 讀者自己 GET /api/users/:id/borrows 看;通知要靠外部服務與排程 |
| 4 | 多館 / 跨館借還 | 只做單一館,沒有館藏地與運送狀態 |
| 5 | 書目詮釋資料(ISBN、MARC、多作者、分類號) | 書只有 id、title、status |
| 6 | 身分驗證與權限 | request 帶 userId 就當合法;登入、token、館員角色都不在範圍 |
寫這張表,我給自己三條規矩:
第一,每一條都要有理由。 沒有理由的禁令,三週後你會想不起來為什麼禁,然後就破例了。
第二,理由寫「範圍」,不寫「難」。 「串接支付很花時間」可以被反駁 —— 找個 SDK 就不花時間了;「金流是另一個系統」不能被反駁,因為那是在講邊界,不是在講難度。
第三,刻意留下的醜要另外列,並且承認它是醜。 沒有 rate limit、錯誤訊息只有 status code、逾期不排除假日 —— 這三項不是非目標(以後該做),是現在刻意不做。跟非目標分開寫,是為了不騙自己:非目標是「不屬於這個題目」,醜是「屬於,但我先欠著」。
規格先行有一個很容易被忽略的紀律:規格必須早於第一行 code,而且早於你去問 AI 的第一句話。
理由跟後半直接有關 —— 如果我先看過 AI 怎麼規劃這個功能,再回頭寫我的規格,那份規格就已經被污染了,之後就分不出哪些是你想的、哪些是它替你想的。
所以順序是死的:先自己規劃 → 寫進檔案 → commit 留下紀錄 → 最後才讓 AI 動工。 前三步各擋一件事:自己先想,它的計畫才有東西可對;寫進檔案,規格才不會跟著它的計畫一起漂;commit 留下時間,「我先寫的」才有證據。
這份規格的 commit 是 523b0fa,message 寫著「AI 尚未介入」。Plan Mode 三輪的原文 09da5cb 在它後面,先後順序都查得到。
Plan Mode 的定義很簡單:它讀檔、跑指令探索、寫一份計畫,但不改你的原始碼 —— 你核准計畫之前,edit 一律擋住。進去有三種方式:Shift+Tab 切到狀態列出現 ⏸ plan mode on;或在單一句 prompt 前面加 /plan,只有這一句用規劃模式;或一開始就 claude --permission-mode plan。
官方的建議是反過來講的:「如果你一句話就能描述 diff,跳過計畫。」 改個 typo、加一行 log、改個變數名,直接叫它做。Plan Mode 值得的是三種情況:你不確定該怎麼做、改動會跨很多檔、或你不熟那段 code。今天這個 feature 三個都中 —— 而且還有第四個:你的需求自己有洞。
多數人用 Plan Mode 的方式是讀完它的計畫、按下同意。而計畫最後那一節「待確認」,往往就跳過去了。我這次反過來用:只回答它問的,不看它的計畫。 它問一輪,我答一輪,它據此改計畫、再問。直到它自己說「沒有了」,才算需求釐清完。
標的:借書與還書 —— 四條 endpoint 裡有狀態轉移和併發的那兩條,也是硬規則全部集中的地方。
紀律,四條:
spec.md、沒有 non-goals.md,prompt 就一句:「實作借書與還書:讀者可以借一本可借的書,借期 14 天;還了之後別人才能借;逾期只記天數。」它的問題應該來自需求本身,而不是讀完我的規格後才產生。CLAUDE.md 照常載入。 這是真實情境,不是無菌室 —— 它看得到那五條硬規則。我要驗的不是「AI 在真空中會不會出錯」,而是「規則已經寫給它了,它會不會照做」;前者比較接近無菌實驗,後者才是實際工作裡會遇到的情境。第一輪。 乾淨 clone、拿掉兩份規格檔、rm -rf .git(不然它可能從 git 歷史把規格讀回來)、claude -p "…" --permission-mode plan。210 秒、$0.75,它交出 169 行的計畫 —— 但沒有問題。它第一句就說:「這個 session 沒有 AskUserQuestion / ExitPlanMode 可用,所以我把要問的事都寫成可推翻的假設放在計畫裡。」七條,每條寫它怎麼假設、為什麼:
| 它假設 | 我的規格 | |
|---|---|---|
userId 從 header X-User-Id 取 |
body 帶 {userId, bookId} |
不同 |
逾期天數用 ceil,晚 1 秒算 1 天 |
floor,未滿一天不算 |
不同 |
還書用 POST /loans/:loanId/return |
POST /api/return,以人 + 書找紀錄 |
不同 |
借閱是否歸還由 returned_at IS NULL 推導,不另加欄位 |
沒說 | 相容 |
加 src/db/ 放 SQL |
沒說 | 相容 |
| 不加建書 endpoint,書靠 SQL 塞 | 沒有這條 endpoint | 相容 |
presentation/ 這次不放東西 |
— | 相容 |
這三處「不同」都不是洞 —— 是它沒讀到規格、只能照常識猜的地方,規格翻開就有答案。真正的洞在它沒寫到的:借期 14 天它寫成 code 常數,規格是要存在 loan_policies 表、借出時快照;兩條 GET 它沒做,因為我那句需求只講借還;一人一書、還完立刻可借的測試也沒有。這四件是規格有、計畫沒有 —— 也提醒我:Plan Mode 只會規劃你明確交給它的東西。
第二輪。 --resume 接回同一個 session,七條逐一回答,把那四件規格有、計畫沒寫的補上。108 秒、$0.33,計畫改成 196 行,然後問了一題:「GET /api/users/:id/borrows 要不要帶 overdueDays?」—— 這一題規格真的沒寫。我答帶,第三輪 26 秒、$0.23:「還需要你決定的:沒有了。」
三輪,總共 $1.32。規格因此多了一條決定:列表要帶 overdueDays,未歸還那段用當下的 now 算。其餘六條決定原本就在,只是它沒讀到 —— 而這正是對照組的用處:它猜錯的三處,就是沒有規格的人會直接做錯的三處。
有一件事要老實講:它沒問的也有。兩條 GET 和一人一書那條,它都沒問 —— 是我在回它假設時自己補進去的。它不會問自己沒想到的東西。 補洞靠它問,補漏還是靠前半的規格。
跑下來,有幾個官方文件寫了、但我這次才真的用到的東西:
計畫可以直接改。 互動模式下計畫出來時按 Ctrl+G,它會在你的文字編輯器裡打開那份計畫,改完存檔它照改過的做。所以第二輪如果不想逐條打字回答,也可以直接在計畫裡把那七條假設改掉。
核准不是單純按 y,而是三選一。 「Yes, and use auto mode」核准並切到 auto、「Yes, manually approve edits」核准但每個 edit 都問你、「No, keep planning」留在 Plan Mode 叫它改。我這次等於連按了兩次第三個。
-p 裡它問不了,就改成假設。 互動模式下它會用 AskUserQuestion 停下來問你;-p 沒有這支工具,它就把問題寫成「可推翻的假設」放進計畫。所以非互動跑 Plan Mode 時,計畫裡的假設那一節就是它的問題清單,要逐條回答。
--resume 接得回去。 第一輪是 -p 跑的,第二、三輪用 --resume <session id> 接回同一個 session,計畫檔還在、它問過什麼還記得。非互動也能做多輪。
Plan Mode 的「禁止修改」,在 -p 裡才是硬限制。 文件明講:互動終端機裡如果 bypass 權限可用,Plan Mode「被指示不改」但真的去改不會被擋;-p、SDK、VS Code 的 chat 才強制擋。這次實驗全程用 -p,所以它最後交出的只有計畫,一行 code 都沒動。
需求釐清,官方也有一條路。 best-practices 的「Let Claude interview you」:大功能先讓它用 AskUserQuestion 訪談你,問到沒得問再寫 SPEC.md,然後開新 session 執行。官方甚至直接給了一段可以使用的 prompt:
I want to build [brief description]. Interview me in detail using the AskUserQuestion tool. Ask about technical implementation, UI/UX, edge cases, concerns, and tradeoffs. Don't ask obvious questions, dig into the hard parts I might not have considered. Keep interviewing until we've covered everything, then write a complete spec to SPEC.md.
跟今天做的是同一件事,只是它訪談、我用 Plan Mode 的假設當訪談。明天會用一支專門做這件事的 skill 再問一次。
前半寫了兩份檔案:「做完」的定義要寫成能轉成測試的句子;非目標清單比功能清單重要,而且每一條要寫理由、理由要寫範圍不寫難。兩份都要在問 AI 之前存檔、標日期。
後半讓 Plan Mode 來補洞:不讀它的計畫,只回它的問題。這次它猜錯三處、漏掉四件、問出一個真正的洞 —— 猜錯的是沒有規格會做錯的地方,漏掉的是規格補回來的,問出的那一個才是規格欠的。
Plan Mode 值錢的地方不是它想得比你周全, 是它把你規格裡沒寫清楚的地方,在動手之前就一題一題攤在桌上 —— 而且答完一層,它會問下一層。
這一篇留下的心法:
規格先行不是把需求寫得更漂亮,而是在 AI 進場前,先把「做完」和「不做」寫成能當裁判的檔案。先寫你的,再讓它問。別急著看計畫,只回答它的問題;答到它說「沒有了」,它問出什麼洞,就補進規格。
明天:Plan Mode 問到「沒有了」就停。換一支專門拷問人的 skill,看它是問得更狠,還是問出一樣的洞。
docs/spec.md、docs/non-goals.md;規格 commit 523b0fa