feat: 将 task 从 plan 文档提升为独立 SDD 阶段

This commit is contained in:
2026-06-10 22:38:19 +08:00
parent 289a7c6633
commit c4f83a3753
20 changed files with 318 additions and 353 deletions

View File

@@ -92,7 +92,7 @@ CLI 通过子命令提供帮助和版本信息,不使用 `--help`/`--version`
### 阶段与配置 ### 阶段与配置
- **阶段固定**discuss → plan → build → archive不可自定义增删 - **阶段固定**discuss → plan → task → build → archive不可自定义增删
- **配置覆盖策略**:阶段级别全量覆盖,不支持字段级合并。自定义 plan 时需完整重写所有 documents - **配置覆盖策略**:阶段级别全量覆盖,不支持字段级合并。自定义 plan 时需完整重写所有 documents
- **配置文件名**`.rune/config.yaml`,不是 `rune.yml` - **配置文件名**`.rune/config.yaml`,不是 `rune.yml`
- **文档模板**:纯静态文本模板,不进行变量替换 - **文档模板**:纯静态文本模板,不进行变量替换
@@ -101,6 +101,7 @@ CLI 通过子命令提供帮助和版本信息,不使用 `--help`/`--version`
- **discuss**:不持久化讨论结果,完全依赖 AI 会话上下文传递;不设强制门控,通过提示词引导。讨论结束时引导用户运行 `rune create` 创建变更目录 - **discuss**:不持久化讨论结果,完全依赖 AI 会话上下文传递;不设强制门控,通过提示词引导。讨论结束时引导用户运行 `rune create` 创建变更目录
- **plan**命令只输出提示词不写入文件AI 负责根据提示词生成文档内容并写入。新变更时引导用户先运行 `rune create` 创建目录。重复调用同一文档的 plan 会追加已有内容用于增量修订。依赖未满足时有友好提示(非报错) - **plan**命令只输出提示词不写入文件AI 负责根据提示词生成文档内容并写入。新变更时引导用户先运行 `rune create` 创建目录。重复调用同一文档的 plan 会追加已有内容用于增量修订。依赖未满足时有友好提示(非报错)
- **task**:根据规划文档内容拆分为 checkbox 任务清单;格式固定,不可自定义模板;规划文档必须全部完成才能进入
- **build**:按 task.md 的 checkbox 顺序执行;任务间无结构化依赖;可多次执行直到全部完成 - **build**:按 task.md 的 checkbox 顺序执行;任务间无结构化依赖;可多次执行直到全部完成
- **archive**:输出归档提示词(含未完成任务的警告),引导 AI 汇总变更并确认。`finish` 命令执行实际的目录移动 - **archive**:输出归档提示词(含未完成任务的警告),引导 AI 汇总变更并确认。`finish` 命令执行实际的目录移动

View File

@@ -37,12 +37,13 @@ bunx @lanyuanxiaoyao/rune update claude-code # 更新 Claude Code 的命令
### SDD 流程 ### SDD 流程
SDD 工作流包含固定的个阶段,不可自定义增删: SDD 工作流包含固定的个阶段,不可自定义增删:
1. **讨论阶段**`/rune-discuss`:与 AI 自由讨论需求和方案。讨论结果保留在 AI 会话上下文中传递到后续阶段,不持久化到文件。结束前会引导是否进入规划阶段。 1. **讨论阶段**`/rune-discuss`:与 AI 自由讨论需求和方案。讨论结果保留在 AI 会话上下文中传递到后续阶段,不持久化到文件。结束前会引导是否进入规划阶段。
2. **规划阶段**`/rune-plan <变更名> <文档名>`:按配置的文档模板生成规划文档。变更名仅支持中文、英文和短横线(`-`)。默认包含 `design`(设计文档)`task`(任务清单,依赖 design个文档。文档间支持 `depend` 字段声明前置依赖依赖未满足时有友好提示。plan 命令自身不写入文件,只输出提示词供 AI 消费。 2. **规划阶段**`/rune-plan <变更名> <文档名>`:按配置的文档模板生成规划文档。变更名仅支持中文、英文和短横线(`-`)。默认包含 `design`(设计文档)个文档。文档间支持 `depend` 字段声明前置依赖依赖未满足时有友好提示。plan 命令自身不写入文件,只输出提示词供 AI 消费。
3. **构建阶段**`/rune-build <变更名>`按 task.md 中的任务顺序逐个实现。每个任务完成后更新对应的 checkbox 为 `[x]`。可多次执行直到所有任务完成 3. **任务拆解阶段**`/rune-task <变更名>`根据规划阶段生成的文档内容,拆分为 checkbox 格式的任务清单task.md。规划阶段的所有文档必须已完成。格式固定为 checkbox 列表(`- [ ] 待完成``- [x] 已完成`),不可自定义模板
4. **归档阶段**`/rune-archive <变更名>`输出归档阶段提示词,引导 AI 汇总变更内容并确认归档。归档前自动检查 task.md 的完成状态,如有未完成任务会注入警告提示词,引导 AI 询问用户是否确认。确认后执行 `rune finish <变更名>` 将变更目录移动到 `archive/` 4. **构建阶段**`/rune-build <变更名>`按 task.md 中的任务顺序逐个实现。每个任务完成后更新对应的 checkbox 为 `[x]`。可多次执行直到所有任务完成
5. **归档阶段**`/rune-archive <变更名>`:输出归档阶段提示词,引导 AI 汇总变更内容并确认归档。归档前自动检查 task.md 的完成状态,如有未完成任务会注入警告提示词,引导 AI 询问用户是否确认。确认后执行 `rune finish <变更名>` 将变更目录移动到 `archive/`
> **辅助命令**`rune create <变更名>` 用于在 `.rune/changes/` 下创建变更目录。它不是 SDD 阶段,而是在 discuss 结束后或 plan 开始前通过 CLI 运行的辅助命令。discuss 和 plan 的编辑器命令中已内嵌 create 的使用引导。 > **辅助命令**`rune create <变更名>` 用于在 `.rune/changes/` 下创建变更目录。它不是 SDD 阶段,而是在 discuss 结束后或 plan 开始前通过 CLI 运行的辅助命令。discuss 和 plan 的编辑器命令中已内嵌 create 的使用引导。
@@ -70,6 +71,7 @@ bunx @lanyuanxiaoyao/rune version # 显示版本号
| `bunx @lanyuanxiaoyao/rune discuss` | 输出讨论阶段提示词 | | `bunx @lanyuanxiaoyao/rune discuss` | 输出讨论阶段提示词 |
| `bunx @lanyuanxiaoyao/rune create <变更名>` | 创建变更目录discuss 和 plan 之间的辅助命令) | | `bunx @lanyuanxiaoyao/rune create <变更名>` | 创建变更目录discuss 和 plan 之间的辅助命令) |
| `bunx @lanyuanxiaoyao/rune plan <变更名> <文档名>` | 输出规划阶段提示词 | | `bunx @lanyuanxiaoyao/rune plan <变更名> <文档名>` | 输出规划阶段提示词 |
| `bunx @lanyuanxiaoyao/rune task <变更名>` | 输出任务拆解阶段提示词 |
| `bunx @lanyuanxiaoyao/rune build <变更名>` | 输出构建阶段提示词 | | `bunx @lanyuanxiaoyao/rune build <变更名>` | 输出构建阶段提示词 |
| `bunx @lanyuanxiaoyao/rune archive <变更名>` | 输出归档阶段提示词 | | `bunx @lanyuanxiaoyao/rune archive <变更名>` | 输出归档阶段提示词 |
| `bunx @lanyuanxiaoyao/rune finish <变更名>` | 归档变更(将变更目录移动到 archive/ | | `bunx @lanyuanxiaoyao/rune finish <变更名>` | 归档变更(将变更目录移动到 archive/ |
@@ -79,7 +81,7 @@ bunx @lanyuanxiaoyao/rune version # 显示版本号
编辑 `.rune/config.yaml` 自定义各阶段的提示词和文档模板。配置合并采用阶段级别全量覆盖策略:自定义某个阶段时需完整重写该阶段的配置,未配置的阶段使用内置默认配置。 编辑 `.rune/config.yaml` 自定义各阶段的提示词和文档模板。配置合并采用阶段级别全量覆盖策略:自定义某个阶段时需完整重写该阶段的配置,未配置的阶段使用内置默认配置。
规划阶段的文档支持 `depend` 字段声明前置依赖,如 `task` 依赖 `design` 规划阶段的文档支持 `depend` 字段声明前置依赖:
```yaml ```yaml
stages: stages:
@@ -87,8 +89,8 @@ stages:
documents: documents:
- name: design - name: design
prompt: 生成设计文档,包含背景、目标、方案、接口和注意事项 prompt: 生成设计文档,包含背景、目标、方案、接口和注意事项
- name: task - name: api
prompt: 生成任务清单,将设计拆分为可独立执行的小任务 prompt: 生成 API 设计文档
depend: [design] depend: [design]
``` ```
@@ -96,7 +98,7 @@ stages:
## 设计决策 ## 设计决策
- **阶段固定**discuss → plan → build → archive 不可自定义增删 - **阶段固定**discuss → plan → task → build → archive 不可自定义增删
- **配置覆盖策略**:阶段级别全量覆盖,不支持字段级合并 - **配置覆盖策略**:阶段级别全量覆盖,不支持字段级合并
- **讨论结果不持久化**:完全依赖 AI 会话上下文传递 - **讨论结果不持久化**:完全依赖 AI 会话上下文传递
- **plan 不写文件**plan 命令只输出提示词,由 AI 负责写入文档 - **plan 不写文件**plan 命令只输出提示词,由 AI 负责写入文档

View File

@@ -6,7 +6,7 @@ import { writeIfChanged } from "./utils.ts";
const COMMANDS_DIR = ".claude/commands"; const COMMANDS_DIR = ".claude/commands";
const STAGES_WITH_CHANGE_NAME = new Set(["plan", "build", "archive"]); const STAGES_WITH_CHANGE_NAME = new Set(["plan", "task", "build", "archive"]);
function buildSmartGuide(command: string): string { function buildSmartGuide(command: string): string {
return `如果用户没有指定变更名称,请按以下步骤智能识别: return `如果用户没有指定变更名称,请按以下步骤智能识别:
@@ -33,6 +33,9 @@ export async function injectClaudeCode(
if (stage === "plan") { if (stage === "plan") {
content += `\n如果变更目录尚不存在新变更请先运行 \`${command} create <变更名>\` 创建目录,再开始规划。`; content += `\n如果变更目录尚不存在新变更请先运行 \`${command} create <变更名>\` 创建目录,再开始规划。`;
} }
if (stage === "task") {
content += `\n任务拆解前请确认规划文档已全部完成运行 \`${command} status <变更名>\` 检查。`;
}
if (stage === "discuss") { if (stage === "discuss") {
content += `\n讨论结束后如果确定了变更方向请运行 \`${command} create <变更名>\` 创建变更目录,然后进入规划阶段。`; content += `\n讨论结束后如果确定了变更方向请运行 \`${command} create <变更名>\` 创建变更目录,然后进入规划阶段。`;
} }
@@ -63,6 +66,9 @@ export async function updateClaudeCode(
if (stage === "plan") { if (stage === "plan") {
newContent += `\n如果变更目录尚不存在新变更请先运行 \`${command} create <变更名>\` 创建目录,再开始规划。`; newContent += `\n如果变更目录尚不存在新变更请先运行 \`${command} create <变更名>\` 创建目录,再开始规划。`;
} }
if (stage === "task") {
newContent += `\n任务拆解前请确认规划文档已全部完成运行 \`${command} status <变更名>\` 检查。`;
}
if (stage === "discuss") { if (stage === "discuss") {
newContent += `\n讨论结束后如果确定了变更方向请运行 \`${command} create <变更名>\` 创建变更目录,然后进入规划阶段。`; newContent += `\n讨论结束后如果确定了变更方向请运行 \`${command} create <变更名>\` 创建变更目录,然后进入规划阶段。`;
} }
@@ -76,11 +82,12 @@ export async function updateClaudeCode(
function generateIntroCommand(command: string): string { function generateIntroCommand(command: string): string {
return `Rune 是基于规格驱动开发SDD的 AI 开发辅助工具。SDD 工作流程: return `Rune 是基于规格驱动开发SDD的 AI 开发辅助工具。SDD 工作流程:
discuss → plan → build → archive discuss → plan → task → build → archive
可用命令: 可用命令:
- /rune-discuss — 自由讨论需求和方案 - /rune-discuss — 自由讨论需求和方案
- /rune-plan — 生成设计文档和任务清单(新变更需先运行 \`${command} create <变更名>\` 创建目录) - /rune-plan — 生成设计文档(新变更需先运行 \`${command} create <变更名>\` 创建目录)
- /rune-task — 根据设计文档生成任务清单
- /rune-build — 按任务清单逐步实现 - /rune-build — 按任务清单逐步实现
- /rune-archive — 归档已完成的变更 - /rune-archive — 归档已完成的变更

View File

@@ -7,7 +7,7 @@ import { writeIfChanged } from "./utils.ts";
const COMMANDS_DIR = ".opencode/commands"; const COMMANDS_DIR = ".opencode/commands";
const SKILLS_DIR = ".opencode/skills"; const SKILLS_DIR = ".opencode/skills";
const STAGES_WITH_CHANGE_NAME = new Set(["plan", "build", "archive"]); const STAGES_WITH_CHANGE_NAME = new Set(["plan", "task", "build", "archive"]);
export async function injectOpenCode(projectRoot: string, command: string = "rune"): Promise<void> { export async function injectOpenCode(projectRoot: string, command: string = "rune"): Promise<void> {
for (const stage of STAGES) { for (const stage of STAGES) {
@@ -67,6 +67,10 @@ function generateSkill(stage: string, command: string): string {
extraGuide = `\n规划阶段应先运行 \`${command} status <变更名>\` 获取当前有哪些文档需要编写,再按依赖顺序逐个生成。\n\n如果变更目录尚不存在新变更请先运行 \`${command} create <变更名>\` 创建目录,再开始规划。\n`; extraGuide = `\n规划阶段应先运行 \`${command} status <变更名>\` 获取当前有哪些文档需要编写,再按依赖顺序逐个生成。\n\n如果变更目录尚不存在新变更请先运行 \`${command} create <变更名>\` 创建目录,再开始规划。\n`;
} }
if (stage === "task") {
extraGuide = `\n任务拆解阶段应先运行 \`${command} status <变更名>\` 确认规划文档已全部完成,再生成任务清单。\n`;
}
if (stage === "discuss") { if (stage === "discuss") {
extraGuide = `\n讨论结束后如果确定了变更方向请运行 \`${command} create <变更名>\` 创建变更目录,然后进入规划阶段。\n`; extraGuide = `\n讨论结束后如果确定了变更方向请运行 \`${command} create <变更名>\` 创建变更目录,然后进入规划阶段。\n`;
} }
@@ -74,6 +78,7 @@ function generateSkill(stage: string, command: string): string {
const descriptionMap: Record<string, string> = { const descriptionMap: Record<string, string> = {
discuss: "Use when 需要进入 SDD 讨论阶段,自由讨论需求和架构方案", discuss: "Use when 需要进入 SDD 讨论阶段,自由讨论需求和架构方案",
plan: "Use when 需要进入 SDD 规划阶段,生成设计文档和任务清单", plan: "Use when 需要进入 SDD 规划阶段,生成设计文档和任务清单",
task: "Use when 需要进入 SDD 任务拆解阶段,根据设计文档生成任务清单",
build: "Use when 需要进入 SDD 构建阶段,按任务清单逐步实现变更", build: "Use when 需要进入 SDD 构建阶段,按任务清单逐步实现变更",
archive: "Use when 需要进入 SDD 归档阶段,确认变更完成并归档", archive: "Use when 需要进入 SDD 归档阶段,确认变更完成并归档",
}; };
@@ -118,8 +123,8 @@ Rune 是基于规格驱动开发SDD的 AI 开发辅助工具。它通过
## SDD 工作流程 ## SDD 工作流程
\`\`\` \`\`\`
discuss → plan → build → archive discuss → plan → task → build → archive
探索 规划 构建 归档 探索 规划 任务 构建 归档
\`\`\` \`\`\`
## 可用命令 ## 可用命令
@@ -128,6 +133,7 @@ discuss → plan → build → archive
|------|-----------|------| |------|-----------|------|
| discuss | /rune-discuss | 自由讨论需求和方案 | | discuss | /rune-discuss | 自由讨论需求和方案 |
| plan | /rune-plan | 生成设计文档和任务清单 | | plan | /rune-plan | 生成设计文档和任务清单 |
| task | /rune-task | 根据设计文档生成任务清单 |
| build | /rune-build | 按任务清单逐步实现 | | build | /rune-build | 按任务清单逐步实现 |
| archive | /rune-archive | 归档已完成的变更 | | archive | /rune-archive | 归档已完成的变更 |
@@ -140,8 +146,9 @@ ${command} status
## 快速开始 ## 快速开始
1. 使用 /rune-discuss 进入讨论,自由探索需求 1. 使用 /rune-discuss 进入讨论,自由探索需求
2. 讨论结束后,运行 \`${command} create <变更名>\` 创建变更目录,然后用 /rune-plan 生成设计文档和任务清单 2. 讨论结束后,运行 \`${command} create <变更名>\` 创建变更目录,然后用 /rune-plan 生成设计文档
3. 使用 /rune-build 按任务顺序实现功能 3. 使用 /rune-task 根据设计文档生成任务清单
4. 使用 /rune-archive 归档已完成的变更 4. 使用 /rune-build 按任务顺序实现功能
5. 使用 /rune-archive 归档已完成的变更
`; `;
} }

View File

@@ -8,6 +8,7 @@ import { findProjectRoot, loadConfig, getChangeDir, getArchiveDir } from "./core
import { import {
assembleDiscussPrompt, assembleDiscussPrompt,
assemblePlanPrompt, assemblePlanPrompt,
assembleTaskPrompt,
assembleBuildPrompt, assembleBuildPrompt,
assembleArchivePrompt, assembleArchivePrompt,
} from "./core/assembler.ts"; } from "./core/assembler.ts";
@@ -83,17 +84,17 @@ export function suggestNextStep(change: ChangeStatus, config?: RuneConfig): stri
return `完成前置依赖后再规划文档`; return `完成前置依赖后再规划文档`;
} }
if (change.taskProgress && change.taskProgress.completed < change.taskProgress.total) { if (!change.taskProgress) {
return `${prefix} task ${change.name}`;
}
if (change.taskProgress.completed < change.taskProgress.total) {
return `${prefix} build ${change.name}`; return `${prefix} build ${change.name}`;
} }
if (change.taskProgress && change.taskProgress.completed === change.taskProgress.total) {
return `${prefix} archive ${change.name}`; return `${prefix} archive ${change.name}`;
} }
return `${prefix} build ${change.name}`;
}
const cli = cac("rune"); const cli = cac("rune");
cli.command("", "").action(async () => { cli.command("", "").action(async () => {
@@ -257,6 +258,21 @@ cli
console.log(prompt); console.log(prompt);
}); });
cli.command("task <change-name>", "任务拆解阶段").action(async (changeName: string) => {
validateChangeName(changeName);
const root = requireProjectRoot();
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
const prefix = getPmPrefix();
throw new CommandError(`变更"${changeName}"不存在`, {
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
});
}
const config = await loadConfig(root);
const prompt = await assembleTaskPrompt(config, root, changeName);
console.log(prompt);
});
cli.command("build <change-name>", "构建阶段").action(async (changeName: string) => { cli.command("build <change-name>", "构建阶段").action(async (changeName: string) => {
validateChangeName(changeName); validateChangeName(changeName);
const root = requireProjectRoot(); const root = requireProjectRoot();

View File

@@ -44,11 +44,21 @@ const COMMANDS: Record<string, CommandHelpDef> = {
usageTemplate: "plan <change-name> <document-name>", usageTemplate: "plan <change-name> <document-name>",
args: [ args: [
{ name: "<change-name>", desc: '变更名称,如 "add-login"' }, { name: "<change-name>", desc: '变更名称,如 "add-login"' },
{ name: "<document-name>", desc: '文档名称,如 "design"、"task"' }, { name: "<document-name>", desc: '文档名称,如 "design"' },
], ],
detail: detail:
"生成规划阶段指定文档的提示词。依赖的前置文档必须已完成。可用文档由配置中的 plan.documents 定义。", "生成规划阶段指定文档的提示词。依赖的前置文档必须已完成。可用文档由配置中的 plan.documents 定义。",
exampleArgs: ["plan add-user-auth design", "plan add-user-auth task"], exampleArgs: ["plan add-user-auth design", "plan add-user-auth design"],
},
task: {
name: "task",
alias: "task <变更>",
description: "任务拆解:根据规划文档生成任务清单",
usageTemplate: "task <change-name>",
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
detail:
"根据规划阶段的文档内容,生成 checkbox 格式的任务清单task.md。规划阶段的所有文档必须已完成。",
exampleArgs: ["task add-user-auth", "task fix-memory-leak"],
}, },
build: { build: {
name: "build", name: "build",
@@ -56,7 +66,8 @@ const COMMANDS: Record<string, CommandHelpDef> = {
description: "构建:生成构建阶段提示词", description: "构建:生成构建阶段提示词",
usageTemplate: "build <change-name>", usageTemplate: "build <change-name>",
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }], args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
detail: "生成构建阶段的提示词。变更目录需已存在(通过 create 创建)。", detail:
"按 task.md 中的任务顺序逐步实现。需先完成任务拆解阶段task。可多次执行直到全部完成。",
exampleArgs: ["build add-user-auth", "build fix-memory-leak"], exampleArgs: ["build add-user-auth", "build fix-memory-leak"],
}, },
archive: { archive: {

View File

@@ -11,12 +11,13 @@ import { parse as parseYaml } from "yaml";
const CONFIG_TEMPLATE = `# Rune 配置文件 const CONFIG_TEMPLATE = `# Rune 配置文件
# #
# 未配置的阶段将使用内置默认配置。 # 未配置的阶段将使用内置默认配置。
# SDD 阶段discuss -> plan -> build -> archive # SDD 阶段discuss -> plan -> task -> build -> archive
# 辅助命令create创建变更目录plan 阶段的前置步骤) # 辅助命令create创建变更目录plan 阶段的前置步骤)
# #
# 可配置阶段: # 可配置阶段:
# discuss - 探索阶段:深度思考、调查代码库、对比方案 # discuss - 探索阶段:深度思考、调查代码库、对比方案
# plan - 规划阶段:生成设计文档和任务清单 # plan - 规划阶段:生成设计文档
# task - 任务拆解阶段:根据设计文档生成 checkbox 任务清单
# build - 构建阶段:按任务清单逐步实现 # build - 构建阶段:按任务清单逐步实现
# archive - 归档阶段:确认完成并归档变更 # archive - 归档阶段:确认完成并归档变更
# #
@@ -34,15 +35,9 @@ const CONFIG_TEMPLATE = `# Rune 配置文件
# prompt: 生成设计文档 # prompt: 生成设计文档
# template: | # template: |
# # 设计文档 # # 设计文档
# - name: task
# prompt: 生成任务清单
# depend: [design]
# template: |
# # 任务清单
# #
# metadata 说明: # metadata 说明:
# command - Rune CLI 执行命令(如 bunx @lanyuanxiaoyao/runeinit 时自动检测 # command - Rune CLI 执行命令(如 bunx @lanyuanxiaoyao/runeinit 时自动检测
# tracked - 是否启用任务追踪(默认 true开启时 plan.documents 必须包含 task 文档
`; `;
export const SUPPORTED_TOOLS: Record<string, (root: string, command?: string) => Promise<void>> = { export const SUPPORTED_TOOLS: Record<string, (root: string, command?: string) => Promise<void>> = {

View File

@@ -71,6 +71,60 @@ export async function assemblePlanPrompt(
return applyCommandPrefix(parts.join("\n"), config); return applyCommandPrefix(parts.join("\n"), config);
} }
export async function assembleTaskPrompt(
config: RuneConfig,
projectRoot: string,
changeName: string,
): Promise<string> {
const task = config.stages.task;
if (!task) {
throw new CommandError("任务拆解阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.task",
});
}
const plan = config.stages.plan;
if (!plan) {
throw new CommandError("规划阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.plan",
});
}
const changeDir = getChangeDir(projectRoot, changeName);
const missingDocs: string[] = [];
for (const doc of plan.documents) {
const docPath = join(changeDir, `${doc.name}.md`);
if (!existsSync(docPath)) {
missingDocs.push(`${doc.name}.md`);
}
}
if (missingDocs.length > 0) {
throw new CommandError(`变更"${changeName}"的规划文档尚未全部完成:${missingDocs.join("、")}`, {
hint: `请先完成规划阶段rune plan ${changeName} ${missingDocs[0].replace(".md", "")}`,
});
}
const parts: string[] = [];
parts.push(`# 任务拆解阶段:${changeName}\n`);
parts.push(task.prompt);
const planDocPaths = plan.documents.map((d) => join(changeDir, `${d.name}.md`));
parts.push(`\n请先读取以下规划文档`);
for (const p of planDocPaths) {
parts.push(`- ${p}`);
}
const taskPath = join(changeDir, "task.md");
if (existsSync(taskPath)) {
parts.push(`\ntask.md 已存在,请先读取 ${taskPath} 查看已有内容,在此基础上修订。`);
}
parts.push(`\n请将任务列表写入 ${taskPath}`);
return applyCommandPrefix(parts.join("\n"), config);
}
export async function assembleBuildPrompt( export async function assembleBuildPrompt(
config: RuneConfig, config: RuneConfig,
projectRoot: string, projectRoot: string,
@@ -83,10 +137,6 @@ export async function assembleBuildPrompt(
}); });
} }
if (!config.metadata?.tracked) {
return applyCommandPrefix(build.prompt, config);
}
const changeDir = getChangeDir(projectRoot, changeName); const changeDir = getChangeDir(projectRoot, changeName);
const taskPath = join(changeDir, "task.md"); const taskPath = join(changeDir, "task.md");
@@ -95,8 +145,8 @@ export async function assembleBuildPrompt(
taskContent = await readFile(taskPath, "utf-8"); taskContent = await readFile(taskPath, "utf-8");
} catch { } catch {
const prefix = getPmPrefix(config); const prefix = getPmPrefix(config);
throw new CommandError(`变更"${changeName}"尚未完成规划task.md 不存在`, { throw new CommandError(`变更"${changeName}"尚未完成任务拆解task.md 不存在`, {
hint: `请先完成规划阶段:${prefix} plan ${changeName} task 生成任务文档`, hint: `请先完成任务拆解阶段:${prefix} task ${changeName}`,
}); });
} }
@@ -142,7 +192,6 @@ export async function assembleArchivePrompt(
const parts: string[] = []; const parts: string[] = [];
parts.push(`# 归档阶段:${changeName}\n`); parts.push(`# 归档阶段:${changeName}\n`);
if (config.metadata?.tracked) {
const changeDir = getChangeDir(projectRoot, changeName); const changeDir = getChangeDir(projectRoot, changeName);
const taskPath = join(changeDir, "task.md"); const taskPath = join(changeDir, "task.md");
if (existsSync(taskPath)) { if (existsSync(taskPath)) {
@@ -168,7 +217,6 @@ export async function assembleArchivePrompt(
} }
} }
} }
}
parts.push(archive.prompt); parts.push(archive.prompt);
return applyCommandPrefix(parts.join("\n"), config); return applyCommandPrefix(parts.join("\n"), config);

View File

@@ -45,18 +45,6 @@ export function validateConfig(config: RuneConfig): void {
const plan = config.stages.plan; const plan = config.stages.plan;
if (!plan) return; if (!plan) return;
if (config.metadata?.tracked) {
const hasTaskDoc = plan.documents.some((d) => d.name === "task");
if (!hasTaskDoc) {
throw new ConfigError(
"配置校验失败开启了任务追踪metadata.tracked但规划阶段的文档列表中没有 task 文档",
{
hint: `请在 .rune/config.yaml 的 stages.plan.documents 中添加 task 文档:\n - name: task\n prompt: 生成任务清单\n depend: [design]`,
},
);
}
}
const docNames = new Set(plan.documents.map((d) => d.name)); const docNames = new Set(plan.documents.map((d) => d.name));
for (const doc of plan.documents) { for (const doc of plan.documents) {
@@ -110,7 +98,7 @@ export function validateConfig(config: RuneConfig): void {
function mergeConfig(userConfig: Partial<RuneConfig>): RuneConfig { function mergeConfig(userConfig: Partial<RuneConfig>): RuneConfig {
const result: RuneConfig = { stages: {} }; const result: RuneConfig = { stages: {} };
const stageKeys = ["discuss", "plan", "build", "archive"] as const; const stageKeys = ["discuss", "plan", "task", "build", "archive"] as const;
for (const stage of stageKeys) { for (const stage of stageKeys) {
if (userConfig.stages?.[stage]) { if (userConfig.stages?.[stage]) {

View File

@@ -44,7 +44,6 @@ export async function scanChanges(
const buildUnlocked = planCompleted; const buildUnlocked = planCompleted;
let taskProgress: { completed: number; total: number } | null = null; let taskProgress: { completed: number; total: number } | null = null;
if (config?.metadata?.tracked) {
const taskFile = files.find((d) => d === "task.md"); const taskFile = files.find((d) => d === "task.md");
if (taskFile) { if (taskFile) {
const content = await readFile(join(entryPath, taskFile), "utf-8"); const content = await readFile(join(entryPath, taskFile), "utf-8");
@@ -54,7 +53,6 @@ export async function scanChanges(
total: tasks.length, total: tasks.length,
}; };
} }
}
results.push({ results.push({
name: entry, name: entry,

View File

@@ -1,9 +1,6 @@
import type { RuneConfig } from "../types.ts"; import type { RuneConfig } from "../types.ts";
export const defaultConfig: RuneConfig = { export const defaultConfig: RuneConfig = {
metadata: {
tracked: true,
},
stages: { stages: {
discuss: { discuss: {
prompt: `进入探索模式。深度思考,自由发散。跟随对话走向。 prompt: `进入探索模式。深度思考,自由发散。跟随对话走向。
@@ -263,28 +260,23 @@ rune status
## 注意事项 ## 注意事项
`, `,
}, },
{ ],
name: "task", },
depend: ["design"], task: {
prompt: `请先获取当前规划状态 prompt: `你是一位高级软件工程师,擅长将设计文档拆解为可执行的任务列表
建议执行: rune status <变更名> 请先读取变更目录下所有已有的规划文档(如 design.md理解设计内容。
请根据设计文档,生成一份任务列表 然后将设计拆分为一份任务列表,写入 task.md
要求: 要求:
- 将设计拆分为可独立执行的小任务 - 将设计拆分为可独立执行的小任务
- 每个任务应该足够具体,能直接编码实现 - 每个任务应该足够具体,能直接编码实现
- 任务之间有合理的依赖顺序 - 任务之间有合理的依赖顺序
- 使用 checkbox 格式:- [ ] 待完成,- [x] 已完成 - 使用 checkbox 格式:- [ ] 待完成,- [x] 已完成
- 格式固定为 checkbox 列表,不需要模板
请将文档写入指定路径。`, 请将任务列表写入指定路径的 task.md 文件`,
template: `# 任务列表
- [ ]
`,
},
],
}, },
build: { build: {
prompt: `你是一位高级软件工程师。 prompt: `你是一位高级软件工程师。
@@ -295,7 +287,7 @@ rune status
- 每完成一个任务,立即更新 task.md 中对应项为 [x] - 每完成一个任务,立即更新 task.md 中对应项为 [x]
- 遵循项目现有的代码风格和约定 - 遵循项目现有的代码风格和约定
- 编写必要的测试 - 编写必要的测试
- 完成所有任务后,提示用户可以使用 /archive <变更名> 归档`, - 完成所有任务后,提示用户可以使用 /rune-archive <变更名> 归档`,
}, },
archive: { archive: {
prompt: `当前变更已进入归档阶段。 prompt: `当前变更已进入归档阶段。

View File

@@ -19,6 +19,10 @@ export interface PlanStage {
documents: DocumentConfig[]; documents: DocumentConfig[];
} }
export interface TaskStage {
prompt: string;
}
export interface BuildStage { export interface BuildStage {
prompt: string; prompt: string;
} }
@@ -30,6 +34,7 @@ export interface ArchiveStage {
export interface StagesConfig { export interface StagesConfig {
discuss?: DiscussStage; discuss?: DiscussStage;
plan?: PlanStage; plan?: PlanStage;
task?: TaskStage;
build?: BuildStage; build?: BuildStage;
archive?: ArchiveStage; archive?: ArchiveStage;
} }
@@ -38,7 +43,6 @@ export interface RuneConfig {
stages: StagesConfig; stages: StagesConfig;
metadata?: { metadata?: {
command?: string; command?: string;
tracked?: boolean;
}; };
} }
@@ -55,7 +59,7 @@ export interface ChangeStatus {
taskProgress: { completed: number; total: number } | null; taskProgress: { completed: number; total: number } | null;
} }
export const STAGES = ["discuss", "plan", "build", "archive"] as const; export const STAGES = ["discuss", "plan", "task", "build", "archive"] as const;
export type Stage = (typeof STAGES)[number]; export type Stage = (typeof STAGES)[number];
export const RUNE_DIR = ".rune"; export const RUNE_DIR = ".rune";

View File

@@ -53,7 +53,7 @@ describe("create 命令(工具命令,非 SDD 阶段)", () => {
it("create 不是 SDD 阶段常量之一", async () => { it("create 不是 SDD 阶段常量之一", async () => {
const { STAGES } = await import("../../src/types.ts"); const { STAGES } = await import("../../src/types.ts");
expect(STAGES).not.toContain("create"); expect(STAGES).not.toContain("create");
expect(STAGES).toHaveLength(4); expect(STAGES).toHaveLength(5);
}); });
}); });

View File

@@ -134,15 +134,12 @@ describe("suggestNextStep", () => {
expect(suggestNextStep(status)).toContain("rune archive test-change"); expect(suggestNextStep(status)).toContain("rune archive test-change");
}); });
it("规划完成但无 taskProgress 时建议 build", () => { it("规划完成但无 taskProgress 时建议 task", () => {
const status = makeStatus({ const status = makeStatus({
documents: [ documents: [{ name: "design", completed: true, dependMet: true }],
{ name: "design", completed: true, dependMet: true },
{ name: "task", completed: true, dependMet: true },
],
planCompleted: true, planCompleted: true,
taskProgress: null, taskProgress: null,
}); });
expect(suggestNextStep(status)).toContain("rune build test-change"); expect(suggestNextStep(status)).toContain("rune task test-change");
}); });
}); });

View File

@@ -80,7 +80,7 @@ describe("runInit", () => {
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8"); const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
expect(content).toContain("metadata"); expect(content).toContain("metadata");
expect(content).toContain("tracked"); expect(content).toContain("command");
}); });
it("config.yaml 模板包含 create 命令说明", async () => { it("config.yaml 模板包含 create 命令说明", async () => {

View File

@@ -6,6 +6,7 @@ import {
assemblePlanPrompt, assemblePlanPrompt,
assembleBuildPrompt, assembleBuildPrompt,
assembleArchivePrompt, assembleArchivePrompt,
assembleTaskPrompt,
} from "../../src/core/assembler.ts"; } from "../../src/core/assembler.ts";
import type { RuneConfig } from "../../src/types.ts"; import type { RuneConfig } from "../../src/types.ts";
import { defaultConfig } from "../../src/defaults/config.ts"; import { defaultConfig } from "../../src/defaults/config.ts";
@@ -150,28 +151,18 @@ describe("assembleBuildPrompt", () => {
await assembleBuildPrompt(defaultConfig, TMP_DIR, "nonexistent"); await assembleBuildPrompt(defaultConfig, TMP_DIR, "nonexistent");
expect.unreachable(); expect.unreachable();
} catch (e: any) { } catch (e: any) {
expect(e.message).toContain("尚未完成规划"); expect(e.message).toContain("尚未完成任务拆解");
expect(e.message).toContain("nonexistent"); expect(e.message).toContain("nonexistent");
expect(e.hint).toContain("plan nonexistent"); expect(e.hint).toContain("task");
} }
}); });
it("tracked=false 时只输出通用提示词", async () => { it("task.md 格式不合法时抛错", async () => {
const config: RuneConfig = {
stages: { build: { prompt: "按规划文档逐步实现功能" } },
metadata: { tracked: false },
};
const prompt = await assembleBuildPrompt(config, TMP_DIR, "user-auth");
expect(prompt).toBe("按规划文档逐步实现功能");
});
it("tracked=true 且 task.md 格式不合法时抛错", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true }); await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "task.md"), "# 标题\n无 checkbox"); await writeFile(join(changeDir, "task.md"), "# 标题\n无 checkbox");
const config: RuneConfig = { const config: RuneConfig = {
stages: { build: { prompt: "构建阶段" } }, stages: { build: { prompt: "构建阶段" } },
metadata: { tracked: true },
}; };
try { try {
await assembleBuildPrompt(config, TMP_DIR, "user-auth"); await assembleBuildPrompt(config, TMP_DIR, "user-auth");
@@ -183,13 +174,12 @@ describe("assembleBuildPrompt", () => {
} }
}); });
it("tracked=true 且 task.md 有空 checkbox 文本时抛错", async () => { it("task.md 有空 checkbox 文本时抛错", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true }); await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "task.md"), "- [ ] \n- [x] 有内容"); await writeFile(join(changeDir, "task.md"), "- [ ] \n- [x] 有内容");
const config: RuneConfig = { const config: RuneConfig = {
stages: { build: { prompt: "构建阶段" } }, stages: { build: { prompt: "构建阶段" } },
metadata: { tracked: true },
}; };
try { try {
await assembleBuildPrompt(config, TMP_DIR, "user-auth"); await assembleBuildPrompt(config, TMP_DIR, "user-auth");
@@ -210,44 +200,110 @@ describe("assembleArchivePrompt", () => {
expect(prompt).toContain("归档"); expect(prompt).toContain("归档");
}); });
it("tracked=false 时不读取 task.md只输出通用提示词", async () => { it("未完成任务时注入警告并引导用户确认", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "task.md"), "- [ ] 未完成任务");
const config: RuneConfig = {
stages: { archive: { prompt: "确认归档" } },
metadata: { tracked: false },
};
const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth");
expect(prompt).toContain("确认归档");
expect(prompt).not.toContain("未完成");
});
it("tracked=true 时内嵌未完成任务列表并引导用户确认", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "task.md"), "- [ ] 未完成任务");
const config: RuneConfig = { const config: RuneConfig = {
stages: { archive: { prompt: "归档阶段" } }, stages: { archive: { prompt: "归档阶段" } },
metadata: { tracked: true },
}; };
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "task.md"), "- [ ] 未完成任务");
const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth"); const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth");
expect(prompt).toContain("未完成"); expect(prompt).toContain("未完成");
expect(prompt).toContain("未完成任务"); expect(prompt).toContain("未完成任务");
expect(prompt).toContain("是否确认"); expect(prompt).toContain("是否确认");
}); });
it("tracked=true 且所有任务完成时不注入警告", async () => { it("所有任务完成时不注入警告", async () => {
const config: RuneConfig = {
stages: { archive: { prompt: "归档阶段" } },
};
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true }); await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "task.md"), "- [x] 已完成任务"); await writeFile(join(changeDir, "task.md"), "- [x] 已完成任务");
const config: RuneConfig = {
stages: { archive: { prompt: "归档阶段" } },
metadata: { tracked: true },
};
const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth"); const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth");
expect(prompt).not.toContain("未完成"); expect(prompt).not.toContain("未完成");
}); });
it("task.md 不存在时不注入警告", async () => {
const config: RuneConfig = {
stages: { archive: { prompt: "归档阶段" } },
};
const prompt = await assembleArchivePrompt(config, TMP_DIR, "no-task-change");
expect(prompt).toContain("归档阶段");
expect(prompt).not.toContain("警告");
});
});
describe("assembleTaskPrompt", () => {
it("任务拆解阶段提示词包含变更名和文档路径", async () => {
const config: RuneConfig = {
stages: {
plan: { documents: [{ name: "design", prompt: "生成设计" }] },
task: { prompt: "拆解任务" },
},
};
const changeDir = join(TMP_DIR, ".rune", "changes", "feature-x");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 设计");
const prompt = await assembleTaskPrompt(config, TMP_DIR, "feature-x");
expect(prompt).toContain("feature-x");
expect(prompt).toContain("design.md");
});
it("task.md 已存在时提示修订", async () => {
const config: RuneConfig = {
stages: {
plan: { documents: [{ name: "design", prompt: "生成设计" }] },
task: { prompt: "拆解任务" },
},
};
const changeDir = join(TMP_DIR, ".rune", "changes", "revise-task");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 设计");
await writeFile(join(changeDir, "task.md"), "- [ ] 已有任务");
const prompt = await assembleTaskPrompt(config, TMP_DIR, "revise-task");
expect(prompt).toContain("已有内容");
});
it("task 阶段未配置时抛错", async () => {
const config: RuneConfig = {
stages: { plan: { documents: [{ name: "design", prompt: "生成设计" }] } },
};
try {
await assembleTaskPrompt(config, TMP_DIR, "test");
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("任务拆解阶段未配置");
}
});
it("plan 阶段未配置时抛错", async () => {
const config: RuneConfig = {
stages: { task: { prompt: "拆解任务" } },
};
try {
await assembleTaskPrompt(config, TMP_DIR, "test");
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("规划阶段未配置");
}
});
it("plan 文档未完成时抛错", async () => {
const config: RuneConfig = {
stages: {
plan: { documents: [{ name: "design", prompt: "生成设计" }] },
task: { prompt: "拆解任务" },
},
};
try {
await assembleTaskPrompt(config, TMP_DIR, "incomplete-plan");
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("规划文档");
expect(e.message).toContain("尚未全部完成");
}
});
}); });
describe("命令前缀替换", () => { describe("命令前缀替换", () => {
@@ -273,13 +329,13 @@ describe("命令前缀替换", () => {
it("assembleBuildPrompt 错误提示使用动态前缀", async () => { it("assembleBuildPrompt 错误提示使用动态前缀", async () => {
const config: RuneConfig = { const config: RuneConfig = {
stages: { build: { prompt: "构建阶段" } }, stages: { build: { prompt: "构建阶段" } },
metadata: { command: "pnpx @lanyuanxiaoyao/rune", tracked: true }, metadata: { command: "pnpx @lanyuanxiaoyao/rune" },
}; };
try { try {
await assembleBuildPrompt(config, TMP_DIR, "nonexistent-build"); await assembleBuildPrompt(config, TMP_DIR, "nonexistent-build");
expect.unreachable(); expect.unreachable();
} catch (e: any) { } catch (e: any) {
expect(e.hint).toContain("pnpx @lanyuanxiaoyao/rune plan nonexistent-build"); expect(e.hint).toContain("pnpx @lanyuanxiaoyao/rune task nonexistent-build");
} }
}); });

View File

@@ -39,6 +39,7 @@ describe("loadConfig", () => {
const config = await loadConfig(TMP_DIR); const config = await loadConfig(TMP_DIR);
expect(config.stages.discuss).toBeDefined(); expect(config.stages.discuss).toBeDefined();
expect(config.stages.plan).toBeDefined(); expect(config.stages.plan).toBeDefined();
expect(config.stages.task).toBeDefined();
expect(config.stages.build).toBeDefined(); expect(config.stages.build).toBeDefined();
expect(config.stages.archive).toBeDefined(); expect(config.stages.archive).toBeDefined();
}); });
@@ -65,9 +66,7 @@ describe("loadConfig", () => {
await mkdir(runeDir, { recursive: true }); await mkdir(runeDir, { recursive: true });
await writeFile( await writeFile(
join(runeDir, "config.yaml"), join(runeDir, "config.yaml"),
`metadata: `stages:
tracked: false
stages:
plan: plan:
documents: documents:
- name: spec - name: spec
@@ -86,6 +85,7 @@ stages:
const config = await loadConfig(TMP_DIR); const config = await loadConfig(TMP_DIR);
expect(config.stages.discuss).toBeDefined(); expect(config.stages.discuss).toBeDefined();
expect(config.stages.plan).toBeDefined(); expect(config.stages.plan).toBeDefined();
expect(config.stages.task).toBeDefined();
expect(config.stages.build).toBeDefined(); expect(config.stages.build).toBeDefined();
expect(config.stages.archive).toBeDefined(); expect(config.stages.archive).toBeDefined();
}); });
@@ -170,56 +170,6 @@ describe("validateConfig", () => {
}; };
expect(() => validateConfig(config)).not.toThrow(); expect(() => validateConfig(config)).not.toThrow();
}); });
it("tracked=true 时 plan.documents 必须包含 task 文档", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计" }],
},
},
metadata: { tracked: true },
};
expect(() => validateConfig(config)).toThrow(ConfigError);
});
it("tracked=true 且 plan.documents 包含 task 时不报错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务" },
],
},
},
metadata: { tracked: true },
};
expect(() => validateConfig(config)).not.toThrow();
});
it("tracked=false 时 plan.documents 不包含 task 也不报错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计" }],
},
},
metadata: { tracked: false },
};
expect(() => validateConfig(config)).not.toThrow();
});
it("tracked 未配置时等同于 false不强制要求 task 文档", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计" }],
},
},
};
expect(() => validateConfig(config)).not.toThrow();
});
}); });
describe("mergeConfig 保留 metadata", () => { describe("mergeConfig 保留 metadata", () => {
@@ -242,52 +192,28 @@ describe("mergeConfig 保留 metadata", () => {
} }
}); });
it("无 metadata 时保留默认 metadatatracked: true", async () => { it("默认配置包含 task 阶段", async () => {
const tmpDir = join(import.meta.dir, "__tmp_config_nometa_test__"); const config = await loadConfig(TMP_DIR);
await mkdir(tmpDir, { recursive: true }); expect(config.stages.task).toBeDefined();
try { expect(config.stages.task!.prompt).toBeTruthy();
const configPath = join(tmpDir, ".rune", "config.yaml");
await mkdir(join(tmpDir, ".rune"), { recursive: true });
await writeFile(configPath, `stages:\n discuss:\n prompt: "测试"\n`);
const config = await loadConfig(tmpDir);
expect(config.metadata?.tracked).toBe(true);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}); });
it("用户 metadata 与默认 metadata 深合并", async () => { it("默认 metadata 不包含 tracked", async () => {
const tmpDir = join(import.meta.dir, "__tmp_config_deep_merge__"); const config = await loadConfig(TMP_DIR);
await mkdir(tmpDir, { recursive: true }); expect((config.metadata as any)?.tracked).toBeUndefined();
try {
const configPath = join(tmpDir, ".rune", "config.yaml");
await mkdir(join(tmpDir, ".rune"), { recursive: true });
await writeFile(
configPath,
`metadata:\n command: "rune"\nstages:\n discuss:\n prompt: "测试"\n`,
);
const config = await loadConfig(tmpDir);
expect(config.metadata?.command).toBe("rune");
expect(config.metadata?.tracked).toBe(true);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}); });
it("用户 metadata.tracked 显式覆盖默认", async () => { it("用户配置 task 阶段覆盖默认", async () => {
const tmpDir = join(import.meta.dir, "__tmp_config_tracked_override__"); const runeDir = join(TMP_DIR, ".rune");
await mkdir(tmpDir, { recursive: true }); await mkdir(runeDir, { recursive: true });
try {
const configPath = join(tmpDir, ".rune", "config.yaml");
await mkdir(join(tmpDir, ".rune"), { recursive: true });
await writeFile( await writeFile(
configPath, join(runeDir, "config.yaml"),
`metadata:\n tracked: false\nstages:\n discuss:\n prompt: "测试"\n`, `stages:
task:
prompt: 自定义任务提示词
`,
); );
const config = await loadConfig(tmpDir); const config = await loadConfig(TMP_DIR);
expect(config.metadata?.tracked).toBe(false); expect(config.stages.task!.prompt).toBe("自定义任务提示词");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}); });
}); });

View File

@@ -21,31 +21,14 @@ describe("scanChanges", () => {
expect(changes).toEqual([]); expect(changes).toEqual([]);
}); });
it("扫描到变更及其文档", async () => { it("有 task.md 时无条件计算 taskProgress", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes"); const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "user-auth"), { recursive: true }); await mkdir(join(changesDir, "user-auth"), { recursive: true });
await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计"); await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计");
await writeFile(join(changesDir, "user-auth", "task.md"), `- [x] 任务一\n- [ ] 任务二`); await writeFile(join(changesDir, "user-auth", "task.md"), `- [x] 任务一\n- [ ] 任务二`);
const config: RuneConfig = { const changes = await scanChanges(TMP_DIR);
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务" },
],
},
},
metadata: { tracked: true },
};
const changes = await scanChanges(TMP_DIR, config);
expect(changes).toHaveLength(1); expect(changes).toHaveLength(1);
expect(changes[0].name).toBe("user-auth");
const docNames = changes[0].documents.map((d) => `${d.name}.md`);
expect(docNames).toContain("design.md");
expect(docNames).toContain("task.md");
expect(changes[0].planCompleted).toBe(true);
expect(changes[0].buildUnlocked).toBe(true);
expect(changes[0].taskProgress).toEqual({ completed: 1, total: 2 }); expect(changes[0].taskProgress).toEqual({ completed: 1, total: 2 });
}); });
@@ -156,59 +139,6 @@ describe("scanChanges", () => {
expect(changes[0].planCompleted).toBe(false); expect(changes[0].planCompleted).toBe(false);
expect(changes[0].buildUnlocked).toBe(false); expect(changes[0].buildUnlocked).toBe(false);
}); });
it("tracked=false 时不扫描 task.mdtaskProgress 为 null", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "test-change"), { recursive: true });
await writeFile(join(changesDir, "test-change", "design.md"), "# 设计");
await writeFile(join(changesDir, "test-change", "task.md"), "- [x] 完成\n- [ ] 未完成");
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计" }],
},
},
metadata: { tracked: false },
};
const results = await scanChanges(TMP_DIR, config);
expect(results[0].taskProgress).toBeNull();
});
it("tracked=true 时扫描 task.mdtaskProgress 有值", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "test-change2"), { recursive: true });
await writeFile(join(changesDir, "test-change2", "design.md"), "# 设计");
await writeFile(join(changesDir, "test-change2", "task.md"), "- [x] 完成\n- [ ] 未完成");
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务" },
],
},
},
metadata: { tracked: true },
};
const results = await scanChanges(TMP_DIR, config);
expect(results[0].taskProgress).toEqual({ completed: 1, total: 2 });
});
it("tracked 未配置undefined时不扫描 task.md", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "test-change3"), { recursive: true });
await writeFile(join(changesDir, "test-change3", "design.md"), "# 设计");
await writeFile(join(changesDir, "test-change3", "task.md"), "- [x] 完成");
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计" }],
},
},
};
const results = await scanChanges(TMP_DIR, config);
expect(results[0].taskProgress).toBeNull();
});
}); });
describe("scanArchives", () => { describe("scanArchives", () => {

View File

@@ -2,9 +2,10 @@ import { describe, it, expect } from "bun:test";
import { defaultConfig } from "../../src/defaults/config.ts"; import { defaultConfig } from "../../src/defaults/config.ts";
describe("defaultConfig", () => { describe("defaultConfig", () => {
it("包含所有个阶段的配置", () => { it("包含所有个阶段的配置", () => {
expect(defaultConfig.stages.discuss).toBeDefined(); expect(defaultConfig.stages.discuss).toBeDefined();
expect(defaultConfig.stages.plan).toBeDefined(); expect(defaultConfig.stages.plan).toBeDefined();
expect(defaultConfig.stages.task).toBeDefined();
expect(defaultConfig.stages.build).toBeDefined(); expect(defaultConfig.stages.build).toBeDefined();
expect(defaultConfig.stages.archive).toBeDefined(); expect(defaultConfig.stages.archive).toBeDefined();
}); });
@@ -42,25 +43,11 @@ describe("defaultConfig", () => {
expect(prompt).toContain("task.md"); expect(prompt).toContain("task.md");
}); });
it("plan 阶段包含 design 和 task 两个文档配置", () => { it("plan 阶段包含 design 文档配置", () => {
const docs = defaultConfig.stages.plan!.documents; const docs = defaultConfig.stages.plan!.documents;
expect(docs).toHaveLength(2); expect(docs).toHaveLength(1);
expect(docs[0].name).toBe("design"); expect(docs[0].name).toBe("design");
expect(docs[1].name).toBe("task"); expect(docs[0].prompt).toBeTruthy();
for (const doc of docs) {
expect(doc.prompt).toBeTruthy();
}
});
it("plan 的 task 文档配置存在", () => {
const taskDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "task");
expect(taskDoc).toBeDefined();
expect(taskDoc!.prompt).toBeTruthy();
});
it("task 文档依赖 design", () => {
const taskDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "task");
expect(taskDoc!.depend).toEqual(["design"]);
}); });
it("design 文档有 template", () => { it("design 文档有 template", () => {
@@ -69,10 +56,9 @@ describe("defaultConfig", () => {
expect(designDoc!.template).toContain("设计文档"); expect(designDoc!.template).toContain("设计文档");
}); });
it("task 文档有 template", () => { it("task 阶段有 prompt", () => {
const taskDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "task"); expect(defaultConfig.stages.task).toBeDefined();
expect(taskDoc!.template).toBeTruthy(); expect(defaultConfig.stages.task!.prompt).toBeTruthy();
expect(taskDoc!.template).toContain("- [ ]");
}); });
it("build 阶段有 prompt", () => { it("build 阶段有 prompt", () => {

View File

@@ -7,6 +7,7 @@ import { loadConfig, getChangeDir, getArchiveDir } from "../../src/core/config.t
import { import {
assembleDiscussPrompt, assembleDiscussPrompt,
assemblePlanPrompt, assemblePlanPrompt,
assembleTaskPrompt,
assembleBuildPrompt, assembleBuildPrompt,
assembleArchivePrompt, assembleArchivePrompt,
} from "../../src/core/assembler.ts"; } from "../../src/core/assembler.ts";
@@ -23,7 +24,7 @@ afterEach(async () => {
}); });
describe("完整 SDD 流程", () => { describe("完整 SDD 流程", () => {
it("init → discuss → plan → build → archive 完整流程", async () => { it("init → discuss → plan → task → build → archive 完整流程", async () => {
await runInit(TMP_DIR, ["opencode"]); await runInit(TMP_DIR, ["opencode"]);
expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true); expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true);
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true); expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
@@ -39,6 +40,12 @@ describe("完整 SDD 流程", () => {
const changeDir = getChangeDir(TMP_DIR, changeName); const changeDir = getChangeDir(TMP_DIR, changeName);
await writeFile(join(changeDir, "design.md"), "# 用户认证设计\n\n## 背景\n需要用户登录功能"); await writeFile(join(changeDir, "design.md"), "# 用户认证设计\n\n## 背景\n需要用户登录功能");
const taskPrompt = await assembleTaskPrompt(config, TMP_DIR, changeName);
expect(taskPrompt).toContain("user-auth");
expect(taskPrompt).toContain("task.md");
expect(taskPrompt).toContain("design.md");
await writeFile(join(changeDir, "task.md"), "- [ ] 实现登录 API\n- [ ] 编写登录测试"); await writeFile(join(changeDir, "task.md"), "- [ ] 实现登录 API\n- [ ] 编写登录测试");
const changes = await scanChanges(TMP_DIR, config); const changes = await scanChanges(TMP_DIR, config);
@@ -97,9 +104,7 @@ describe("完整 SDD 流程", () => {
await writeFile( await writeFile(
join(TMP_DIR, ".rune", "config.yaml"), join(TMP_DIR, ".rune", "config.yaml"),
`metadata: `stages:
tracked: false
stages:
discuss: discuss:
prompt: 自定义讨论 prompt: 自定义讨论
plan: plan:
@@ -121,6 +126,7 @@ stages:
expect(planPrompt).toContain("规格文档"); expect(planPrompt).toContain("规格文档");
expect(planPrompt).not.toContain("design"); expect(planPrompt).not.toContain("design");
expect(config.stages.task).toBeDefined();
expect(config.stages.build).toBeDefined(); expect(config.stages.build).toBeDefined();
expect(config.stages.archive).toBeDefined(); expect(config.stages.archive).toBeDefined();
}); });
@@ -139,11 +145,6 @@ stages:
const designDoc = changes[0].documents.find((d) => d.name === "design"); const designDoc = changes[0].documents.find((d) => d.name === "design");
expect(designDoc).toBeDefined(); expect(designDoc).toBeDefined();
expect(designDoc!.completed).toBe(true); expect(designDoc!.completed).toBe(true);
const taskDoc = changes[0].documents.find((d) => d.name === "task");
expect(taskDoc).toBeDefined();
expect(taskDoc!.completed).toBe(false);
expect(taskDoc!.dependMet).toBe(true);
}); });
}); });