OpenSpec 是一個為 AI coding assistant 打造的 spec-driven development(SDD)工具。它的野心不是再寫一個「更聰明的 prompt」,而是反過來思考一個問題:當生成內容的主體是不確定的 LLM 時,工程系統該把哪些東西留給程式碼、哪些東西留給模型? OpenSpec 的答案很清楚——用一支 TypeScript CLI 承擔所有「確定性」職責(依賴排序、狀態偵測、格式契約、多工具轉接),把「創造性」職責(寫 proposal、設計 spec、拆 task)留給 AI。讀完這份原始碼,你會學到如何設計一個「給 AI 用的工具」:它的介面契約、狀態管理與 prompt 分工,都和「給人用的工具」有本質差異。
OpenSpec GitHub Repository — 專案原始碼,包含本文解讀的 artifact graph、command adapter、schema 與 prompt 設計實作
專案概覽
OpenSpec 以 npm 套件 @fission-ai/openspec 發布,是一支純 TypeScript(ESM)CLI 工具,核心依賴精簡:commander(命令列框架)、zod(schema 驗證)、yaml、fast-glob、@inquirer/prompts(互動)。它不含任何 LLM 呼叫——這點非常關鍵:OpenSpec 本身「不是 AI」,它是被各種 AI coding 工具(Claude Code、Cursor、Copilot…)當作外部工具呼叫的 CLI。
它的工作流是把一次「變更(change)」拆解成一連串有依賴關係的產物(artifact):proposal.md(為什麼)→ specs/**/*.md(要做什麼)→ design.md(怎麼做)→ tasks.md(實作清單)。AI 依序生成這些檔案,OpenSpec 負責告訴 AI「現在該做哪一步、上一步的產物在哪、這一步的格式長什麼樣」。
架構總覽
原始碼集中在 src/,約 170 個檔案,分層清晰:
| 目錄 | 職責 |
|---|---|
src/cli/ src/commands/ |
CLI 進入點與各子命令(status / instructions / validate / new change…) |
src/core/artifact-graph/ |
核心引擎:artifact 依賴圖、拓撲排序、狀態偵測、指令載入 |
src/core/schemas/ src/core/validation/ |
Zod schema 與 spec 內容驗證 |
src/core/command-generation/ |
把工作流指令轉譯成 28 種 AI 工具各自的檔案格式 |
src/core/parsers/ |
Markdown → 結構化 spec/change 的解析 |
src/core/templates/workflows/ |
各工作流的 prompt 模板(skill / slash command 內容) |
schemas/ |
內建的 spec-driven workflow schema(YAML)與 markdown 模板 |
設計上有一條明顯的分界線:core/ 是純函式與確定性邏輯,templates/workflows/ 是要餵給 LLM 的自然語言指令。這條線就是整個專案哲學的縮影。
核心設計解析
設計一:Artifact 依賴圖 + 拓撲排序,把「工作流」變成資料結構
OpenSpec 沒有把工作流寫死成 if proposal then spec then design 的程式流程,而是把它抽象成一張有向依賴圖:每個 artifact 宣告自己 requires 哪些前置 artifact,圖引擎負責算出建構順序與「現在可以做哪些」。
src/core/artifact-graph/graph.ts — ArtifactGraph 類別,工作流的資料結構化核心
// 用 Kahn's algorithm 算拓撲順序
getBuildOrder(): string[] {
const inDegree = new Map<string, number>();
// ...
// Start with roots (in-degree 0), sorted for determinism
const queue = [...this.artifacts.keys()]
.filter(id => inDegree.get(id) === 0)
.sort();
// ...每次取出節點後,把新變為 ready 的節點 sort 再入列
queue.push(...newlyReady.sort());
}
getNextArtifacts(completed: CompletedSet): string[] {
const ready: string[] = [];
for (const artifact of this.artifacts.values()) {
if (completed.has(artifact.id)) continue;
const allDepsCompleted = artifact.requires.every(req => completed.has(req));
if (allDepsCompleted) ready.push(artifact.id);
}
return ready.sort(); // 排序確保決定性
}
Code language: JavaScript (javascript)值得注意的細節:每個地方都 .sort()。作者刻意在 Kahn’s algorithm 的入列環節排序,讓相同輸入永遠產出相同順序。對一個要餵給 LLM、且需要可測試/可重現的工具而言,決定性(determinism)不是可有可無的——它讓 AI 的行為可預期、讓 snapshot 測試穩定。這是「為 AI 設計工具」時很容易被忽略卻極重要的一課。
設計二:狀態不落地,直接從檔案系統推導
大部分工作流引擎會維護一份 state.json 記錄「哪些步驟做完了」。OpenSpec 反其道而行——它沒有狀態檔,完成與否直接看產物檔案存不存在。
src/core/artifact-graph/state.ts — 從 change 目錄掃描檔案,推導出已完成的 artifact 集合
export function detectCompleted(graph: ArtifactGraph, changeDir: string): CompletedSet {
const completed = new Set<string>();
if (!fs.existsSync(changeDir)) return completed;
for (const artifact of graph.getAllArtifacts()) {
if (isArtifactComplete(artifact.generates, changeDir)) {
completed.add(artifact.id);
}
}
return completed;
}
Code language: JavaScript (javascript)搭配 src/core/artifact-graph/outputs.ts 支援 glob(specs/**/*.md 這種產物用 fast-glob 判斷是否至少存在一個檔案),完成狀態完全由檔案系統這個「單一真相來源」決定。
這個設計的威力在於:檔案系統就是狀態,git 就是版本控制,人類手動改檔案也會被正確反映。沒有狀態同步、沒有 state 損毀、沒有「檔案存在但 state 說沒做」的不一致。對一個「brownfield 友善、要跟人類與 AI 共同編輯」的工具,這種無狀態設計把複雜度降到最低。
設計三:Schema-driven workflow——工作流本身是可插拔的資料
工作流不是寫死在程式裡的,而是一份 YAML schema,用 Zod 定義結構並驗證。
src/core/artifact-graph/types.ts — 用 Zod 定義 artifact schema,並由 z.infer 反推 TypeScript 型別
export const ArtifactSchema = z.object({
id: z.string().min(1, { error: 'Artifact ID is required' }),
generates: z.string().min(1, { error: 'generates field is required' }),
description: z.string(),
template: z.string().min(1, { error: 'template field is required' }),
instruction: z.string().optional(),
requires: z.array(z.string()).default([]),
});
export type Artifact = z.infer<typeof ArtifactSchema>;
Code language: JavaScript (javascript)這裡有兩個值得學的點。其一是 schema 即型別的 single source of truth:用 Zod 宣告一次,執行期驗證與編譯期型別同時得到,杜絕「型別與驗證邏輯不同步」。其二是 schemas/spec-driven/schema.yaml 這份內建 schema 把每個 artifact 該怎麼寫的 instruction 直接內嵌在 YAML 裡(proposal 要有 Why/What Changes/Capabilities…、spec 的 scenario「必須剛好用 4 個 #」)。工作流的規則、順序、產物、給 AI 的指引,全部是資料而非程式碼。
配合 src/core/artifact-graph/resolver.ts 的三層解析順序(project-local → user override → package built-in),使用者可以在專案內、或全域覆寫掉內建工作流,完全不需改動 OpenSpec 原始碼:
// Resolution order:
// 1. Project-local: <projectRoot>/openspec/schemas/<name>/schema.yaml
// 2. User override: ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml
// 3. Package built-in: <package>/schemas/<name>/schema.yaml
Code language: HTML, XML (xml)這是一種標準卻好用的擴展點設計:越靠近使用者、越具體的設定優先級越高(cascade / override 模式),和 CSS、.gitconfig、ESLint config 的思路一脈相承。
設計四:CLI 作為 AI 的工具介面——嚴格的「Agent Contract」
這是 OpenSpec 最獨到的設計。AI agent 不是讀 OpenSpec 的說明文件來使用它,而是呼叫 CLI 並解析 JSON 輸出。因此 OpenSpec 為 --json 模式定義了一份極其嚴謹的機器可讀契約。
docs/agent-contract.md — 逐一對照 src/ 原始碼寫成的 CLI 機器介面契約,是「為 AI 設計 API」的教科書級範例
契約中幾個關鍵約定:
- 一次呼叫 = 一份 JSON 文件:
--json模式下 stdout 只會有一份 2-space pretty-print 的 JSON;所有人類可讀的 prose、spinner、banner 一律走 stderr。這讓 AI 能無腦JSON.parse(stdout),不必過濾雜訊。 - 失敗也有結構:命令失敗時,stdout 仍輸出該命令的 null-shape 加上
status: [diagnostic],並以 exit code 1 結束。診斷訊息用統一的信封格式(severity/code/message/target/fix)。 - 可預期的退場碼:成功(含健康檢查發現)exit 0;
--json失敗 exit 1;使用者取消 exit 130。
在 src/commands/workflow/status.ts 可以看到這套約定如何被落實——連 spinner 都要小心不與 stderr 打架:
// The root resolves (and the store banner prints) before the spinner starts
// so the two do not fight over stderr.
const root = await resolveRootForCommand(options, { json: options.json });
const spinner = options.json ? undefined : ora('Loading change status...').start();
Code language: JavaScript (javascript)openspec status --change X --json 回傳 artifacts(各 artifact 的 done/ready/blocked 狀態)、applyRequires(實作前必須完成哪些)、artifactPaths(各產物的解析路徑);openspec instructions <artifact> --json 回傳該步驟的 template、instruction、dependencies。AI 的每一步都由 CLI 告知,不靠 AI 自己記憶或臆測路徑。 這就是把不確定的 LLM 綁定在確定的軌道上。
設計五:Adapter + Registry,一份指令產出 28 種工具格式
OpenSpec 要支援 Claude Code、Cursor、Copilot、Gemini、Windsurf…數十種 AI 工具,每種工具的 slash command / skill 檔案放在不同路徑、用不同 frontmatter 格式。作者用經典的 Adapter 模式把「內容」與「格式」徹底解耦。
src/core/command-generation/types.ts — 定義 tool-agnostic 的 CommandContent 與 per-tool 的 ToolCommandAdapter 介面
export interface ToolCommandAdapter {
toolId: string;
getFilePath(commandId: string): string; // 每個工具的檔案路徑規則
formatFile(content: CommandContent): string; // 每個工具的 frontmatter 格式
}
Code language: PHP (php)每個工具只需實作這兩個方法,例如 src/core/command-generation/adapters/claude.ts:
export const claudeAdapter: ToolCommandAdapter = {
toolId: 'claude',
getFilePath(commandId) {
return path.join('.claude', 'commands', 'opsx', `${commandId}.md`);
},
formatFile(content) {
return `---\nname: ${escapeYamlValue(content.name)}\n...\n---\n\n${content.body}\n`;
},
};
Code language: JavaScript (javascript)而 src/core/command-generation/registry.ts 用 class 的 static initializer block 在載入時一次註冊所有 adapter:
export class CommandAdapterRegistry {
private static adapters = new Map<string, ToolCommandAdapter>();
static {
CommandAdapterRegistry.register(claudeAdapter);
CommandAdapterRegistry.register(cursorAdapter);
// ...28 個 adapter
}
}
Code language: JavaScript (javascript)generator.ts 只要 adapter.getFilePath() + adapter.formatFile() 就能產出任何工具的檔案,完全不 care 是哪個工具。新增一種 AI 工具 = 新增一個 adapter 檔案 + 在 registry 註冊一行,其餘程式碼零改動——這就是 Open/Closed Principle 的漂亮實踐,也解釋了為何這個專案能快速跟上層出不窮的 AI coding 工具。
設計六:把生成內容的結構「當資料傳」,而非塞進 prompt
在 src/core/artifact-graph/instruction-loader.ts 定義的 ArtifactInstructions 介面,透露了一個細膩的分工哲學。它把要給 AI 的東西分成幾類,而且在型別註解裡明確標示「這是給你當約束用的,不要寫進輸出檔案」:
export interface ArtifactInstructions {
instruction: string | undefined; // schema 對這類 artifact 的指引
context: string | undefined; // 專案背景(constraints for AI, NOT to be included in output)
rules: string[] | undefined; // artifact 專屬規則(constraints for AI, NOT in output)
template: string; // 輸出的結構範本(this IS the output format)
dependencies: DependencyInfo[]; // 要讀來當上下文的已完成產物
unlocks: string[]; // 完成後會解鎖哪些 artifact
}
Code language: JavaScript (javascript)context/rules 是「約束」,template 是「輸出格式」,dependencies 是「上下文來源」——三者角色分明地餵給模型,而不是揉成一大團 prompt。這種結構化的資訊供給,讓 AI 更不容易搞混「該遵守的規則」與「該產出的內容」。
Prompt 設計解讀
OpenSpec 的 prompt 集中在 src/core/templates/workflows/,這些是安裝時會被 command-generation 轉譯成各工具 slash command 的指令內容。以 propose 工作流為例:
src/core/templates/workflows/propose.ts — 一次生成完整 change(proposal + design + spec + tasks)的 skill 指令
這份 prompt 的設計意圖非常清楚:它不告訴 AI「怎麼寫一份好的 proposal」,而是告訴 AI「怎麼跟 openspec CLI 互動來完成工作」。核心步驟被寫成一個明確的迴圈:
1. 若輸入不明確,用 AskUserQuestion 工具問清楚要做什麼(不准擅自開工)
2. openspec new change "<name>" ← 建立骨架
3. openspec status --change X --json ← 取得 applyRequires 與 artifact 狀態
4. 依 dependency order 迴圈:
a. openspec instructions <id> --json ← 取得該步驟 template/instruction/dependencies
讀取已完成的 dependency 檔案當上下文
依 template 寫檔到 resolvedOutputPath
b. 每寫完一個就重跑 status --json 確認,直到 applyRequires 全部 done
5. openspec status --change X ← 顯示最終狀態
Code language: HTML, XML (xml)幾個 prompt 工程上值得借鏡的手法:
- 把工具當狀態來源,而非讓模型記憶:每寫完一步就重新
openspec status --json查一次,明確要求「檢查每個applyRequires的 artifact ID 是否status: "done"」。這避免了 LLM 對「我做到哪了」產生幻覺。 - 強制澄清、禁止臆測:
IMPORTANT: Do NOT proceed without understanding what the user wants to build.用大寫祈使句把「不確定就問」變成硬規則。 - 明確指派工具:指名要用
AskUserQuestion、TodoWrite這些 agent 內建工具,而非泛泛地說「追蹤進度」。 - 分離約束與輸出:prompt 明白告訴 AI「
context和rules是給你的約束,不要複製進輸出檔案;template才是輸出結構」,呼應設計六的型別註解。
整體來看,OpenSpec 的 prompt 哲學是:把 LLM 當成一個需要外部記憶與外部裁決的執行者。凡是可以由 CLI 確定回答的(順序、路徑、完成與否、格式),一律讓 AI 去查 CLI;只有真正需要創造力的部分(內容)才交給模型自由發揮。
設計理念萃取
- 確定性與創造性分工:把工作流拆成「CLI 負責的確定性邏輯(排序/狀態/路徑/格式契約)」與「LLM 負責的創造性生成(內容)」。凡能由程式確定的,就別讓模型猜——這是駕馭 LLM 不確定性的根本策略。
- 狀態即檔案系統:能用「產物是否存在」推導狀態,就不要另外維護 state 檔。單一真相來源省掉了同步、損毀與不一致,還天然支援人類手動編輯與 git 版控。
- 工作流即資料:用 Zod + YAML schema 把流程、依賴、產物、指引全部宣告成資料,配合 project/user/package 三層 override,讓工具無需改碼即可被客製。schema 同時是型別的 single source of truth。
- 為 AI 設計 API 要像設計協定:stdout 只放一份 JSON、雜訊走 stderr、失敗也有結構化 shape、退場碼可預期。把
docs/agent-contract.md這種「逐行對照原始碼的契約文件」當成一等公民來維護。 - Adapter + Registry 應對碎片化生態:面對數十個異質整合目標,用統一介面 + 註冊表把「內容」與「格式」解耦,新增整合只是加一個檔案。這讓專案能低成本地跟上快速演化的外部生態。
延伸思考
如果你正在打造任何「AI agent 會呼叫」的工具或服務,OpenSpec 的思路可以直接借鏡:
- 設計一個
--json契約:讓你的 CLI/API 在給 agent 用時輸出乾淨、可 parse、失敗也有結構的 JSON,並把它文件化成契約。這比讓 agent 讀你的 README 去猜用法可靠太多。 - 把「該做什麼」變成可查詢的狀態端點:與其在 system prompt 塞一大段流程說明,不如提供一個
status端點讓 agent 隨時查「現在該做哪一步、前置條件滿足了嗎」,用外部狀態壓制 LLM 的記憶幻覺。 - 用依賴圖描述多步驟工作流:任何有前後依賴的 pipeline(資料處理、審批流、部署流程)都可以抽象成 artifact graph,用拓撲排序算出可執行步驟,並記得在排序中加入決定性 tie-break。
- 面對碎片化整合,先定義介面再談實作:若你要對接多個外部系統(通知渠道、雲端供應商、AI 工具),先抽出最小 adapter 介面(本例只有兩個方法),用 registry 收斂,讓「新增整合」變成低風險的加法。
Vibe Coding:把觀念變成程式碼
以下是你可以直接給 AI(Claude、Cursor、Copilot…)的 prompt 指令,將本文介紹的設計觀念應用到你的專案中。使用時只需把方括號的部分替換成你自己專案的內容。
為工具設計 --json Agent Contract
場景:你有一支會被 AI agent 呼叫的 CLI 或內部工具,目前輸出把人類可讀的 prose、spinner、log 混在一起,agent 很難穩定解析。
給 AI 的指令:
我要把
[你的 CLI 名稱]改造成 AI agent 友善的工具,參考 OpenSpec 的「agent contract」設計。請幫我為每個子命令加上--json模式,並遵守以下契約:(1)--json下 stdout **只**輸出一份 2-space pretty-print 的 JSON,所有 spinner/banner/人類可讀訊息一律改走 stderr;(2) 命令失敗時 stdout 仍輸出該命令的 null-shape 加一個結構化status診斷欄位(欄位用severity/code/message/target/fix的統一信封格式),並以 exit code 1 結束;(3) 退場碼要可預期:成功 0、--json失敗 1、使用者取消 130。改完後幫我寫一份agent-contract.md,逐一對照原始碼描述每個命令的輸入、JSON 輸出 shape 與退場碼。
效果:AI 會重構 CLI 的輸出層(把 stdout/stderr 分流),為每個命令定義穩定的 JSON schema 與錯誤信封,並產出一份可維護的機器介面契約文件,讓下游 agent 能無腦 JSON.parse(stdout)。
用 Zod schema 當「型別的單一真相來源」
場景:專案裡同一份資料結構同時有 TypeScript interface 和另外手寫的驗證邏輯,兩者常常不同步。
給 AI 的指令:
請把
[你的型別/設定檔結構]改成用 Zod 定義的 single source of truth,仿照 OpenSpec 的做法:用z.object({...})宣告 schema(每個欄位帶上有意義的error訊息與.default()),再用type X = z.infer<typeof XSchema>反推 TypeScript 型別,刪掉原本手寫的interface。所有外部輸入(設定檔、API payload、CLI 參數)進來時都先過XSchema.parse(),讓執行期驗證與編譯期型別共用同一份宣告。
效果:AI 會把重複的型別宣告與驗證邏輯收斂成單一 Zod schema,杜絕「型別改了但驗證沒改」的不一致,並在資料邊界補上 runtime 驗證。
把多步驟工作流抽象成依賴圖 + 拓撲排序
場景:你有一段用 if/else 硬串起來的多步驟流程(資料 pipeline、審批流、部署流程),步驟間有前後依賴,每加一步都要改流程控制碼。
給 AI 的指令:
我這段流程
[貼上目前的流程程式碼或步驟描述]目前是寫死的順序。請仿照 OpenSpec 的 artifact graph 設計,把它重構成一張有向依賴圖:每個步驟宣告自己requires哪些前置步驟,再寫一個用 Kahn’s algorithm 算 build order 的函式,以及一個getNextArtifacts(completed)回傳「目前所有前置條件都滿足、可以執行」的步驟。**關鍵要求**:在拓撲排序的每個入列環節都對候選節點.sort(),確保相同輸入永遠產出相同順序(決定性 tie-break),這樣才好寫 snapshot 測試。
效果:AI 會把命令式的流程控制改成宣告式的依賴圖,新增步驟只需宣告依賴而不動排序邏輯,並產出可重現、可測試的執行順序。
用「狀態即檔案系統」取代 state 檔
場景:你的工具維護一份 state.json 記錄「哪些步驟做完了」,但常出現 state 與實際產物不一致、人工改了檔案但 state 沒更新的問題。
給 AI 的指令:
請幫我移除
[你的 state.json/狀態記錄機制],改用 OpenSpec 的「狀態即檔案系統」設計:完成與否直接從**產物檔案是否存在**推導,而不是額外記錄。寫一個detectCompleted()掃描產物目錄,對每個步驟檢查它宣告的generates產物(支援 glob,例如output/**/*.json用 fast-glob 判斷是否至少存在一個檔案)是否存在,回傳已完成集合。目標是讓檔案系統成為單一真相來源,天然支援 git 版控與人工手動編輯。
效果:AI 會把狀態管理從獨立 state 檔改為對檔案系統的即時推導,消除狀態同步與不一致問題,並讓流程對人工介入與版控友善。
用 Adapter + Registry 應對碎片化整合
場景:你要對接多個異質的外部目標(多家通知渠道、多種雲端供應商、多個 AI 工具),每個目標格式/路徑都不同,程式裡塞滿了 switch (tool)。
給 AI 的指令:
我需要把
[你的內容/資料]輸出成[列出目標系統,例如 Slack/Email/Webhook]等多種格式,目前是一堆switch判斷。請用 Adapter + Registry 模式重構:(1) 定義一個 tool-agnostic 的內容型別和一個最小Adapter介面(參考 OpenSpec 只有getFilePath()和formatFile()兩個方法,你這邊抽出對應的最小方法集);(2) 每個目標實作成獨立的 adapter 檔案;(3) 用一個Registryclass 的 static initializer block 在載入時註冊所有 adapter。核心產出邏輯只依賴介面、完全不 care 是哪個目標。這樣新增一個整合 = 加一個 adapter 檔 + registry 註冊一行,符合 Open/Closed Principle。
效果:AI 會把散落的條件分支收斂成統一介面 + 註冊表,讓「內容」與「格式」解耦,新增整合變成低風險的加法,其餘程式碼零改動。
在 prompt 裡分離「約束」與「輸出格式」
場景:你在寫給 AI agent 的 system prompt 或 skill 指令,AI 常把「該遵守的規則」誤當成內容寫進產出檔案。
給 AI 的指令:
請幫我重寫這份給 AI 的指令
[貼上你的 prompt/skill],套用 OpenSpec 的 prompt 分工哲學:(1) 把資訊明確分成三類並標註角色——context/rules是「給你的約束,**不要**寫進輸出」、template才是「輸出的結構範本」、dependencies是「要讀來當上下文的既有產物」;(2) 把工具當外部狀態來源而非讓模型記憶,每完成一步就要求 AI 重新查一次[你的 status 命令/狀態端點]確認進度,避免幻覺;(3) 對不確定的輸入用大寫祈使句強制澄清(例如IMPORTANT: Do NOT proceed without ...),禁止擅自臆測。
效果:AI 會把 prompt 重構成角色分明的結構化指令,明確區隔約束與輸出、用外部狀態壓制記憶幻覺、並把「不確定就問」變成硬規則,讓 agent 行為更可預期。
延伸閱讀
- OpenSpec Getting Started 官方文件 — 官方入門指南,說明「CLI 工具」與「slash command」兩個半邊如何分工,是理解本文架構設計的最佳對照。
- OpenSpec Agent Contract 契約文件 — 本文設計四的一手來源,逐行對照原始碼定義
--json輸出 shape 與退場碼,「為 AI 設計 API」的教科書級範例。 - Spec-driven development with AI(GitHub Blog) — GitHub 官方對 SDD 方法論的完整闡述,幫你理解 OpenSpec 所處的整個 spec-driven 生態脈絡。
- I Tested Three Spec-Driven AI Tools(實測比較) — 開發者實測 OpenSpec、Spec Kit 等工具的誠實心得,看看不同 SDD 設計取捨在真實使用下的差異。
- Designing a CLI for AI agents(Arcjet Blog) — 深入探討為 agent 設計 CLI 的原則(stdout 即 API 契約、結構化錯誤、退場碼),與本文設計四彼此印證。
- Zod and the Joy of Single Sources of Truth — 詳解如何用 Zod 的
z.infer讓 schema 同時當驗證與型別的單一真相來源,對應本文設計三。