OpenSpec
一個 OpenSpec 改動就是四個檔案組成的目錄,在寫任何程式碼之前完成,說清楚你要做什麼、為什麼做。代理寫出來,你批准,然後照著它做事。它是工作流在 Propose 步驟產出的東西,也是 Retro 日後讀回去的紀錄。
重點是那個中斷點。已經寫了 400 行程式碼的代理會為這些程式碼辯護;手上只有一頁提案的代理會痛快接受修改。你會在它誤解任務的時候就發現,代價是一句話,而不是一個下午。
目錄
openspec/changes/2026-09-20-saved-searches/
├── .openspec.yaml
├── proposal.md
├── specs/
│ └── saved-searches/
│ └── spec.md
└── tasks.md
目錄名是日期加一個 slug,因此按時間排序,兩個改動也不會撞名。一次改動的所有東西都在一個地方,封存就是一次搬移。
.openspec.yaml
四行中繼資料:name、created、status,以及在多專案儲存庫中它作用於哪個專案。
name: saved-searches
created: 2026-09-20
status: approved
project: app
status 就是閘門:在你表態之前是 proposed,表態之後才是 approved。代理看到 proposed 的改動,就代表還沒獲准寫程式碼。
proposal.md
論證本身,分為幾節。
| 小節 | 該寫什麼 |
|---|---|
| Why | 問題,帶證據。來自程式庫或線上的數字,不是形容詞。 |
| Goals / Non-Goals | 這次改動做什麼,以及明確不做什麼,讓範圍蔓延有個撞牆的地方。 |
| Decisions | 每個真實的取捨、被放棄的方案,以及選它的理由。半年後你會回來讀這一節。 |
| Impact | 新建和修改的檔案,以及儲存庫之外的東西:規則、基礎設施、其他服務。 |
| Risks | 可能出什麼問題,以及什麼能兜住。 |
值得較真的是 Why。「搜尋很慢」不是證據。「每次查詢都把五個集合的全部文件拉進瀏覽器,約 500 次讀取、1 到 3 秒」才是,而且它同時告訴審查者什麼算成功。
specs/<capability>/spec.md
需求,一條行為一條,用祈使句。
# Saved searches
## Requirement: save and manage
A logged-in user on a result page SHALL be able to save the current query and
filters (max 10 per user); a logged-out user SHALL be sent to login and back.
## Requirement: daily digest
A scheduled function at 08:00 Europe/Berlin SHALL, for every saved search,
fetch new ads since `lastNotifiedAt`, and when there is at least one, email the
owner a digest, then set `lastNotifiedAt`. A failed send SHALL leave
`lastNotifiedAt` untouched so the next run retries without duplicates.
SHALL 不是儀式感。它逼你寫出可驗證的句子:你可以指著跑起來的系統說「是」或「否」。「應當優雅地處理錯誤」沒辦法檢查,所以永遠不會被檢查。
一個能力一個目錄。牽涉三個能力的改動就有三個 spec 檔案,而這些檔案正是行為變化時後續改動要修改的對象。
tasks.md
任務組,不是檔案清單。三到八組,每組可獨立驗證,最後一組永遠是驗證。
## 1. Engine [ ]
- Files: `src/lib/server/search.ts`.
- Index, query parsing, scoring, suggestions.
- Acceptance: spec § ranked results.
## 2. Verify [ ]
- Type-check, curl matrix, browser run with real clicks, review, prod check.
按單元而不是按檔案分組,才讓清單可審查。「改六個檔案」什麼也沒說;「先引擎、再頁面、最後驗證」說清了這件事的形狀,而且每一組只有真正跑通了才能打勾。
閘門
任何牽涉兩個以上檔案、改動公開介面、改動 spec 或新增行為的改動,都需要先有一份獲准的 OpenSpec 才能寫程式碼。豁免只有四條,而且是字面意義上的:
- 單一檔案裡的錯字修正
- 只改註解或文件字串
- 你直接指定的設定值(「把逾時改成 30」)
- 已獲准且仍在進行中的改動的後續修補
「微不足道」「顯而易見」「很小」「就改一點點」不在這份清單上,而這四句話恰恰是代理重寫你的驗證機制之前會說的。拿不準就提案:多寫一份用不上的提案,代價是一頁紙;該寫而沒寫,代價是重來一遍。
生命週期
- Propose. 代理寫完四個檔案就停下。不寫程式碼。
- Approve. 你讀、提修改、或者說繼續。這個時候改最便宜。
- Apply. 代理按順序做任務組,做完一組勾一組。
- Review.
/coograph-review拿結果對照 spec,而不是對照 diff,範圍蔓延和被悄悄丟掉的需求就是這樣抓出來的。 - Archive. 目錄移到
openspec/changes/archive/,成為當初決定了什麼的紀錄。
需求會中途變。變了就先改 spec,再繼續照新版本做。程式碼和自己的 spec 對不上的改動,沒人審查得了。
封存為什麼重要
封存的改動不是檔案櫃,而是為什麼這件事唯一留得住的紀錄,它比參與其中的每個人和每個代理都活得久。
它也是資料。Retro 會讀封存,報告每次改動有幾個任務、多少改動帶著沒勾完的任務就封存了、審查多久才發現一次值得寫進修復小節的問題,以及哪些路徑反覆出現。一個專案裡三分之一的封存改動都帶著沒勾完的任務,那是工作流出了問題,單看任何一次審查都看不出來。
一個真實的例子
某個線上分類資訊站的搜尋重寫,就是一個改動目錄。提案的 Why 寫著:用戶端搜尋每次查詢都要拉取五個集合的全部文件。Goals 把範圍釘在排序、查詢理解、建議和零結果處理上,Non-Goals 排除了外部搜尋服務——否則那場對話必然往那邊跑。Decisions 記下了 60 秒的索引快取,以及結果為什麼在伺服器端算。Spec 定了五條需求,其中一條是所有既有篩選必須照常運作,正是這條在審查時抓出了一個被丟掉的排序項。Tasks 依次是引擎、頁面、建議、驗證。
這次改動一天內上線。提案花了二十分鐘,而它正是審查能用「是」或「否」結束、而不是變成一場爭論的原因。
它住在哪裡
寫和執行這些東西的技能是 coograph-propose、coograph-apply、coograph-review 和 coograph-archive,在 Claude Code 裡是 /coograph-* 指令,在其他支援的工具裡是對應的代理。格式就是你儲存庫裡的純 Markdown 和 YAML,沒有任何一處綁定廠商。一個改動目錄,在任何特定代理不再是寫它的那一個之後,依然讀得懂。