OpenSpec 原始碼解讀:讓 CLI 成為 AI Agent 的「確定性狀態機」

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 驗證)、yamlfast-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.tsArtifactGraph 類別,工作流的資料結構化核心

// 用 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 回傳該步驟的 templateinstructiondependenciesAI 的每一步都由 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. 用大寫祈使句把「不確定就問」變成硬規則。
  • 明確指派工具:指名要用 AskUserQuestionTodoWrite 這些 agent 內建工具,而非泛泛地說「追蹤進度」。
  • 分離約束與輸出:prompt 明白告訴 AI「contextrules 是給你的約束,不要複製進輸出檔案;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 的思路可以直接借鏡:

  1. 設計一個 --json 契約:讓你的 CLI/API 在給 agent 用時輸出乾淨、可 parse、失敗也有結構的 JSON,並把它文件化成契約。這比讓 agent 讀你的 README 去猜用法可靠太多。
  2. 把「該做什麼」變成可查詢的狀態端點:與其在 system prompt 塞一大段流程說明,不如提供一個 status 端點讓 agent 隨時查「現在該做哪一步、前置條件滿足了嗎」,用外部狀態壓制 LLM 的記憶幻覺。
  3. 用依賴圖描述多步驟工作流:任何有前後依賴的 pipeline(資料處理、審批流、部署流程)都可以抽象成 artifact graph,用拓撲排序算出可執行步驟,並記得在排序中加入決定性 tie-break。
  4. 面對碎片化整合,先定義介面再談實作:若你要對接多個外部系統(通知渠道、雲端供應商、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 診斷欄位(欄位用 severitycodemessagetargetfix 的統一信封格式),並以 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) 用一個 Registry class 的 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) 把資訊明確分成三類並標註角色——contextrules 是「給你的約束,**不要**寫進輸出」、template 才是「輸出的結構範本」、dependencies 是「要讀來當上下文的既有產物」;(2) 把工具當外部狀態來源而非讓模型記憶,每完成一步就要求 AI 重新查一次 [你的 status 命令/狀態端點] 確認進度,避免幻覺;(3) 對不確定的輸入用大寫祈使句強制澄清(例如 IMPORTANT: Do NOT proceed without ...),禁止擅自臆測。

效果:AI 會把 prompt 重構成角色分明的結構化指令,明確區隔約束與輸出、用外部狀態壓制記憶幻覺、並把「不確定就問」變成硬規則,讓 agent 行為更可預期。

延伸閱讀

0

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *