# Compose：规格驱动的开发流程

> 已标为 legacy 的 compose 智能体与十四个技能、Build 下的 /compose-next 契约，以及模式锁定和技能调用控制在代码里怎么实现。

- 作者：David（道雾轩）
- 专栏：MiMo Code 源码解读（https://daiw.net/manual/mimo-code.md）
- 最后更新：2026-09-17
- 原文：https://daiw.net/manual/mimo-code/compose
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

写一个功能，光改代码不够：先把需求问清楚，写下规格与计划，按计划实现，跑验证，交给别人审查，最后合并。MiMo Code 把这条链路叫 Compose。它不是上游的东西：OpenCode v1.14.19 里没有 compose 智能体，没有 `compose:*` 技能，也没有 `config/compose.ts`。

到 v0.1.14，同一套流程在代码里有三种形态：

| 形态 | 入口 | 载体 | 现状 |
| --- | --- | --- | --- |
| compose 智能体 | 发第一条消息前按 Tab 切过去 | 智能体定义、`compose.txt` 提示词、14 个 `compose:*` 技能 | 描述里写着 deprecated |
| `/compose-next` | 在 Build 里输入 `/compose-next` 或明确要求 | 内置技能包里的一份 `SKILL.md` | README 推荐的路径 |
| `compose` workflow | `workflow` 工具以 `name: "compose"` 运行 | `workflow/builtin/compose.js` | 实验特性，默认不注册 |

compose 智能体的描述以“Compose mode (deprecated)”开头（`packages/opencode/src/agent/agent.ts:215`），输入栏把它显示成“Compose (legacy)”（`packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx:1849`）；`workflow` 工具要打开 `MIMOCODE_EXPERIMENTAL_WORKFLOW_TOOL` 才会注册（`packages/opencode/src/tool/registry.ts:312`）。本篇讲前两种对话式路径，第三种放在[四个内置 Workflow](https://daiw.net/manual/mimo-code/builtin-workflows)。

## 怎么用

- **legacy 路径**：新会话里先按 Tab（或 `<leader>a` 打开智能体列表）切到 compose 再发消息。`/compose:plan`、`/compose:tdd` 这类斜杠命令只有当前模式是 compose 时才出现在补全里（`packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx:388`）。
- **产出目录**：配置 `compose.docs` 指定 spec、plan、report 的根目录，默认 `docs/compose`；`compose.docs_absolute: true` 时把相对路径展开成 worktree 下的绝对路径（实现见下文）。
- **关掉技能包**：环境变量 `MIMOCODE_DISABLE_COMPOSE_SKILLS` 为真时，这 14 个技能既不解压也不扫描（`packages/opencode/src/skill/index.ts:206`）。
- **推荐路径**：留在 Build，输入 `/compose-next <需求>`，或者用自然语言明确说要走这个流程。说 `without worktree`、`without spec` 可以跳过对应关卡（`packages/opencode/src/skill/builtin/.bundle/compose-next/SKILL.md:26`）。
- **中途换模式**：会话一旦有了消息，只剩 build 与 plan 能互切。非换不可时用 `/force-agent` 或 `<leader>o` 打开强制切换对话框（实现见文末）。

## compose 智能体

### 权限：只有它能加载 compose 技能

所有智能体共享的默认规则里，`skill` 权限先放行 `*`、再拒绝 `compose:*`（`agent.ts:112`）。compose 智能体在默认规则之后追加一条放行（`agent.ts:211`）：

```ts
compose: {
  name: "compose",
  color: "#a7a3d8",
  description:
    "Compose mode (deprecated). Orchestrates workflows with built-in compose skills. For Fable/Sol-class models, use Build and run /compose-next instead.",
  options: {},
  permission: Permission.merge(
    defaults,
    Permission.fromConfig({
      question: "allow",
      skill: { "compose:*": "allow" },
    }),
    user,
  ),
  mode: "primary",
  native: true,
},
```

规则按“最后匹配优先”求值（见[权限系统](https://daiw.net/manual/opencode/permission)），所以 14 个技能只对 compose 智能体开放，其他智能体（包括所有子智能体）都命中那条 deny。compose 没有放行 `plan_exit`，默认规则里它是 deny（`agent.ts:116`），这与提示词要求“不用原生 plan 工具，改用 `compose:plan`”一致。定义里也没有 `prompt` 字段，系统提示词仍按模型选择（`packages/opencode/src/session/system.ts:53`）。

### 提示词：挂在第一条 compose 消息上

compose 专属的指令不在系统提示词里，而是在每一步请求前由 `insertReminders` 插进历史（`packages/opencode/src/session/prompt.ts:1160`）：

```ts
const composeModeMsg = input.messages.find(
  (msg) => msg.info.role === "user" && msg.info.agent === "compose",
)
const cfg = yield* config.get()
if (composeModeMsg) {
  const ctx = yield* InstanceState.context
  const composeCfg = cfg.compose
  const docsDir = ConfigCompose.resolveDocsDir(ctx.worktree, composeCfg)
  const text = PROMPT_COMPOSE
    .replace("{{compose_docs_dir}}", `Save compose skill outputs: specs in \`${path.join(docsDir, "specs")}\`, plans in \`${path.join(docsDir, "plans")}\`, reports in \`${path.join(docsDir, "reports")}\`.`)
  composeModeMsg.parts.unshift({
    id: PartID.ascending(),
    messageID: composeModeMsg.info.id,
    sessionID: composeModeMsg.info.sessionID,
    type: "text",
    text,
    synthetic: true,
  })
}
```

几个细节：

- 找的是整段历史里**第一条** `agent` 为 compose 的 user 消息，插在它的最前面，位置固定。
- 同一函数里的 plan 模式提醒走 `sessions.updatePart` 落库（`prompt.ts:1352`），这里只改内存；主循环每一步都从数据库重读历史（`prompt.ts:3633`），所以每步重新插一次，不会叠加。
- 查找不看当前智能体：强制切回 Build 后，只要那条 compose 消息还在上下文里，`compose.txt` 照样注入。

设计文档解释了弃用时为什么没动这份提示词（`docs/compose/spec/compose-next.md:105`）：

> any byte change to a model-facing system prompt invalidates prefix cache for every existing Compose session.

`packages/opencode/src/session/prompt/compose.txt` 共 133 行，要点：

- 技能匹配就必须先加载再行动，能不能跳过由技能正文说了算，不能只看描述决定（`compose.txt:4`）。
- `compose:*` 技能不可检索，直接按名字加载（`compose.txt:7`）；也不要加载或推荐 `compose-next`，用户点名要就请他切回 Build（`compose.txt:14`）。
- 所有决策、澄清、审批都通过 `compose:ask` 走 `question` 工具，不许用一句自然语言提问结束回合；没人应答时自己挑最适合无人值守的选项继续，这条压过技能里的 HARD-GATE（`compose.txt:18`）。
- 优先级是用户指令高于 compose 技能，compose 技能高于默认系统提示词（`compose.txt:28`）。
- GPT/Codex 模式下没有顶层 `skill` 工具，要在 `exec` 里调 `tools.skill`（`compose.txt:38`，见 [exec 与 QuickJS 沙箱](https://daiw.net/manual/mimo-code/exec-sandbox)）。
- 实现必须是最小改动，改动超过任务表面复杂度的 3 倍就停下重想（`compose.txt:48`）。
- 末尾的 `{{compose_docs_dir}}` 占位符被替换成上面那句产出目录说明（`compose.txt:130`）。

产出目录由 `resolveDocsDir` 计算（`packages/opencode/src/config/compose.ts:20`）：`compose.docs` 缺省为 `docs/compose`；相对路径默认原样交给模型，相对的是智能体当时的工作目录，只有 `docs_absolute` 为真时才按 worktree 根展开成绝对路径。

## 十四个 compose 技能

### 打包与解压

技能正文在 `packages/opencode/src/skill/compose/.bundle/` 下，每个技能一个目录。`bundle.macro.ts` 是 Bun 宏，构建时把整个目录读成“技能名 → 相对路径 → 文本”的对象内联进二进制（`packages/opencode/src/skill/compose/bundle.macro.ts:16`）；运行时 `extractComposeBundle` 把它写回数据目录下的 `compose/<version>/skills/<name>/`，写完留一个 `.extracted` 标记，标记在就不再重写，本地开发版（`InstallationLocal`）则每次启动都重新解压（`packages/opencode/src/skill/compose/extract.ts:32`）。技能注册表把这个根目录记为 bundled 并扫描其中的 `SKILL.md`（`packages/opencode/src/skill/index.ts:205`），同名的用户技能会覆盖 bundled 版本（`index.ts:122`）。技能名来自 frontmatter，例如 `name: compose:plan`（`packages/opencode/src/skill/compose/.bundle/plan/SKILL.md:2`）。内置技能包走的是同一套机制，见[内置技能包与技能检索](https://daiw.net/manual/mimo-code/skills)。

来源写在同目录的两份许可说明里：技能派生自 Jesse Vincent 的 superpowers 项目（`packages/opencode/src/skill/compose/LICENSE-superpowers:1`），`compose.txt` 里的 Simplicity 准则部分取自 andrej-karpathy-skills（`packages/opencode/src/skill/compose/LICENSE-karpathy:1`），均为 MIT。

### 各管哪一步

中文说明取自 TUI 的本地化词条（`packages/opencode/src/cli/cmd/tui/i18n/zh.ts:220`），衔接关系取自各技能正文：

| 技能 | 说明 | 衔接 |
| --- | --- | --- |
| `compose:brainstorm` | 探索需求与设计，动手前先想清楚 | 终点固定是加载 `compose:plan` |
| `compose:plan` | 从规格书生成分步实施计划 | 存盘后决定用子智能体还是本会话执行 |
| `compose:subagent` | 分派独立任务给子代理执行 | 每个任务一个新子智能体，先规格审查再质量审查 |
| `compose:execute` | 按实施计划逐步执行，含审查节点 | 全部完成后交给 `compose:report` |
| `compose:worktree` | 创建隔离工作空间进行特性开发 | 执行前确保有隔离工作区 |
| `compose:tdd` | 测试驱动开发——先写测试再写实现 | 实现者遵循 |
| `compose:review` | 合并前验证工作是否符合需求 | 派审查子智能体，只给它精心准备的上下文 |
| `compose:verify` | 运行验证命令并确认通过 | 任何“完成”声明之前 |
| `compose:report` | 汇总实现成果为终态报告 | 写完交给 `compose:merge` |
| `compose:merge` | 集成已完成的工作——合并、PR 或清理 | 链路终点 |
| `compose:ask` | 向用户请求决策或澄清 | 其他技能的决策点都引用它 |
| `compose:debug` | 系统化调试，先定位再修复 | bug 场景的入口 |
| `compose:feedback` | 严谨处理代码审查反馈 | 收到审查意见时 |
| `compose:parallel` | 并行执行互不依赖的任务 | 两个以上独立任务时 |

主干是这样接起来的：

```mermaid
flowchart LR
  BS["compose:brainstorm"] --> PL["compose:plan"]
  PL -->|"Subagent"| SA["compose:subagent"]
  PL -->|"Inline"| EX["compose:execute"]
  EX --> RP["compose:report"]
  RP --> MG["compose:merge"]
  SA -->|"final review"| MG
```

`compose:plan` 选执行方式时先查记忆里 `compose-preferences` 文件的 `execution-style`，没有就问用户，选了“always”会写回记忆（`plan/SKILL.md:165`）。三类产物的命名不同：设计稿是 `specs/` 下的 `YYYY-MM-DD-<topic>-design.md`（`packages/opencode/src/skill/compose/.bundle/brainstorm/SKILL.md:35`），计划是 `plans/` 下带日期的文件（`plan/SKILL.md:18`），报告是 `reports/` 下不带日期、原地覆盖的 `<feature-name>.md`（`packages/opencode/src/skill/compose/.bundle/report/SKILL.md:16`）。

`compose:ask` 统一了“没人回答”的处理：`question` 工具不在工具表里，或者返回 `[Never-Ask]` 指令（never-ask 模式，`packages/opencode/src/tool/question.ts:30`），都由模型自己挑最适合无人值守的选项并说明理由；删分支、强推这类不可逆操作永不自动批准（`packages/opencode/src/skill/compose/.bundle/ask/SKILL.md:49`）。

## `/compose-next`：一份契约

`compose-next/SKILL.md` 共 197 行，把整条流程压进一次加载，开头就写明（`compose-next/SKILL.md:8`）：

> Compact end-to-end contract for grill → workspace → spec → implement → verify → review → finalize → finish. One skill load, no internal skill hand-offs.

与 legacy 路径相比，关键差别有这些：

- **只在明确请求时进入**：描述和正文都写了，不能因为任务看起来大就自行启用（`compose-next/SKILL.md:3`、`compose-next/SKILL.md:12`）。
- **扛得住压缩**：要求检查点或压缩摘要里保留一句恢复指令，续跑时若契约不在上下文里就重新加载本技能（`compose-next/SKILL.md:10`，检查点机制见[检查点写手](https://daiw.net/manual/mimo-code/checkpoint-writer)）。
- **先占工作区**：所有路径都必须先过 Workspace，默认在 `.worktrees/<slug>` 建 linked worktree，未经同意不在 `main`、`master` 上动手（`compose-next/SKILL.md:30`、`compose-next/SKILL.md:58`）。
- **一个功能一份文档**：`docs/compose/spec/<feature-name>.md`，不带日期，设计、任务和交付报告都在里面，不再另写 plan 和 report（`compose-next/SKILL.md:67`）；frontmatter 的 `status` 依次是 designed、in-progress、delivered，任务带验收标准、`covers:` 与 `depends:`。这个路径写死在技能里，不读 `<compose_docs_dir>`，因为那个块只有 compose 智能体和 `compose.js` 才有（`compose-next.md:43`）。
- **验证与审查严格串行**：验证命令全部结束后才派一个新的审查子智能体，审查模型至少不弱于实现者，分别给出规格符合性、正确性、代码库一致性三项结论（`compose-next/SKILL.md:134`、`compose-next/SKILL.md:152`）。
- **不自动收尾**：Finalize 把文档改为 delivered 并填好报告，然后停下来报告分支与 SHA，由用户决定合并、开 PR 还是保留（`compose-next/SKILL.md:186`）。

仓库自己的 `docs/compose/spec/*.md` 就是按这个模板写的，frontmatter 里是 `feature`、`status`、`updated`、`branch`、`commits`（`docs/compose/spec/skill-invocation-control.md:1`）。

## 技能调用控制

`docs/compose/spec/skill-invocation-control.md` 把两个问题拆开：`permission.skill` 只管授权，deny 意味着谁都用不了，包括用户；frontmatter 的 `disable-model-invocation` 只管模型可达性。代码里对应两个取数函数（`skill/index.ts:350`、`index.ts:361`）：`available(agent)` 过滤掉 deny 的技能，`modelInvocable(agent)` 在此基础上再去掉设了 `disable_model_invocation` 的技能。各入口的取数与效果：

| 入口 | 取数 | Build 下的 `compose:plan` | `compose-next` |
| --- | --- | --- | --- |
| 系统提示词里的技能目录 | `modelInvocable`（`system.ts:180`） | 不出现 | 出现 |
| `skill_search` | `modelInvocable` 再排除 `compose:` 前缀（`packages/opencode/src/tool/skill-search.ts:36`、`packages/opencode/src/skill/search.ts:69`） | 搜不到 | 可检索 |
| `skill` 工具加载 | `skill.get` 后 `ctx.ask`（`packages/opencode/src/tool/skill.ts:58`） | `DeniedError` | 可加载 |
| 用户输入 `/名字` 注入正文 | `available`（`prompt.ts:1245`） | 不注入 | 注入 |
| 斜杠补全、技能对话框 | `skill.all()` 再由 TUI 过滤（`packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx:27`） | 隐藏 | 显示 |

设了 `disable-model-invocation` 的技能，模型调用 `skill` 时会拿到一条指路的错误，告诉它只有用户能用 `/名字` 启动（`skill.ts:50`）。

对照代码，文档有三处已经过时：

- 正文说 `compose-next` 设置了 `disable-model-invocation: true`；文档顶部 2026-08-12 的补记（`skill-invocation-control.md:11`）和当前的 `SKILL.md` frontmatter 都表明这个字段已经去掉，模型看得见也能调用它，“只在用户明确要求时启用”只靠描述和正文约束。
- 文档把 `tool/registry.ts` 里的 `describeSkill` 列为模型可见入口；当前代码里没有这个函数，`skill` 工具的说明是一段不列技能名的静态文本（`packages/opencode/src/tool/skill.txt:1`）。
- 文档里标注的行号都已漂移，比如它说在 111 行的默认 deny 规则如今在 `agent.ts:114`，它说在 864 行的提及扫描如今在 `prompt.ts:1245`。

## 模式锁定

上游 v1.14.19 的 `move` 只是在主智能体列表里循环。MiMo 在 TUI 的本地状态里加了一道闸（`packages/opencode/src/cli/cmd/tui/context/local.tsx:50`）：

```ts
const FREE_SWITCH_GROUP = ["build", "plan"]
const canSwitchTo = (target: string) => {
  if (!agentStore.sessionHasMessages) return true
  const current = agentStore.current
  if (!current) return true
  if (current === target) return true
  const currentInGroup = FREE_SWITCH_GROUP.includes(current)
  const targetInGroup = FREE_SWITCH_GROUP.includes(target)
  return currentInGroup && targetInGroup
}
```

`sessionHasMessages` 由输入框根据会话里有没有 user 消息派生（`prompt/index.tsx:585`）。Tab 循环时跳过不可达的智能体，一个都去不了就弹出提示；当前模式不在 build、plan 里时，提示是“进入 `{{mode}}` 模式后无法在运行中切换模式”（`zh.ts:343`）。重新进入已有会话时，TUI 把模式恢复成最后一条 user 消息的智能体（`prompt/index.tsx:604`）。`forceSwitch` 绕过这道闸，对应命令 `/force-agent` 与快捷键 `<leader>o`（`packages/opencode/src/cli/cmd/tui/app.tsx:688`、`packages/opencode/src/config/keybinds.ts:63`）。

README 说“Compose 一旦进入即被隔离”（`README.zh.md:121`），代码的实际规则更宽：闸只放行 build 与 plan 之间的切换，compose 以及实验性的 orchestrator、max 进去了就出不来，build 和 plan 也进不去它们。锁只在 TUI 客户端，服务端不校验；Orchestrator 的 `session` 工具用 `setmode` 操作仍可把子会话改成 build、plan 或 compose（`packages/opencode/src/tool/session.ts:542`，见 [Orchestrator](https://daiw.net/manual/mimo-code/orchestrator)）。README 提到的“显著提升工具调用成功率”在代码里无从核对。

下一篇：[Workflow 运行时](https://daiw.net/manual/mimo-code/workflow-runtime)——把多智能体编排写成确定性的 JS 脚本：原语、并发上限、日志与断点续跑。
