跳到內文
coograph 17

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

四行中繼資料:namecreatedstatus,以及在多專案儲存庫中它作用於哪個專案。

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 才能寫程式碼。豁免只有四條,而且是字面意義上的:

「微不足道」「顯而易見」「很小」「就改一點點」不在這份清單上,而這四句話恰恰是代理重寫你的驗證機制之前會說的。拿不準就提案:多寫一份用不上的提案,代價是一頁紙;該寫而沒寫,代價是重來一遍。

生命週期

  1. Propose. 代理寫完四個檔案就停下。不寫程式碼。
  2. Approve. 你讀、提修改、或者說繼續。這個時候改最便宜。
  3. Apply. 代理按順序做任務組,做完一組勾一組。
  4. Review. /coograph-review 拿結果對照 spec,而不是對照 diff,範圍蔓延和被悄悄丟掉的需求就是這樣抓出來的。
  5. Archive. 目錄移到 openspec/changes/archive/,成為當初決定了什麼的紀錄。

需求會中途變。變了就先改 spec,再繼續照新版本做。程式碼和自己的 spec 對不上的改動,沒人審查得了。

封存為什麼重要

封存的改動不是檔案櫃,而是為什麼這件事唯一留得住的紀錄,它比參與其中的每個人和每個代理都活得久。

它也是資料。Retro 會讀封存,報告每次改動有幾個任務、多少改動帶著沒勾完的任務就封存了、審查多久才發現一次值得寫進修復小節的問題,以及哪些路徑反覆出現。一個專案裡三分之一的封存改動都帶著沒勾完的任務,那是工作流出了問題,單看任何一次審查都看不出來。

一個真實的例子

某個線上分類資訊站的搜尋重寫,就是一個改動目錄。提案的 Why 寫著:用戶端搜尋每次查詢都要拉取五個集合的全部文件。Goals 把範圍釘在排序、查詢理解、建議和零結果處理上,Non-Goals 排除了外部搜尋服務——否則那場對話必然往那邊跑。Decisions 記下了 60 秒的索引快取,以及結果為什麼在伺服器端算。Spec 定了五條需求,其中一條是所有既有篩選必須照常運作,正是這條在審查時抓出了一個被丟掉的排序項。Tasks 依次是引擎、頁面、建議、驗證。

這次改動一天內上線。提案花了二十分鐘,而它正是審查能用「是」或「否」結束、而不是變成一場爭論的原因。

它住在哪裡

寫和執行這些東西的技能是 coograph-proposecoograph-applycoograph-reviewcoograph-archive,在 Claude Code 裡是 /coograph-* 指令,在其他支援的工具裡是對應的代理。格式就是你儲存庫裡的純 Markdown 和 YAML,沒有任何一處綁定廠商。一個改動目錄,在任何特定代理不再是寫它的那一個之後,依然讀得懂。